cctx-cli 1.15.0__tar.gz → 1.16.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.15.0 → cctx_cli-1.16.0}/CHANGELOG.md +9 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/PKG-INFO +1 -1
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/__init__.py +1 -1
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/__init__.py +2 -0
- cctx_cli-1.16.0/cctx/diagnostician/patterns/exploration_thrash.py +192 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/models.py +29 -26
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/recommender/claude_md.py +18 -6
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/pyproject.toml +1 -1
- cctx_cli-1.16.0/tests/test_exploration_thrash_classifier.py +272 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_harvest_emit.py +9 -8
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/.github/workflows/ci.yml +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/.github/workflows/publish.yml +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/.github/workflows/release.yml +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/.gitignore +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/CLAUDE.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/DESIGN.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/PRODUCT.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/README.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/action.yml +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/agents.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/cli.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/aggregate.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/inflection.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/cache_hygiene.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/compaction.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/fan_out.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/project_specific.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/discovery.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/exporters/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/exporters/csv.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/exporters/json.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/exporters/jsonl.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/harvest.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/hook_installer.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/parsers/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/parsers/claude_code.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/parsers/otel.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/pricing.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/recommender/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/recommender/evidence.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/github.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/report.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/terminal.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/trace_tui.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/tokenizer.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/watcher.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx-project-brief.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/demo.gif +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/demo.tape +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/product-reviews/2026-06-09-product-review.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/quickstart-otel.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-17-harvest-check-depth.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-19-claude-agents-live-integration.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-06-19-otel-parser.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-17-harvest-check-depth-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-19-claude-agents-live-integration-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-06-09-cross-agent-emit-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-06-19-otel-parser-design.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/conftest.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/conftest.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_dead_end.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_inflection.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_orchestrator.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_project_specific.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_retry_loop.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_scope_creep.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_stale_context.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_tool_thrash.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/exporters/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/exporters/test_csv.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/exporters/test_jsonl.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/README.md +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a0b4c2cf1dde0ca56.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a116ae34b1b09c332.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1c4c417b35658c9e.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1e41a901de38f1b5.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a338f8d0c74612a24.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a34f6f3c0e7094186.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a5a5a0cff4d13308b.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a6b0a3da6a0484db5.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f73f1790b02cde5.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f7c17c38a9d8788.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a853259e2cd7bbe8a.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a8d9aedb0d0c6e12d.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aa778bc1d59e4a441.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aba869dedee4a12ba.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-ada2746d9774b94db.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea0132068c64d2dd.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea215eff50874d5f.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-afee21f2b3852a4a0.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.meta.json +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results/tool-results/bosbkda0h.txt +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/otel_fanout.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/otel_handoff.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/scrub.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/parsers/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/parsers/test_claude_code.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/parsers/test_claude_code_integration.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/recommender/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/recommender/test_claude_md.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/recommender/test_evidence.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/renderers/__init__.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/renderers/test_report.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_agents.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_aggregate.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_cache_hygiene_classifier.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_cli.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_cli_export.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_compaction_classifier.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_diagnostician_subagents.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_discovery.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_efficacy.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_fanout_classifier.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_github_summary.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_harvest.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_harvest_check.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_init.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_models.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_models_project_pattern.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_otel_parser.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_recommender.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_savings_framing.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_smoke.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_terminal_renderer.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_tokenizer.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_trace_tui.py +0 -0
- {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_watcher.py +0 -0
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v1.16.0 (2026-06-20)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Exploration thrash classifier — detect read-heavy circling without progress (#99)
|
|
10
|
+
([#124](https://github.com/jacquardlabs/cctx/pull/124),
|
|
11
|
+
[`21f3374`](https://github.com/jacquardlabs/cctx/commit/21f3374bb0e2c60db0411213773ad9dffca7ec1f))
|
|
12
|
+
|
|
13
|
+
|
|
5
14
|
## v1.15.0 (2026-06-20)
|
|
6
15
|
|
|
7
16
|
### Features
|
|
@@ -18,6 +18,7 @@ from cctx.diagnostician.patterns import (
|
|
|
18
18
|
cache_hygiene,
|
|
19
19
|
compaction,
|
|
20
20
|
dead_end,
|
|
21
|
+
exploration_thrash,
|
|
21
22
|
fan_out,
|
|
22
23
|
retry_loop,
|
|
23
24
|
scope_creep,
|
|
@@ -156,6 +157,7 @@ def run(trace: SessionTrace) -> Diagnosis:
|
|
|
156
157
|
*fan_out.classify(trace),
|
|
157
158
|
*cache_hygiene.classify(trace),
|
|
158
159
|
*compaction.classify(trace),
|
|
160
|
+
*exploration_thrash.classify(trace),
|
|
159
161
|
]
|
|
160
162
|
findings.sort(key=lambda f: f.first_turn)
|
|
161
163
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Exploration-thrash classifier.
|
|
2
|
+
|
|
3
|
+
Detects when the assistant circles with repeated read/search tool calls
|
|
4
|
+
instead of making progress — high ratio of read-only tools to write/execute
|
|
5
|
+
tools in a window, or repeated identical discovery commands, with no file
|
|
6
|
+
edits or test runs in N consecutive turns.
|
|
7
|
+
|
|
8
|
+
Signals:
|
|
9
|
+
1. Sliding window of WINDOW_SIZE consecutive assistant turns where ≥ 80%
|
|
10
|
+
of tool calls are read-only and no Write/Edit appears.
|
|
11
|
+
2. Any (tool_name, key) read-only pair called ≥ REPEAT_THRESHOLD times
|
|
12
|
+
across the full session.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections import Counter
|
|
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
|
|
24
|
+
|
|
25
|
+
WINDOW_SIZE = 6
|
|
26
|
+
READ_RATIO_THRESHOLD = 0.80
|
|
27
|
+
REPEAT_THRESHOLD = 3
|
|
28
|
+
|
|
29
|
+
_READ_TOOLS = frozenset({"Read", "Grep", "Glob"})
|
|
30
|
+
_WRITE_TOOLS = frozenset({"Write", "Edit", "NotebookEdit"})
|
|
31
|
+
|
|
32
|
+
_READ_BASH_PREFIXES = (
|
|
33
|
+
"ls",
|
|
34
|
+
"find",
|
|
35
|
+
"cat",
|
|
36
|
+
"head",
|
|
37
|
+
"tail",
|
|
38
|
+
"grep",
|
|
39
|
+
"rg",
|
|
40
|
+
"ag",
|
|
41
|
+
"wc",
|
|
42
|
+
"file",
|
|
43
|
+
"stat",
|
|
44
|
+
"echo",
|
|
45
|
+
"pwd",
|
|
46
|
+
"which",
|
|
47
|
+
"type",
|
|
48
|
+
"less",
|
|
49
|
+
"git log",
|
|
50
|
+
"git diff",
|
|
51
|
+
"git show",
|
|
52
|
+
"git status",
|
|
53
|
+
"git blame",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_read_only(tool_name: str, tool_input: dict) -> bool:
|
|
58
|
+
if tool_name in _READ_TOOLS:
|
|
59
|
+
return True
|
|
60
|
+
if tool_name in _WRITE_TOOLS:
|
|
61
|
+
return False
|
|
62
|
+
if tool_name == "Bash":
|
|
63
|
+
cmd = tool_input.get("command", "").lstrip()
|
|
64
|
+
return any(cmd.startswith(p) for p in _READ_BASH_PREFIXES)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tool_key(tool_name: str, tool_input: dict) -> str:
|
|
69
|
+
"""Canonical key for deduplication."""
|
|
70
|
+
match tool_name:
|
|
71
|
+
case "Bash":
|
|
72
|
+
return tool_input.get("command", "").strip()
|
|
73
|
+
case "Read" | "Write" | "Edit":
|
|
74
|
+
return tool_input.get("file_path", "")
|
|
75
|
+
case "Grep" | "Glob":
|
|
76
|
+
return tool_input.get("pattern", "")
|
|
77
|
+
case _:
|
|
78
|
+
return json.dumps(tool_input, sort_keys=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _classify_impl(trace: SessionTrace) -> list[Finding]:
|
|
82
|
+
# Only look at assistant turns with tool calls
|
|
83
|
+
active_turns = [
|
|
84
|
+
t for t in trace.turns
|
|
85
|
+
if t.role == "assistant" and t.tool_uses
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
thrash_windows: list[dict] = []
|
|
89
|
+
|
|
90
|
+
# Sliding window detection — requires at least WINDOW_SIZE active turns
|
|
91
|
+
for i in range(max(0, len(active_turns) - WINDOW_SIZE + 1)):
|
|
92
|
+
window = active_turns[i : i + WINDOW_SIZE]
|
|
93
|
+
all_calls = [
|
|
94
|
+
(tu.tool_name, tu.tool_input)
|
|
95
|
+
for t in window
|
|
96
|
+
for tu in t.tool_uses
|
|
97
|
+
]
|
|
98
|
+
if not all_calls:
|
|
99
|
+
continue
|
|
100
|
+
read_count = sum(
|
|
101
|
+
1 for name, inp in all_calls if _is_read_only(name, inp)
|
|
102
|
+
)
|
|
103
|
+
ratio = read_count / len(all_calls)
|
|
104
|
+
has_write = any(name in _WRITE_TOOLS for name, _ in all_calls)
|
|
105
|
+
|
|
106
|
+
if ratio >= READ_RATIO_THRESHOLD and not has_write:
|
|
107
|
+
# Avoid double-counting overlapping windows that cover the same turns
|
|
108
|
+
if (
|
|
109
|
+
thrash_windows
|
|
110
|
+
and thrash_windows[-1]["last_turn"] >= window[0].turn_number
|
|
111
|
+
):
|
|
112
|
+
continue
|
|
113
|
+
thrash_windows.append({
|
|
114
|
+
"first_turn": window[0].turn_number,
|
|
115
|
+
"last_turn": window[-1].turn_number,
|
|
116
|
+
"read_ratio": round(ratio, 2),
|
|
117
|
+
"total_calls": len(all_calls),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
# Repeated identical reads
|
|
121
|
+
read_call_counts: Counter[str] = Counter()
|
|
122
|
+
read_call_turns: dict[str, list[int]] = {}
|
|
123
|
+
for turn in trace.turns:
|
|
124
|
+
if turn.role != "assistant":
|
|
125
|
+
continue
|
|
126
|
+
for tu in turn.tool_uses:
|
|
127
|
+
if _is_read_only(tu.tool_name, tu.tool_input):
|
|
128
|
+
key = f"{tu.tool_name}:{_tool_key(tu.tool_name, tu.tool_input)}"
|
|
129
|
+
read_call_counts[key] += 1
|
|
130
|
+
read_call_turns.setdefault(key, []).append(turn.turn_number)
|
|
131
|
+
|
|
132
|
+
repeated_reads: list[dict] = []
|
|
133
|
+
for key, count in read_call_counts.items():
|
|
134
|
+
if count >= REPEAT_THRESHOLD:
|
|
135
|
+
tool_name, _, call_key = key.partition(":")
|
|
136
|
+
repeated_reads.append({
|
|
137
|
+
"tool_name": tool_name,
|
|
138
|
+
"key": call_key[:60],
|
|
139
|
+
"count": count,
|
|
140
|
+
"turns": read_call_turns[key],
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
if not thrash_windows and not repeated_reads:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
severity = Severity.HIGH if thrash_windows else Severity.MEDIUM
|
|
147
|
+
confidence = Confidence.MEDIUM
|
|
148
|
+
|
|
149
|
+
parts = []
|
|
150
|
+
if thrash_windows:
|
|
151
|
+
parts.append(
|
|
152
|
+
f"{len(thrash_windows)} exploration thrash window"
|
|
153
|
+
f"{'s' if len(thrash_windows) > 1 else ''} "
|
|
154
|
+
f"(turns {thrash_windows[0]['first_turn']}–{thrash_windows[-1]['last_turn']}, "
|
|
155
|
+
f"{thrash_windows[0]['read_ratio']:.0%} read-only)"
|
|
156
|
+
)
|
|
157
|
+
if repeated_reads:
|
|
158
|
+
worst = max(repeated_reads, key=lambda r: r["count"])
|
|
159
|
+
parts.append(
|
|
160
|
+
f"{worst['tool_name']}({worst['key']!r}) called {worst['count']}× identically"
|
|
161
|
+
)
|
|
162
|
+
summary = "; ".join(parts)
|
|
163
|
+
|
|
164
|
+
all_first = min(
|
|
165
|
+
[w["first_turn"] for w in thrash_windows]
|
|
166
|
+
+ [r["turns"][0] for r in repeated_reads]
|
|
167
|
+
)
|
|
168
|
+
all_last = max(
|
|
169
|
+
[w["last_turn"] for w in thrash_windows]
|
|
170
|
+
+ [r["turns"][-1] for r in repeated_reads]
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return [Finding(
|
|
174
|
+
kind=FindingKind.EXPLORATION_THRASH,
|
|
175
|
+
severity=severity,
|
|
176
|
+
confidence=confidence,
|
|
177
|
+
first_turn=all_first,
|
|
178
|
+
last_turn=all_last,
|
|
179
|
+
evidence={
|
|
180
|
+
"thrash_windows": thrash_windows,
|
|
181
|
+
"repeated_reads": repeated_reads,
|
|
182
|
+
},
|
|
183
|
+
cost_usd=None,
|
|
184
|
+
summary=summary,
|
|
185
|
+
)]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def classify(trace: SessionTrace) -> list[Finding]:
|
|
189
|
+
try:
|
|
190
|
+
return _classify_impl(trace)
|
|
191
|
+
except Exception:
|
|
192
|
+
return []
|
|
@@ -167,27 +167,29 @@ class SessionTrace:
|
|
|
167
167
|
|
|
168
168
|
|
|
169
169
|
class FindingKind(str, Enum):
|
|
170
|
-
RETRY_LOOP
|
|
171
|
-
SCOPE_CREEP
|
|
172
|
-
STALE_CONTEXT
|
|
173
|
-
TOOL_THRASH
|
|
174
|
-
DEAD_END
|
|
175
|
-
FANOUT_WASTE
|
|
176
|
-
PROJECT_PATTERN
|
|
177
|
-
CACHE_HYGIENE
|
|
178
|
-
COMPACTION
|
|
170
|
+
RETRY_LOOP = "retry_loop"
|
|
171
|
+
SCOPE_CREEP = "scope_creep"
|
|
172
|
+
STALE_CONTEXT = "stale_context"
|
|
173
|
+
TOOL_THRASH = "tool_thrash"
|
|
174
|
+
DEAD_END = "dead_end"
|
|
175
|
+
FANOUT_WASTE = "fanout_waste"
|
|
176
|
+
PROJECT_PATTERN = "project_pattern"
|
|
177
|
+
CACHE_HYGIENE = "cache_hygiene"
|
|
178
|
+
COMPACTION = "compaction"
|
|
179
|
+
EXPLORATION_THRASH = "exploration_thrash"
|
|
179
180
|
|
|
180
181
|
|
|
181
182
|
KIND_LABEL: dict[FindingKind, str] = {
|
|
182
|
-
FindingKind.RETRY_LOOP:
|
|
183
|
-
FindingKind.SCOPE_CREEP:
|
|
184
|
-
FindingKind.STALE_CONTEXT:
|
|
185
|
-
FindingKind.TOOL_THRASH:
|
|
186
|
-
FindingKind.DEAD_END:
|
|
187
|
-
FindingKind.FANOUT_WASTE:
|
|
188
|
-
FindingKind.PROJECT_PATTERN:
|
|
189
|
-
FindingKind.CACHE_HYGIENE:
|
|
190
|
-
FindingKind.COMPACTION:
|
|
183
|
+
FindingKind.RETRY_LOOP: "RETRY LOOP",
|
|
184
|
+
FindingKind.SCOPE_CREEP: "SCOPE CREEP",
|
|
185
|
+
FindingKind.STALE_CONTEXT: "STALE CONTEXT",
|
|
186
|
+
FindingKind.TOOL_THRASH: "TOOL THRASH",
|
|
187
|
+
FindingKind.DEAD_END: "DEAD END",
|
|
188
|
+
FindingKind.FANOUT_WASTE: "FANOUT WASTE",
|
|
189
|
+
FindingKind.PROJECT_PATTERN: "PROJECT PATTERN",
|
|
190
|
+
FindingKind.CACHE_HYGIENE: "CACHE HYGIENE",
|
|
191
|
+
FindingKind.COMPACTION: "COMPACTION",
|
|
192
|
+
FindingKind.EXPLORATION_THRASH: "EXPLORATION THRASH",
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
# Maps FindingKind to the exact ## heading emitted by its recommender patch
|
|
@@ -195,14 +197,15 @@ KIND_LABEL: dict[FindingKind, str] = {
|
|
|
195
197
|
# harvest.py imports this (never reaches into recommender/) so emit/sync can
|
|
196
198
|
# identify cctx-managed sections without depending on the patch generator.
|
|
197
199
|
MANAGED_HEADINGS: dict[FindingKind, str] = {
|
|
198
|
-
FindingKind.RETRY_LOOP:
|
|
199
|
-
FindingKind.SCOPE_CREEP:
|
|
200
|
-
FindingKind.STALE_CONTEXT:
|
|
201
|
-
FindingKind.TOOL_THRASH:
|
|
202
|
-
FindingKind.DEAD_END:
|
|
203
|
-
FindingKind.FANOUT_WASTE:
|
|
204
|
-
FindingKind.CACHE_HYGIENE:
|
|
205
|
-
FindingKind.COMPACTION:
|
|
200
|
+
FindingKind.RETRY_LOOP: "## Retry discipline",
|
|
201
|
+
FindingKind.SCOPE_CREEP: "## Scope discipline",
|
|
202
|
+
FindingKind.STALE_CONTEXT: "## Context hygiene",
|
|
203
|
+
FindingKind.TOOL_THRASH: "## Tool-call discipline",
|
|
204
|
+
FindingKind.DEAD_END: "## Exploration discipline",
|
|
205
|
+
FindingKind.FANOUT_WASTE: "## Fan-out discipline",
|
|
206
|
+
FindingKind.CACHE_HYGIENE: "## Cache hygiene",
|
|
207
|
+
FindingKind.COMPACTION: "## Compaction hygiene",
|
|
208
|
+
FindingKind.EXPLORATION_THRASH: "## Exploration thrash",
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
# Project-specific patterns use a heading that embeds tool+key, so the managed
|
|
@@ -85,16 +85,28 @@ _COMPACTION_DIFF = """\
|
|
|
85
85
|
+tool outputs once you've extracted what you need, so compaction doesn't erase
|
|
86
86
|
+work-in-progress state."""
|
|
87
87
|
|
|
88
|
+
_EXPLORATION_THRASH_DIFF = """\
|
|
89
|
+
+## Exploration thrash
|
|
90
|
+
+
|
|
91
|
+
+Before opening another file or running another search, state the specific
|
|
92
|
+
+question this call must answer and how the answer changes the next step. If
|
|
93
|
+
+you have read 6+ files without writing anything, stop and synthesise what
|
|
94
|
+
+you know. Never call the same read or grep more than twice with identical
|
|
95
|
+
+arguments — if the first two didn't help, a third won't either."""
|
|
96
|
+
|
|
88
97
|
_TEMPLATES: dict[FindingKind, tuple[str, str, str]] = {
|
|
89
98
|
# kind → (description, diff_body, target_file)
|
|
90
|
-
FindingKind.RETRY_LOOP:
|
|
91
|
-
FindingKind.SCOPE_CREEP:
|
|
99
|
+
FindingKind.RETRY_LOOP: ("Add retry discipline rule", _RETRY_LOOP_DIFF, "CLAUDE.md"),
|
|
100
|
+
FindingKind.SCOPE_CREEP: ("Add scope discipline rule", _SCOPE_CREEP_DIFF, "CLAUDE.md"),
|
|
92
101
|
FindingKind.STALE_CONTEXT: ("Add context hygiene rule", _STALE_CONTEXT_DIFF, "CLAUDE.md"),
|
|
93
|
-
FindingKind.TOOL_THRASH:
|
|
94
|
-
FindingKind.DEAD_END:
|
|
95
|
-
FindingKind.FANOUT_WASTE:
|
|
102
|
+
FindingKind.TOOL_THRASH: ("Add tool-call discipline rule", _TOOL_THRASH_DIFF, "CLAUDE.md"),
|
|
103
|
+
FindingKind.DEAD_END: ("Add exploration discipline rule", _DEAD_END_DIFF, "CLAUDE.md"),
|
|
104
|
+
FindingKind.FANOUT_WASTE: ("Add fan-out discipline rule", _FANOUT_WASTE_DIFF, "CLAUDE.md"),
|
|
96
105
|
FindingKind.CACHE_HYGIENE: ("Add cache hygiene rule", _CACHE_HYGIENE_DIFF, "CLAUDE.md"),
|
|
97
|
-
FindingKind.COMPACTION:
|
|
106
|
+
FindingKind.COMPACTION: ("Add compaction hygiene rule", _COMPACTION_DIFF, "CLAUDE.md"),
|
|
107
|
+
FindingKind.EXPLORATION_THRASH: (
|
|
108
|
+
"Add exploration thrash rule", _EXPLORATION_THRASH_DIFF, "CLAUDE.md"
|
|
109
|
+
),
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cctx-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.16.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"
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Tests for exploration_thrash classifier (issue #99)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cctx.models import SessionTrace, ToolResult, ToolUse, Turn, Usage
|
|
8
|
+
|
|
9
|
+
_TS = datetime(2026, 6, 10, tzinfo=timezone.utc)
|
|
10
|
+
_USAGE = Usage(500, 100, 0, 0, 0, None)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Helpers
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_turn(n: int, path: str = "/foo.py") -> Turn:
|
|
19
|
+
tu = ToolUse(
|
|
20
|
+
tool_name="Read",
|
|
21
|
+
tool_use_id=f"r{n}",
|
|
22
|
+
tool_input={"file_path": path},
|
|
23
|
+
)
|
|
24
|
+
tr = ToolResult(
|
|
25
|
+
tool_name="Read",
|
|
26
|
+
tool_use_id=f"r{n}",
|
|
27
|
+
content="content",
|
|
28
|
+
structured=None,
|
|
29
|
+
is_error=False,
|
|
30
|
+
)
|
|
31
|
+
return Turn(
|
|
32
|
+
turn_number=n,
|
|
33
|
+
uuid=f"t{n}",
|
|
34
|
+
parent_uuid=None,
|
|
35
|
+
role="assistant",
|
|
36
|
+
text="",
|
|
37
|
+
thinking="",
|
|
38
|
+
tool_uses=[tu],
|
|
39
|
+
tool_results=[tr],
|
|
40
|
+
usage=_USAGE,
|
|
41
|
+
model=None,
|
|
42
|
+
stop_reason="tool_use",
|
|
43
|
+
timestamp=_TS,
|
|
44
|
+
duration_ms=None,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _write_turn(n: int, path: str = "/foo.py") -> Turn:
|
|
49
|
+
tu = ToolUse(
|
|
50
|
+
tool_name="Edit",
|
|
51
|
+
tool_use_id=f"w{n}",
|
|
52
|
+
tool_input={"file_path": path},
|
|
53
|
+
)
|
|
54
|
+
tr = ToolResult(
|
|
55
|
+
tool_name="Edit",
|
|
56
|
+
tool_use_id=f"w{n}",
|
|
57
|
+
content="ok",
|
|
58
|
+
structured=None,
|
|
59
|
+
is_error=False,
|
|
60
|
+
)
|
|
61
|
+
return Turn(
|
|
62
|
+
turn_number=n,
|
|
63
|
+
uuid=f"t{n}",
|
|
64
|
+
parent_uuid=None,
|
|
65
|
+
role="assistant",
|
|
66
|
+
text="",
|
|
67
|
+
thinking="",
|
|
68
|
+
tool_uses=[tu],
|
|
69
|
+
tool_results=[tr],
|
|
70
|
+
usage=_USAGE,
|
|
71
|
+
model=None,
|
|
72
|
+
stop_reason="tool_use",
|
|
73
|
+
timestamp=_TS,
|
|
74
|
+
duration_ms=None,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _grep_turn(n: int, pattern: str = "def foo") -> Turn:
|
|
79
|
+
tu = ToolUse(
|
|
80
|
+
tool_name="Grep",
|
|
81
|
+
tool_use_id=f"g{n}",
|
|
82
|
+
tool_input={"pattern": pattern},
|
|
83
|
+
)
|
|
84
|
+
tr = ToolResult(
|
|
85
|
+
tool_name="Grep",
|
|
86
|
+
tool_use_id=f"g{n}",
|
|
87
|
+
content="match",
|
|
88
|
+
structured=None,
|
|
89
|
+
is_error=False,
|
|
90
|
+
)
|
|
91
|
+
return Turn(
|
|
92
|
+
turn_number=n,
|
|
93
|
+
uuid=f"t{n}",
|
|
94
|
+
parent_uuid=None,
|
|
95
|
+
role="assistant",
|
|
96
|
+
text="",
|
|
97
|
+
thinking="",
|
|
98
|
+
tool_uses=[tu],
|
|
99
|
+
tool_results=[tr],
|
|
100
|
+
usage=_USAGE,
|
|
101
|
+
model=None,
|
|
102
|
+
stop_reason="tool_use",
|
|
103
|
+
timestamp=_TS,
|
|
104
|
+
duration_ms=None,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _trace(turns: list[Turn]) -> SessionTrace:
|
|
109
|
+
return SessionTrace(
|
|
110
|
+
session_id="test-session",
|
|
111
|
+
parent_session_id=None,
|
|
112
|
+
project_path="/test",
|
|
113
|
+
cwd="/test",
|
|
114
|
+
primary_model="claude-sonnet-4-6",
|
|
115
|
+
claude_code_version="1.0",
|
|
116
|
+
turns=turns,
|
|
117
|
+
subagents=[],
|
|
118
|
+
attachments=[],
|
|
119
|
+
raw_tool_result_files=[],
|
|
120
|
+
initial_context_tokens=0,
|
|
121
|
+
tool_names_loaded=[],
|
|
122
|
+
start_time=_TS,
|
|
123
|
+
end_time=_TS,
|
|
124
|
+
source_path=Path("/test/session.jsonl"),
|
|
125
|
+
subagent_meta={},
|
|
126
|
+
warnings=[],
|
|
127
|
+
subagent_parse_errors=[],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Model smoke tests
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_exploration_thrash_kind_exists():
|
|
137
|
+
from cctx.models import FindingKind
|
|
138
|
+
assert FindingKind.EXPLORATION_THRASH == "exploration_thrash"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_exploration_thrash_kind_label():
|
|
142
|
+
from cctx.models import KIND_LABEL, FindingKind
|
|
143
|
+
assert KIND_LABEL[FindingKind.EXPLORATION_THRASH] == "EXPLORATION THRASH"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_exploration_thrash_managed_heading():
|
|
147
|
+
from cctx.models import MANAGED_HEADINGS, FindingKind
|
|
148
|
+
assert MANAGED_HEADINGS[FindingKind.EXPLORATION_THRASH] == "## Exploration thrash"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Classifier tests
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_too_few_active_turns_no_window_finding():
|
|
157
|
+
"""Fewer than WINDOW_SIZE (6) active tool turns with no repeated calls → no finding."""
|
|
158
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
159
|
+
|
|
160
|
+
# 5 turns each reading a distinct file — not enough for a window, no repeats
|
|
161
|
+
turns = [_read_turn(i, path=f"/file{i}.py") for i in range(1, 6)]
|
|
162
|
+
assert classify(_trace(turns)) == []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_all_write_turns_no_finding():
|
|
166
|
+
"""6 consecutive Edit turns → no finding (no read-only ratio exceeded)."""
|
|
167
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
168
|
+
|
|
169
|
+
turns = [_write_turn(i) for i in range(1, 7)]
|
|
170
|
+
assert classify(_trace(turns)) == []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_six_read_turns_fires_high_severity():
|
|
174
|
+
"""6 consecutive Read turns (100% read-only, no writes) → HIGH severity finding."""
|
|
175
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
176
|
+
from cctx.models import FindingKind, Severity
|
|
177
|
+
|
|
178
|
+
turns = [_read_turn(i, path=f"/file{i}.py") for i in range(1, 7)]
|
|
179
|
+
findings = classify(_trace(turns))
|
|
180
|
+
|
|
181
|
+
assert len(findings) == 1
|
|
182
|
+
f = findings[0]
|
|
183
|
+
assert f.kind is FindingKind.EXPLORATION_THRASH
|
|
184
|
+
assert f.severity is Severity.HIGH
|
|
185
|
+
assert len(f.evidence["thrash_windows"]) == 1
|
|
186
|
+
assert f.evidence["thrash_windows"][0]["read_ratio"] == 1.0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_three_reads_three_writes_no_finding():
|
|
190
|
+
"""3 read turns then 3 write turns — no window is all-read."""
|
|
191
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
192
|
+
|
|
193
|
+
turns = [_read_turn(i, path=f"/file{i}.py") for i in range(1, 4)]
|
|
194
|
+
turns += [_write_turn(i) for i in range(4, 7)]
|
|
195
|
+
assert classify(_trace(turns)) == []
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_repeated_identical_grep_fires_medium():
|
|
199
|
+
"""Same Grep pattern called 3× → MEDIUM severity (repeated_reads signal only)."""
|
|
200
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
201
|
+
from cctx.models import FindingKind, Severity
|
|
202
|
+
|
|
203
|
+
# Only 3 active turns → below WINDOW_SIZE=6, so no thrash window
|
|
204
|
+
# but the repeat threshold should still trigger
|
|
205
|
+
turns = [_grep_turn(i, pattern="def authenticate") for i in range(1, 4)]
|
|
206
|
+
findings = classify(_trace(turns))
|
|
207
|
+
|
|
208
|
+
assert len(findings) == 1
|
|
209
|
+
f = findings[0]
|
|
210
|
+
assert f.kind is FindingKind.EXPLORATION_THRASH
|
|
211
|
+
assert f.severity is Severity.MEDIUM
|
|
212
|
+
assert f.evidence["thrash_windows"] == []
|
|
213
|
+
assert len(f.evidence["repeated_reads"]) == 1
|
|
214
|
+
assert f.evidence["repeated_reads"][0]["count"] == 3
|
|
215
|
+
assert f.evidence["repeated_reads"][0]["tool_name"] == "Grep"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_window_with_one_write_among_reads_no_finding():
|
|
219
|
+
"""6 turns where 1 is a write (17% write) — below 80% read threshold → no window finding."""
|
|
220
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
221
|
+
|
|
222
|
+
# 5 reads + 1 write = 83% read, but has_write is True → should NOT fire
|
|
223
|
+
turns = [_read_turn(i, path=f"/f{i}.py") for i in range(1, 6)]
|
|
224
|
+
turns.append(_write_turn(6))
|
|
225
|
+
assert classify(_trace(turns)) == []
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_summary_contains_ratio():
|
|
229
|
+
"""Finding summary includes the read-only percentage."""
|
|
230
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
231
|
+
|
|
232
|
+
turns = [_read_turn(i, path=f"/file{i}.py") for i in range(1, 7)]
|
|
233
|
+
findings = classify(_trace(turns))
|
|
234
|
+
|
|
235
|
+
assert len(findings) == 1
|
|
236
|
+
assert "100%" in findings[0].summary
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_first_last_turn_span():
|
|
240
|
+
"""first_turn and last_turn correctly span the thrash window."""
|
|
241
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
242
|
+
|
|
243
|
+
turns = [_read_turn(i, path=f"/file{i}.py") for i in range(1, 7)]
|
|
244
|
+
findings = classify(_trace(turns))
|
|
245
|
+
|
|
246
|
+
assert findings[0].first_turn == 1
|
|
247
|
+
assert findings[0].last_turn == 6
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_no_tool_calls_no_finding():
|
|
251
|
+
"""Turns with no tool_uses are ignored; session with only text turns → no finding."""
|
|
252
|
+
from cctx.diagnostician.patterns.exploration_thrash import classify
|
|
253
|
+
|
|
254
|
+
def _text_turn(n: int) -> Turn:
|
|
255
|
+
return Turn(
|
|
256
|
+
turn_number=n,
|
|
257
|
+
uuid=f"t{n}",
|
|
258
|
+
parent_uuid=None,
|
|
259
|
+
role="assistant",
|
|
260
|
+
text="thinking...",
|
|
261
|
+
thinking="",
|
|
262
|
+
tool_uses=[],
|
|
263
|
+
tool_results=[],
|
|
264
|
+
usage=_USAGE,
|
|
265
|
+
model=None,
|
|
266
|
+
stop_reason="end_turn",
|
|
267
|
+
timestamp=_TS,
|
|
268
|
+
duration_ms=None,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
turns = [_text_turn(i) for i in range(1, 10)]
|
|
272
|
+
assert classify(_trace(turns)) == []
|
|
@@ -5,14 +5,15 @@ from __future__ import annotations
|
|
|
5
5
|
def test_managed_headings_cover_the_diagnostic_kinds():
|
|
6
6
|
from cctx.models import MANAGED_HEADINGS, FindingKind
|
|
7
7
|
assert MANAGED_HEADINGS == {
|
|
8
|
-
FindingKind.RETRY_LOOP:
|
|
9
|
-
FindingKind.SCOPE_CREEP:
|
|
10
|
-
FindingKind.STALE_CONTEXT:
|
|
11
|
-
FindingKind.TOOL_THRASH:
|
|
12
|
-
FindingKind.DEAD_END:
|
|
13
|
-
FindingKind.FANOUT_WASTE:
|
|
14
|
-
FindingKind.CACHE_HYGIENE:
|
|
15
|
-
FindingKind.COMPACTION:
|
|
8
|
+
FindingKind.RETRY_LOOP: "## Retry discipline",
|
|
9
|
+
FindingKind.SCOPE_CREEP: "## Scope discipline",
|
|
10
|
+
FindingKind.STALE_CONTEXT: "## Context hygiene",
|
|
11
|
+
FindingKind.TOOL_THRASH: "## Tool-call discipline",
|
|
12
|
+
FindingKind.DEAD_END: "## Exploration discipline",
|
|
13
|
+
FindingKind.FANOUT_WASTE: "## Fan-out discipline",
|
|
14
|
+
FindingKind.CACHE_HYGIENE: "## Cache hygiene",
|
|
15
|
+
FindingKind.COMPACTION: "## Compaction hygiene",
|
|
16
|
+
FindingKind.EXPLORATION_THRASH: "## Exploration thrash",
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|