cctx-cli 1.7.0__tar.gz → 1.9.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.7.0 → cctx_cli-1.9.0}/CHANGELOG.md +81 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/PKG-INFO +1 -1
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/__init__.py +1 -1
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/cli.py +25 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/__init__.py +58 -3
- cctx_cli-1.9.0/cctx/diagnostician/patterns/fan_out.py +175 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/harvest.py +35 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/models.py +28 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/recommender/claude_md.py +10 -0
- cctx_cli-1.9.0/cctx/recommender/evidence.py +129 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/renderers/terminal.py +93 -1
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/pyproject.toml +1 -1
- cctx_cli-1.9.0/tests/test_efficacy.py +389 -0
- cctx_cli-1.9.0/tests/test_fanout_classifier.py +344 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_harvest_emit.py +2 -1
- cctx_cli-1.7.0/cctx/recommender/evidence.py +0 -46
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/.github/workflows/ci.yml +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/.github/workflows/publish.yml +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/.github/workflows/release.yml +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/.gitignore +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/CLAUDE.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/DESIGN.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/PRODUCT.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/README.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/action.yml +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/agents.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/aggregate.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/inflection.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/patterns/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/patterns/project_specific.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/discovery.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/exporters/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/exporters/csv.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/exporters/json.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/exporters/jsonl.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/parsers/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/parsers/claude_code.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/pricing.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/recommender/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/renderers/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/renderers/github.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/renderers/report.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/renderers/trace_tui.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/tokenizer.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx/watcher.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/cctx-project-brief.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/demo.gif +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/demo.tape +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/product-reviews/2026-06-09-product-review.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/plans/2026-05-17-harvest-check-depth.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/plans/2026-05-19-claude-agents-live-integration.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-05-17-harvest-check-depth-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-05-19-claude-agents-live-integration-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/docs/superpowers/specs/2026-06-09-cross-agent-emit-design.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/conftest.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/conftest.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/test_dead_end.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/test_inflection.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/test_orchestrator.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/test_project_specific.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/test_retry_loop.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/test_scope_creep.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/test_stale_context.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/diagnostician/test_tool_thrash.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/exporters/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/exporters/test_csv.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/exporters/test_jsonl.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/README.md +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a0b4c2cf1dde0ca56.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a116ae34b1b09c332.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1c4c417b35658c9e.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1e41a901de38f1b5.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a338f8d0c74612a24.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a34f6f3c0e7094186.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a5a5a0cff4d13308b.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a6b0a3da6a0484db5.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f73f1790b02cde5.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f7c17c38a9d8788.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a853259e2cd7bbe8a.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a8d9aedb0d0c6e12d.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aa778bc1d59e4a441.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aba869dedee4a12ba.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-ada2746d9774b94db.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea0132068c64d2dd.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea215eff50874d5f.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-afee21f2b3852a4a0.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.meta.json +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results/tool-results/bosbkda0h.txt +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/scrub.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/parsers/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/parsers/test_claude_code.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/parsers/test_claude_code_integration.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/recommender/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/recommender/test_claude_md.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/recommender/test_evidence.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/renderers/__init__.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/renderers/test_report.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_agents.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_aggregate.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_cli.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_cli_export.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_diagnostician_subagents.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_discovery.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_github_summary.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_harvest.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_harvest_check.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_models.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_models_project_pattern.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_recommender.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_smoke.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_terminal_renderer.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_tokenizer.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_trace_tui.py +0 -0
- {cctx_cli-1.7.0 → cctx_cli-1.9.0}/tests/test_watcher.py +0 -0
|
@@ -2,6 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v1.9.0 (2026-06-11)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Combine split import in test_efficacy (ruff I001)
|
|
10
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
11
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
12
|
+
|
|
13
|
+
- Normalize Z-suffix UTC timestamps for Python 3.10 fromisoformat
|
|
14
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
15
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
16
|
+
|
|
17
|
+
- Remove unused timezone import (ruff F401) ([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
18
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
19
|
+
|
|
20
|
+
- Use %aI git format for Python 3.10 compat; guard total_after==0 signal
|
|
21
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
22
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
|
|
26
|
+
- Add managed_heading_dates to harvest.py module docstring
|
|
27
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
28
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
29
|
+
|
|
30
|
+
### Features
|
|
31
|
+
|
|
32
|
+
- EfficacyRow + EfficacyReport dataclasses (M17 #90)
|
|
33
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
34
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
35
|
+
|
|
36
|
+
- Evidence.efficacy — before/after session bucketing (M17 #90)
|
|
37
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
38
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
39
|
+
|
|
40
|
+
- Harvest --efficacy CLI flag (M17 #90) ([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
41
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
42
|
+
|
|
43
|
+
- Managed_heading_dates — git-based patch introduction dates (M17 #90)
|
|
44
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
45
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
46
|
+
|
|
47
|
+
- Patch efficacy report — harvest --efficacy (M17 #90)
|
|
48
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
49
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
50
|
+
|
|
51
|
+
- Render_efficacy_report — efficacy table renderer (M17 #90)
|
|
52
|
+
([#111](https://github.com/jacquardlabs/cctx/pull/111),
|
|
53
|
+
[`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
## v1.8.0 (2026-06-11)
|
|
57
|
+
|
|
58
|
+
### Bug Fixes
|
|
59
|
+
|
|
60
|
+
- Add _FANOUT_WASTE_DIFF template so MANAGED_HEADINGS stays in sync
|
|
61
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
62
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
63
|
+
|
|
64
|
+
- Ruff lint — move imports to top, break long lines in test file
|
|
65
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
66
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
67
|
+
|
|
68
|
+
### Features
|
|
69
|
+
|
|
70
|
+
- Add FindingKind.FANOUT_WASTE + KIND_LABEL + MANAGED_HEADINGS
|
|
71
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
72
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
73
|
+
|
|
74
|
+
- Fan-out waste classifier (M16 #89) ([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
75
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
76
|
+
|
|
77
|
+
- Fan_out classifier — Signal A (overlap) + Signal B (retry)
|
|
78
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
79
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
80
|
+
|
|
81
|
+
- Wire fan_out classifier into diagnostician, add _patch_fanout_costs
|
|
82
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
83
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
84
|
+
|
|
85
|
+
|
|
5
86
|
## v1.7.0 (2026-06-11)
|
|
6
87
|
|
|
7
88
|
### Bug Fixes
|
|
@@ -32,6 +32,7 @@ from cctx.renderers.terminal import (
|
|
|
32
32
|
render_aggregate,
|
|
33
33
|
render_aggregate_drilldown,
|
|
34
34
|
render_diagnosis,
|
|
35
|
+
render_efficacy_report,
|
|
35
36
|
render_harvest_results,
|
|
36
37
|
render_projects,
|
|
37
38
|
render_sessions,
|
|
@@ -587,6 +588,13 @@ def trace(target: Path | None, latest: bool) -> None:
|
|
|
587
588
|
help="With --emit: also mirror already-harvested cctx-managed sections "
|
|
588
589
|
"from CLAUDE.md into the emit target.",
|
|
589
590
|
)
|
|
591
|
+
@click.option(
|
|
592
|
+
"--efficacy",
|
|
593
|
+
"efficacy_mode",
|
|
594
|
+
is_flag=True,
|
|
595
|
+
default=False,
|
|
596
|
+
help="Report whether applied patches reduced their target patterns (before vs. after).",
|
|
597
|
+
)
|
|
590
598
|
def harvest(
|
|
591
599
|
target: Path,
|
|
592
600
|
since: str | None,
|
|
@@ -597,6 +605,7 @@ def harvest(
|
|
|
597
605
|
check_severity: str,
|
|
598
606
|
emit_targets: tuple[str, ...],
|
|
599
607
|
sync_mode: bool,
|
|
608
|
+
efficacy_mode: bool,
|
|
600
609
|
) -> None:
|
|
601
610
|
"""Apply autopsy patches to CLAUDE.md."""
|
|
602
611
|
from cctx.harvest import (
|
|
@@ -610,6 +619,22 @@ def harvest(
|
|
|
610
619
|
if sync_mode and not emit_targets:
|
|
611
620
|
raise click.UsageError("--sync requires --emit.")
|
|
612
621
|
|
|
622
|
+
if efficacy_mode:
|
|
623
|
+
if target.is_file():
|
|
624
|
+
raise click.UsageError(
|
|
625
|
+
"--efficacy requires a project directory, not a .jsonl file."
|
|
626
|
+
)
|
|
627
|
+
resolved_dir = target_dir or Path.cwd()
|
|
628
|
+
from cctx.harvest import managed_heading_dates
|
|
629
|
+
from cctx.recommender.evidence import efficacy as _run_efficacy
|
|
630
|
+
start = datetime(2020, 1, 1, tzinfo=UTC)
|
|
631
|
+
end = datetime(2035, 1, 1, tzinfo=UTC)
|
|
632
|
+
pairs = aggregate.run(target, start, end)
|
|
633
|
+
h_dates = managed_heading_dates(resolved_dir)
|
|
634
|
+
report = _run_efficacy(pairs, h_dates)
|
|
635
|
+
render_efficacy_report(report, resolved_dir, target)
|
|
636
|
+
return
|
|
637
|
+
|
|
613
638
|
if check_mode:
|
|
614
639
|
from cctx.harvest import CheckSeverity
|
|
615
640
|
resolved_dir = target_dir or Path.cwd()
|
|
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING
|
|
|
16
16
|
from cctx.diagnostician import inflection
|
|
17
17
|
from cctx.diagnostician.patterns import (
|
|
18
18
|
dead_end,
|
|
19
|
+
fan_out,
|
|
19
20
|
retry_loop,
|
|
20
21
|
scope_creep,
|
|
21
22
|
stale_context,
|
|
@@ -41,6 +42,45 @@ def _patch_costs(findings: list[Finding], model: str | None) -> list[Finding]:
|
|
|
41
42
|
return result
|
|
42
43
|
|
|
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
|
+
|
|
44
84
|
def _compute_own_cost(trace: SessionTrace, model: str | None) -> float:
|
|
45
85
|
"""Parent-turns-only cost — does not recurse into subagents.
|
|
46
86
|
|
|
@@ -111,17 +151,32 @@ def run(trace: SessionTrace) -> Diagnosis:
|
|
|
111
151
|
*stale_context.classify(trace),
|
|
112
152
|
*tool_thrash.classify(trace),
|
|
113
153
|
*dead_end.classify(trace),
|
|
154
|
+
*fan_out.classify(trace),
|
|
114
155
|
]
|
|
115
156
|
findings.sort(key=lambda f: f.first_turn)
|
|
116
157
|
|
|
117
158
|
inflection_turn = inflection.detect(findings)
|
|
118
159
|
findings = _patch_costs(findings, trace.primary_model)
|
|
119
160
|
|
|
161
|
+
# Fan-out cost patching requires attributions first.
|
|
162
|
+
subagent_costs = _collect_attributions(trace)
|
|
163
|
+
findings = _patch_fanout_costs(findings, subagent_costs)
|
|
164
|
+
|
|
120
165
|
total_cost = round(_compute_inclusive_cost(trace), 4)
|
|
121
|
-
waste_cost = sum(f.cost_usd for f in findings if f.cost_usd is not None)
|
|
122
|
-
waste_cost = min(waste_cost, total_cost)
|
|
123
166
|
|
|
124
|
-
|
|
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)
|
|
125
180
|
|
|
126
181
|
return Diagnosis(
|
|
127
182
|
session_id=trace.session_id,
|
|
@@ -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 []
|
|
@@ -5,6 +5,7 @@ Public API:
|
|
|
5
5
|
preview_patches(patches, target_dir) -> list[ApplyResult]
|
|
6
6
|
apply_patches(patches, target_dir) -> list[ApplyResult]
|
|
7
7
|
check_claude_md(target_dir) -> list[CheckFinding]
|
|
8
|
+
managed_heading_dates(target_dir) -> dict[str, datetime | None]
|
|
8
9
|
|
|
9
10
|
Layering rules (MUST respect):
|
|
10
11
|
- Does NOT import click, rich_click, or anthropic.
|
|
@@ -15,8 +16,10 @@ from __future__ import annotations
|
|
|
15
16
|
|
|
16
17
|
import dataclasses
|
|
17
18
|
import re
|
|
19
|
+
import subprocess
|
|
18
20
|
from collections import defaultdict
|
|
19
21
|
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime
|
|
20
23
|
from enum import Enum
|
|
21
24
|
from pathlib import Path
|
|
22
25
|
from typing import TYPE_CHECKING
|
|
@@ -276,6 +279,38 @@ def apply_patches(patches: list[Patch], target_dir: Path) -> list[ApplyResult]:
|
|
|
276
279
|
return [apply_patch(patch, target_dir) for patch in patches]
|
|
277
280
|
|
|
278
281
|
|
|
282
|
+
def managed_heading_dates(target_dir: Path) -> dict[str, datetime | None]:
|
|
283
|
+
"""Return the git introduction date for each MANAGED_HEADINGS heading.
|
|
284
|
+
|
|
285
|
+
For each heading, runs:
|
|
286
|
+
git log --reverse --format="%aI" -S"<heading>" -- CLAUDE.md
|
|
287
|
+
|
|
288
|
+
--reverse gives oldest-first; the first line is the introduction commit.
|
|
289
|
+
-S (pickaxe) fires when the occurrence count of the literal string changes.
|
|
290
|
+
Returns None for any heading not found in git history, or if git fails.
|
|
291
|
+
Never raises.
|
|
292
|
+
"""
|
|
293
|
+
result: dict[str, datetime | None] = {}
|
|
294
|
+
for heading in MANAGED_HEADINGS.values():
|
|
295
|
+
try:
|
|
296
|
+
proc = subprocess.run(
|
|
297
|
+
["git", "log", "--reverse", "--format=%aI", f"-S{heading}", "--", "CLAUDE.md"],
|
|
298
|
+
cwd=target_dir,
|
|
299
|
+
capture_output=True,
|
|
300
|
+
text=True,
|
|
301
|
+
timeout=10,
|
|
302
|
+
)
|
|
303
|
+
lines = proc.stdout.strip().splitlines()
|
|
304
|
+
if lines:
|
|
305
|
+
date_str = lines[0].replace("Z", "+00:00")
|
|
306
|
+
result[heading] = datetime.fromisoformat(date_str)
|
|
307
|
+
else:
|
|
308
|
+
result[heading] = None
|
|
309
|
+
except Exception: # noqa: BLE001
|
|
310
|
+
result[heading] = None
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
|
|
279
314
|
# ---------------------------------------------------------------------------
|
|
280
315
|
# harvest --check
|
|
281
316
|
# ---------------------------------------------------------------------------
|
|
@@ -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
|
|
@@ -294,6 +297,31 @@ class AggregateReport:
|
|
|
294
297
|
project_patterns: list[ProjectPattern] = field(default_factory=list)
|
|
295
298
|
|
|
296
299
|
|
|
300
|
+
@dataclass
|
|
301
|
+
class EfficacyRow:
|
|
302
|
+
"""One row in a patch efficacy report — before/after session counts for a managed heading."""
|
|
303
|
+
|
|
304
|
+
heading: str # e.g. "## Retry discipline"
|
|
305
|
+
kind: FindingKind | None # reverse lookup from MANAGED_HEADINGS; None = not found
|
|
306
|
+
applied_at: datetime | None # first git commit that introduced this heading; None if unknown
|
|
307
|
+
sessions_before: int # sessions with this kind's finding before applied_at
|
|
308
|
+
sessions_after: int # sessions with this kind's finding from applied_at onward
|
|
309
|
+
total_before: int # total sessions analysed before applied_at
|
|
310
|
+
total_after: int # total sessions analysed from applied_at onward
|
|
311
|
+
weeks_before: float # (applied_at - oldest_session_start).days / 7
|
|
312
|
+
weeks_after: float # (newest_session_start - applied_at).days / 7
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class EfficacyReport:
|
|
317
|
+
"""Aggregated before/after report across all managed CLAUDE.md headings."""
|
|
318
|
+
|
|
319
|
+
rows: list[EfficacyRow]
|
|
320
|
+
total_sessions: int
|
|
321
|
+
oldest_session: datetime | None # min start_time across all analysed sessions
|
|
322
|
+
newest_session: datetime | None # max start_time across all analysed sessions
|
|
323
|
+
|
|
324
|
+
|
|
297
325
|
# ---------------------------------------------------------------------------
|
|
298
326
|
# Renderer helper
|
|
299
327
|
# ---------------------------------------------------------------------------
|
|
@@ -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
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Cross-session evidence accumulation.
|
|
2
|
+
|
|
3
|
+
accumulate(diagnoses) -> dict[FindingKind, KindEvidence]
|
|
4
|
+
|
|
5
|
+
Counts how many sessions triggered each finding kind and sums waste cost.
|
|
6
|
+
Per the spec, session_count increments once per session per kind, regardless
|
|
7
|
+
of how many findings of that kind appear in one session.
|
|
8
|
+
Stores up to 3 example_summaries for the renderer.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from cctx.models import (
|
|
16
|
+
MANAGED_HEADINGS, # noqa: E402 — after stdlib, isort groups together
|
|
17
|
+
Diagnosis,
|
|
18
|
+
EfficacyReport,
|
|
19
|
+
EfficacyRow,
|
|
20
|
+
FindingKind,
|
|
21
|
+
KindEvidence,
|
|
22
|
+
SessionTrace,
|
|
23
|
+
)
|
|
24
|
+
from cctx.recommender.claude_md import summarize
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from cctx.models import Finding
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_HEADING_TO_KIND: dict[str, FindingKind] = {v: k for k, v in MANAGED_HEADINGS.items()}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _summarize_finding(finding: Finding) -> str:
|
|
34
|
+
return summarize(finding)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def accumulate(diagnoses: list[Diagnosis]) -> dict[FindingKind, KindEvidence]:
|
|
38
|
+
result: dict[FindingKind, KindEvidence] = {}
|
|
39
|
+
for diagnosis in diagnoses:
|
|
40
|
+
# Track which kinds we've already counted for this session to ensure
|
|
41
|
+
# session_count increments once per session per kind, not per finding.
|
|
42
|
+
seen_kinds: set[FindingKind] = set()
|
|
43
|
+
for finding in diagnosis.findings:
|
|
44
|
+
if finding.kind not in result:
|
|
45
|
+
result[finding.kind] = KindEvidence(
|
|
46
|
+
kind=finding.kind,
|
|
47
|
+
session_count=0,
|
|
48
|
+
total_waste_usd=0.0,
|
|
49
|
+
example_summaries=[],
|
|
50
|
+
)
|
|
51
|
+
ev = result[finding.kind]
|
|
52
|
+
if finding.kind not in seen_kinds:
|
|
53
|
+
ev.session_count += 1
|
|
54
|
+
seen_kinds.add(finding.kind)
|
|
55
|
+
ev.total_waste_usd += finding.cost_usd or 0.0
|
|
56
|
+
if len(ev.example_summaries) < 3:
|
|
57
|
+
ev.example_summaries.append(_summarize_finding(finding))
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _session_matches(diag: Diagnosis, kind: FindingKind | None) -> bool:
|
|
62
|
+
if kind is None:
|
|
63
|
+
return False
|
|
64
|
+
return any(f.kind is kind for f in diag.findings)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def efficacy(
|
|
68
|
+
pairs: list[tuple[Diagnosis, SessionTrace]],
|
|
69
|
+
heading_dates: dict[str, datetime | None],
|
|
70
|
+
) -> EfficacyReport:
|
|
71
|
+
"""Compute before/after session counts for each managed CLAUDE.md heading.
|
|
72
|
+
|
|
73
|
+
For each heading in heading_dates:
|
|
74
|
+
- Sessions with start_time < applied_at → "before" bucket.
|
|
75
|
+
- Sessions with start_time >= applied_at → "after" bucket.
|
|
76
|
+
- Sessions with start_time=None are skipped entirely.
|
|
77
|
+
- If applied_at is None: all sessions go into "after" (no baseline).
|
|
78
|
+
"""
|
|
79
|
+
valid_pairs = [(d, t) for d, t in pairs if t.start_time is not None]
|
|
80
|
+
|
|
81
|
+
oldest = min((t.start_time for _, t in valid_pairs), default=None)
|
|
82
|
+
newest = max((t.start_time for _, t in valid_pairs), default=None)
|
|
83
|
+
|
|
84
|
+
rows: list[EfficacyRow] = []
|
|
85
|
+
|
|
86
|
+
for heading, applied_at in heading_dates.items():
|
|
87
|
+
kind = _HEADING_TO_KIND.get(heading)
|
|
88
|
+
|
|
89
|
+
before_pairs = []
|
|
90
|
+
after_pairs = []
|
|
91
|
+
for diag, trace in valid_pairs:
|
|
92
|
+
if applied_at is None or trace.start_time >= applied_at:
|
|
93
|
+
after_pairs.append((diag, trace))
|
|
94
|
+
else:
|
|
95
|
+
before_pairs.append((diag, trace))
|
|
96
|
+
|
|
97
|
+
sessions_before = sum(1 for d, _ in before_pairs if _session_matches(d, kind))
|
|
98
|
+
sessions_after = sum(1 for d, _ in after_pairs if _session_matches(d, kind))
|
|
99
|
+
|
|
100
|
+
if applied_at is not None and oldest is not None:
|
|
101
|
+
weeks_before = max((applied_at - oldest).days, 0) / 7
|
|
102
|
+
else:
|
|
103
|
+
weeks_before = 0.0
|
|
104
|
+
|
|
105
|
+
if applied_at is not None and newest is not None:
|
|
106
|
+
weeks_after = max((newest - applied_at).days, 0) / 7
|
|
107
|
+
elif newest is not None and oldest is not None:
|
|
108
|
+
weeks_after = max((newest - oldest).days, 0) / 7
|
|
109
|
+
else:
|
|
110
|
+
weeks_after = 0.0
|
|
111
|
+
|
|
112
|
+
rows.append(EfficacyRow(
|
|
113
|
+
heading=heading,
|
|
114
|
+
kind=kind,
|
|
115
|
+
applied_at=applied_at,
|
|
116
|
+
sessions_before=sessions_before,
|
|
117
|
+
sessions_after=sessions_after,
|
|
118
|
+
total_before=len(before_pairs),
|
|
119
|
+
total_after=len(after_pairs),
|
|
120
|
+
weeks_before=weeks_before,
|
|
121
|
+
weeks_after=weeks_after,
|
|
122
|
+
))
|
|
123
|
+
|
|
124
|
+
return EfficacyReport(
|
|
125
|
+
rows=rows,
|
|
126
|
+
total_sessions=len(valid_pairs),
|
|
127
|
+
oldest_session=oldest,
|
|
128
|
+
newest_session=newest,
|
|
129
|
+
)
|