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.
Files changed (194) hide show
  1. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/CHANGELOG.md +9 -0
  2. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/PKG-INFO +1 -1
  3. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/__init__.py +1 -1
  4. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/__init__.py +2 -0
  5. cctx_cli-1.16.0/cctx/diagnostician/patterns/exploration_thrash.py +192 -0
  6. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/models.py +29 -26
  7. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/recommender/claude_md.py +18 -6
  8. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/pyproject.toml +1 -1
  9. cctx_cli-1.16.0/tests/test_exploration_thrash_classifier.py +272 -0
  10. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_harvest_emit.py +9 -8
  11. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/.github/workflows/ci.yml +0 -0
  12. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/.github/workflows/publish.yml +0 -0
  13. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/.github/workflows/release.yml +0 -0
  14. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/.gitignore +0 -0
  15. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/CLAUDE.md +0 -0
  16. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/DESIGN.md +0 -0
  17. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/PRODUCT.md +0 -0
  18. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/README.md +0 -0
  19. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/action.yml +0 -0
  20. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/agents.py +0 -0
  21. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/cli.py +0 -0
  22. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/aggregate.py +0 -0
  23. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/inflection.py +0 -0
  24. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/__init__.py +0 -0
  25. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/cache_hygiene.py +0 -0
  26. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/compaction.py +0 -0
  27. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
  28. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/fan_out.py +0 -0
  29. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/project_specific.py +0 -0
  30. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
  31. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
  32. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
  33. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
  34. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/discovery.py +0 -0
  35. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/exporters/__init__.py +0 -0
  36. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/exporters/csv.py +0 -0
  37. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/exporters/json.py +0 -0
  38. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/exporters/jsonl.py +0 -0
  39. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/harvest.py +0 -0
  40. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/hook_installer.py +0 -0
  41. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/parsers/__init__.py +0 -0
  42. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/parsers/claude_code.py +0 -0
  43. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/parsers/otel.py +0 -0
  44. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/pricing.py +0 -0
  45. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/recommender/__init__.py +0 -0
  46. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/recommender/evidence.py +0 -0
  47. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/__init__.py +0 -0
  48. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/github.py +0 -0
  49. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/report.py +0 -0
  50. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
  51. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/terminal.py +0 -0
  52. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/renderers/trace_tui.py +0 -0
  53. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/tokenizer.py +0 -0
  54. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx/watcher.py +0 -0
  55. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/cctx-project-brief.md +0 -0
  56. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/demo.gif +0 -0
  57. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/demo.tape +0 -0
  58. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
  59. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
  60. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
  61. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/product-reviews/2026-06-09-product-review.md +0 -0
  62. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/quickstart-otel.md +0 -0
  63. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
  64. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
  65. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
  66. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-17-harvest-check-depth.md +0 -0
  67. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +0 -0
  68. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-05-19-claude-agents-live-integration.md +0 -0
  69. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/plans/2026-06-19-otel-parser.md +0 -0
  70. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
  71. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
  72. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
  73. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
  74. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
  75. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-17-harvest-check-depth-design.md +0 -0
  76. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +0 -0
  77. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-05-19-claude-agents-live-integration-design.md +0 -0
  78. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-06-09-cross-agent-emit-design.md +0 -0
  79. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/docs/superpowers/specs/2026-06-19-otel-parser-design.md +0 -0
  80. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/__init__.py +0 -0
  81. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/conftest.py +0 -0
  82. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/__init__.py +0 -0
  83. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/conftest.py +0 -0
  84. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_dead_end.py +0 -0
  85. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_inflection.py +0 -0
  86. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_orchestrator.py +0 -0
  87. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_project_specific.py +0 -0
  88. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_retry_loop.py +0 -0
  89. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_scope_creep.py +0 -0
  90. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_stale_context.py +0 -0
  91. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/diagnostician/test_tool_thrash.py +0 -0
  92. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/exporters/__init__.py +0 -0
  93. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/exporters/test_csv.py +0 -0
  94. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/exporters/test_jsonl.py +0 -0
  95. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/README.md +0 -0
  96. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
  97. {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
  98. {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
  99. {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
  100. {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
  101. {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
  102. {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
  103. {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
  104. {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
  105. {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
  106. {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
  107. {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
  108. {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
  109. {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
  110. {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
  111. {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
  112. {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
  113. {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
  114. {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
  115. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
  116. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
  117. {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
  118. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
  119. {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
  120. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
  121. {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
  122. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
  123. {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
  124. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
  125. {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
  126. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
  127. {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
  128. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
  129. {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
  130. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
  131. {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
  132. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
  133. {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
  134. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
  135. {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
  136. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
  137. {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
  138. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
  139. {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
  140. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
  141. {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
  142. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
  143. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
  144. {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
  145. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
  146. {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
  147. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
  148. {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
  149. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
  150. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
  151. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
  152. {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
  153. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
  154. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/otel_fanout.jsonl +0 -0
  155. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/otel_handoff.jsonl +0 -0
  156. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/scrub.py +0 -0
  157. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
  158. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
  159. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
  160. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
  161. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
  162. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/parsers/__init__.py +0 -0
  163. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/parsers/test_claude_code.py +0 -0
  164. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/parsers/test_claude_code_integration.py +0 -0
  165. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/recommender/__init__.py +0 -0
  166. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/recommender/test_claude_md.py +0 -0
  167. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/recommender/test_evidence.py +0 -0
  168. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/renderers/__init__.py +0 -0
  169. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/renderers/test_report.py +0 -0
  170. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
  171. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_agents.py +0 -0
  172. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_aggregate.py +0 -0
  173. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_cache_hygiene_classifier.py +0 -0
  174. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_cli.py +0 -0
  175. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_cli_export.py +0 -0
  176. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_compaction_classifier.py +0 -0
  177. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_diagnostician_subagents.py +0 -0
  178. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_discovery.py +0 -0
  179. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_efficacy.py +0 -0
  180. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_fanout_classifier.py +0 -0
  181. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_github_summary.py +0 -0
  182. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_harvest.py +0 -0
  183. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_harvest_check.py +0 -0
  184. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_init.py +0 -0
  185. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_models.py +0 -0
  186. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_models_project_pattern.py +0 -0
  187. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_otel_parser.py +0 -0
  188. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_recommender.py +0 -0
  189. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_savings_framing.py +0 -0
  190. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_smoke.py +0 -0
  191. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_terminal_renderer.py +0 -0
  192. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_tokenizer.py +0 -0
  193. {cctx_cli-1.15.0 → cctx_cli-1.16.0}/tests/test_trace_tui.py +0 -0
  194. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctx-cli
3
- Version: 1.15.0
3
+ Version: 1.16.0
4
4
  Summary: Diagnose Claude Code sessions — find what went wrong, what it cost, and what to add to CLAUDE.md
5
5
  Author: Jacquard Labs
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """cctx: profile, debug, and optimize Claude Code and Agent SDK sessions."""
2
2
 
3
- __version__ = "1.15.0"
3
+ __version__ = "1.16.0"
@@ -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 = "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"
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: "RETRY LOOP",
183
- FindingKind.SCOPE_CREEP: "SCOPE CREEP",
184
- FindingKind.STALE_CONTEXT: "STALE CONTEXT",
185
- FindingKind.TOOL_THRASH: "TOOL THRASH",
186
- FindingKind.DEAD_END: "DEAD END",
187
- FindingKind.FANOUT_WASTE: "FANOUT WASTE",
188
- FindingKind.PROJECT_PATTERN: "PROJECT PATTERN",
189
- FindingKind.CACHE_HYGIENE: "CACHE HYGIENE",
190
- FindingKind.COMPACTION: "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: "## Retry discipline",
199
- FindingKind.SCOPE_CREEP: "## Scope discipline",
200
- FindingKind.STALE_CONTEXT: "## Context hygiene",
201
- FindingKind.TOOL_THRASH: "## Tool-call discipline",
202
- FindingKind.DEAD_END: "## Exploration discipline",
203
- FindingKind.FANOUT_WASTE: "## Fan-out discipline",
204
- FindingKind.CACHE_HYGIENE: "## Cache hygiene",
205
- FindingKind.COMPACTION: "## Compaction hygiene",
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: ("Add retry discipline rule", _RETRY_LOOP_DIFF, "CLAUDE.md"),
91
- FindingKind.SCOPE_CREEP: ("Add scope discipline rule", _SCOPE_CREEP_DIFF, "CLAUDE.md"),
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: ("Add tool-call discipline rule", _TOOL_THRASH_DIFF, "CLAUDE.md"),
94
- FindingKind.DEAD_END: ("Add exploration discipline rule", _DEAD_END_DIFF, "CLAUDE.md"),
95
- FindingKind.FANOUT_WASTE: ("Add fan-out discipline rule", _FANOUT_WASTE_DIFF, "CLAUDE.md"),
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: ("Add compaction hygiene rule", _COMPACTION_DIFF, "CLAUDE.md"),
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.15.0"
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: "## 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",
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