cctx-cli 1.8.0__tar.gz → 1.10.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 (179) hide show
  1. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/CHANGELOG.md +60 -0
  2. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/PKG-INFO +1 -1
  3. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/__init__.py +1 -1
  4. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/cli.py +33 -4
  5. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/exporters/jsonl.py +45 -1
  6. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/harvest.py +35 -0
  7. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/models.py +25 -0
  8. cctx_cli-1.10.0/cctx/recommender/evidence.py +129 -0
  9. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/renderers/terminal.py +93 -1
  10. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/pyproject.toml +1 -1
  11. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_cli.py +28 -3
  12. cctx_cli-1.10.0/tests/test_efficacy.py +389 -0
  13. cctx_cli-1.8.0/cctx/recommender/evidence.py +0 -46
  14. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/.github/workflows/ci.yml +0 -0
  15. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/.github/workflows/publish.yml +0 -0
  16. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/.github/workflows/release.yml +0 -0
  17. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/.gitignore +0 -0
  18. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/CLAUDE.md +0 -0
  19. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/DESIGN.md +0 -0
  20. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/PRODUCT.md +0 -0
  21. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/README.md +0 -0
  22. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/action.yml +0 -0
  23. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/agents.py +0 -0
  24. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/__init__.py +0 -0
  25. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/aggregate.py +0 -0
  26. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/inflection.py +0 -0
  27. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/patterns/__init__.py +0 -0
  28. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
  29. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/patterns/fan_out.py +0 -0
  30. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/patterns/project_specific.py +0 -0
  31. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
  32. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
  33. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
  34. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
  35. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/discovery.py +0 -0
  36. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/exporters/__init__.py +0 -0
  37. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/exporters/csv.py +0 -0
  38. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/exporters/json.py +0 -0
  39. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/parsers/__init__.py +0 -0
  40. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/parsers/claude_code.py +0 -0
  41. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/pricing.py +0 -0
  42. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/recommender/__init__.py +0 -0
  43. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/recommender/claude_md.py +0 -0
  44. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/renderers/__init__.py +0 -0
  45. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/renderers/github.py +0 -0
  46. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/renderers/report.py +0 -0
  47. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
  48. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/renderers/trace_tui.py +0 -0
  49. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/tokenizer.py +0 -0
  50. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx/watcher.py +0 -0
  51. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/cctx-project-brief.md +0 -0
  52. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/demo.gif +0 -0
  53. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/demo.tape +0 -0
  54. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
  55. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
  56. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
  57. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/product-reviews/2026-06-09-product-review.md +0 -0
  58. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
  59. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
  60. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
  61. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/plans/2026-05-17-harvest-check-depth.md +0 -0
  62. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +0 -0
  63. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/plans/2026-05-19-claude-agents-live-integration.md +0 -0
  64. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
  65. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
  66. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
  67. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
  68. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
  69. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-05-17-harvest-check-depth-design.md +0 -0
  70. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +0 -0
  71. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-05-19-claude-agents-live-integration-design.md +0 -0
  72. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/docs/superpowers/specs/2026-06-09-cross-agent-emit-design.md +0 -0
  73. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/__init__.py +0 -0
  74. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/conftest.py +0 -0
  75. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/__init__.py +0 -0
  76. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/conftest.py +0 -0
  77. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/test_dead_end.py +0 -0
  78. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/test_inflection.py +0 -0
  79. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/test_orchestrator.py +0 -0
  80. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/test_project_specific.py +0 -0
  81. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/test_retry_loop.py +0 -0
  82. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/test_scope_creep.py +0 -0
  83. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/test_stale_context.py +0 -0
  84. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/diagnostician/test_tool_thrash.py +0 -0
  85. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/exporters/__init__.py +0 -0
  86. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/exporters/test_csv.py +0 -0
  87. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/exporters/test_jsonl.py +0 -0
  88. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/README.md +0 -0
  89. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
  90. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a0b4c2cf1dde0ca56.meta.json +0 -0
  91. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a116ae34b1b09c332.meta.json +0 -0
  92. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1c4c417b35658c9e.meta.json +0 -0
  93. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1e41a901de38f1b5.meta.json +0 -0
  94. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a338f8d0c74612a24.meta.json +0 -0
  95. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a34f6f3c0e7094186.meta.json +0 -0
  96. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a5a5a0cff4d13308b.meta.json +0 -0
  97. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a6b0a3da6a0484db5.meta.json +0 -0
  98. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f73f1790b02cde5.meta.json +0 -0
  99. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f7c17c38a9d8788.meta.json +0 -0
  100. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a853259e2cd7bbe8a.meta.json +0 -0
  101. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a8d9aedb0d0c6e12d.meta.json +0 -0
  102. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aa778bc1d59e4a441.meta.json +0 -0
  103. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aba869dedee4a12ba.meta.json +0 -0
  104. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-ada2746d9774b94db.meta.json +0 -0
  105. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea0132068c64d2dd.meta.json +0 -0
  106. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea215eff50874d5f.meta.json +0 -0
  107. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-afee21f2b3852a4a0.meta.json +0 -0
  108. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
  109. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
  110. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.meta.json +0 -0
  111. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
  112. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.meta.json +0 -0
  113. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
  114. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.meta.json +0 -0
  115. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
  116. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.meta.json +0 -0
  117. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
  118. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.meta.json +0 -0
  119. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
  120. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.meta.json +0 -0
  121. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
  122. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.meta.json +0 -0
  123. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
  124. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.meta.json +0 -0
  125. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
  126. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.meta.json +0 -0
  127. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
  128. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.meta.json +0 -0
  129. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
  130. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.meta.json +0 -0
  131. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
  132. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.meta.json +0 -0
  133. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
  134. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.meta.json +0 -0
  135. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
  136. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
  137. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.meta.json +0 -0
  138. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
  139. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.meta.json +0 -0
  140. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
  141. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.meta.json +0 -0
  142. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
  143. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
  144. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
  145. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results/tool-results/bosbkda0h.txt +0 -0
  146. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
  147. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/scrub.py +0 -0
  148. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
  149. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
  150. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
  151. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
  152. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
  153. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/parsers/__init__.py +0 -0
  154. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/parsers/test_claude_code.py +0 -0
  155. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/parsers/test_claude_code_integration.py +0 -0
  156. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/recommender/__init__.py +0 -0
  157. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/recommender/test_claude_md.py +0 -0
  158. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/recommender/test_evidence.py +0 -0
  159. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/renderers/__init__.py +0 -0
  160. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/renderers/test_report.py +0 -0
  161. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
  162. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_agents.py +0 -0
  163. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_aggregate.py +0 -0
  164. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_cli_export.py +0 -0
  165. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_diagnostician_subagents.py +0 -0
  166. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_discovery.py +0 -0
  167. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_fanout_classifier.py +0 -0
  168. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_github_summary.py +0 -0
  169. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_harvest.py +0 -0
  170. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_harvest_check.py +0 -0
  171. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_harvest_emit.py +0 -0
  172. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_models.py +0 -0
  173. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_models_project_pattern.py +0 -0
  174. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_recommender.py +0 -0
  175. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_smoke.py +0 -0
  176. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_terminal_renderer.py +0 -0
  177. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_tokenizer.py +0 -0
  178. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_trace_tui.py +0 -0
  179. {cctx_cli-1.8.0 → cctx_cli-1.10.0}/tests/test_watcher.py +0 -0
@@ -2,6 +2,66 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v1.10.0 (2026-06-11)
6
+
7
+ ### Features
8
+
9
+ - Autopsy --json aggregate output for --since mode (closes #97)
10
+ ([#112](https://github.com/jacquardlabs/cctx/pull/112),
11
+ [`86b10e0`](https://github.com/jacquardlabs/cctx/commit/86b10e0787efa99e9af251e979e311053d7b8b72))
12
+
13
+
14
+ ## v1.9.0 (2026-06-11)
15
+
16
+ ### Bug Fixes
17
+
18
+ - Combine split import in test_efficacy (ruff I001)
19
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
20
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
21
+
22
+ - Normalize Z-suffix UTC timestamps for Python 3.10 fromisoformat
23
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
24
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
25
+
26
+ - Remove unused timezone import (ruff F401) ([#111](https://github.com/jacquardlabs/cctx/pull/111),
27
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
28
+
29
+ - Use %aI git format for Python 3.10 compat; guard total_after==0 signal
30
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
31
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
32
+
33
+ ### Documentation
34
+
35
+ - Add managed_heading_dates to harvest.py module docstring
36
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
37
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
38
+
39
+ ### Features
40
+
41
+ - EfficacyRow + EfficacyReport dataclasses (M17 #90)
42
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
43
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
44
+
45
+ - Evidence.efficacy — before/after session bucketing (M17 #90)
46
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
47
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
48
+
49
+ - Harvest --efficacy CLI flag (M17 #90) ([#111](https://github.com/jacquardlabs/cctx/pull/111),
50
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
51
+
52
+ - Managed_heading_dates — git-based patch introduction dates (M17 #90)
53
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
54
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
55
+
56
+ - Patch efficacy report — harvest --efficacy (M17 #90)
57
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
58
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
59
+
60
+ - Render_efficacy_report — efficacy table renderer (M17 #90)
61
+ ([#111](https://github.com/jacquardlabs/cctx/pull/111),
62
+ [`d40fe5b`](https://github.com/jacquardlabs/cctx/commit/d40fe5b15643546ef7d9dc75ffcf1b62d4ec051b))
63
+
64
+
5
65
  ## v1.8.0 (2026-06-11)
6
66
 
7
67
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctx-cli
3
- Version: 1.8.0
3
+ Version: 1.10.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.8.0"
3
+ __version__ = "1.10.0"
@@ -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,
@@ -325,8 +326,6 @@ def autopsy(
325
326
  raise click.UsageError("--turn is not supported with --since.")
326
327
  if until_date is not None and since is None:
327
328
  raise click.UsageError("--until requires --since.")
328
- if json_out and since is not None:
329
- raise click.UsageError("--json is not supported with --since.")
330
329
 
331
330
  if target is None:
332
331
  if not latest:
@@ -391,8 +390,14 @@ def autopsy(
391
390
  patches=patches,
392
391
  project_patterns=patterns,
393
392
  )
394
- render_aggregate(report)
395
- _aggregate_drilldown(report, diagnoses)
393
+ if json_out:
394
+ import json as _json
395
+
396
+ from cctx.exporters.jsonl import export_aggregate as _export_agg
397
+ click.echo(_json.dumps(_json.loads(_export_agg(report)), indent=2))
398
+ else:
399
+ render_aggregate(report)
400
+ _aggregate_drilldown(report, diagnoses)
396
401
  else:
397
402
  # Single-session path
398
403
  if target.is_dir():
@@ -587,6 +592,13 @@ def trace(target: Path | None, latest: bool) -> None:
587
592
  help="With --emit: also mirror already-harvested cctx-managed sections "
588
593
  "from CLAUDE.md into the emit target.",
589
594
  )
595
+ @click.option(
596
+ "--efficacy",
597
+ "efficacy_mode",
598
+ is_flag=True,
599
+ default=False,
600
+ help="Report whether applied patches reduced their target patterns (before vs. after).",
601
+ )
590
602
  def harvest(
591
603
  target: Path,
592
604
  since: str | None,
@@ -597,6 +609,7 @@ def harvest(
597
609
  check_severity: str,
598
610
  emit_targets: tuple[str, ...],
599
611
  sync_mode: bool,
612
+ efficacy_mode: bool,
600
613
  ) -> None:
601
614
  """Apply autopsy patches to CLAUDE.md."""
602
615
  from cctx.harvest import (
@@ -610,6 +623,22 @@ def harvest(
610
623
  if sync_mode and not emit_targets:
611
624
  raise click.UsageError("--sync requires --emit.")
612
625
 
626
+ if efficacy_mode:
627
+ if target.is_file():
628
+ raise click.UsageError(
629
+ "--efficacy requires a project directory, not a .jsonl file."
630
+ )
631
+ resolved_dir = target_dir or Path.cwd()
632
+ from cctx.harvest import managed_heading_dates
633
+ from cctx.recommender.evidence import efficacy as _run_efficacy
634
+ start = datetime(2020, 1, 1, tzinfo=UTC)
635
+ end = datetime(2035, 1, 1, tzinfo=UTC)
636
+ pairs = aggregate.run(target, start, end)
637
+ h_dates = managed_heading_dates(resolved_dir)
638
+ report = _run_efficacy(pairs, h_dates)
639
+ render_efficacy_report(report, resolved_dir, target)
640
+ return
641
+
613
642
  if check_mode:
614
643
  from cctx.harvest import CheckSeverity
615
644
  resolved_dir = target_dir or Path.cwd()
@@ -5,7 +5,7 @@ import json
5
5
  from typing import IO, TYPE_CHECKING
6
6
 
7
7
  if TYPE_CHECKING:
8
- from cctx.models import Diagnosis, SessionTrace
8
+ from cctx.models import AggregateReport, Diagnosis, SessionTrace
9
9
 
10
10
 
11
11
  def export_diagnosis(
@@ -64,6 +64,50 @@ def export_diagnosis(
64
64
  return json.dumps(obj)
65
65
 
66
66
 
67
+ def export_aggregate(report: AggregateReport) -> str:
68
+ """Serialize an AggregateReport to a JSON string."""
69
+ by_kind = {
70
+ k.value: {
71
+ "session_count": v.session_count,
72
+ "total_waste_usd": v.total_waste_usd,
73
+ "example_summaries": v.example_summaries,
74
+ }
75
+ for k, v in report.by_kind.items()
76
+ }
77
+ patches = [
78
+ {
79
+ "target_file": p.target_file,
80
+ "finding_kind": p.finding_kind.value,
81
+ "description": p.description,
82
+ "evidence_summary": p.evidence_summary,
83
+ }
84
+ for p in report.patches
85
+ ]
86
+ project_patterns = [
87
+ {
88
+ "tool_name": pp.tool_name,
89
+ "failure_key": pp.failure_key,
90
+ "fix_key": pp.fix_key,
91
+ "session_count": pp.session_count,
92
+ "avg_wasted_turns": pp.avg_wasted_turns,
93
+ "total_waste_usd": pp.total_waste_usd,
94
+ "example_sessions": pp.example_sessions,
95
+ }
96
+ for pp in report.project_patterns
97
+ ]
98
+ obj = {
99
+ "period_label": report.period_label,
100
+ "sessions_analysed": report.sessions_analysed,
101
+ "sessions_with_findings": report.sessions_with_findings,
102
+ "total_cost_usd": report.total_cost_usd,
103
+ "waste_cost_usd": report.waste_cost_usd,
104
+ "by_kind": by_kind,
105
+ "patches": patches,
106
+ "project_patterns": project_patterns,
107
+ }
108
+ return json.dumps(obj)
109
+
110
+
67
111
  def write(
68
112
  diagnoses: list[tuple[Diagnosis, SessionTrace]],
69
113
  out: IO[str],
@@ -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
  # ---------------------------------------------------------------------------
@@ -297,6 +297,31 @@ class AggregateReport:
297
297
  project_patterns: list[ProjectPattern] = field(default_factory=list)
298
298
 
299
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
+
300
325
  # ---------------------------------------------------------------------------
301
326
  # Renderer helper
302
327
  # ---------------------------------------------------------------------------
@@ -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
+ )
@@ -5,6 +5,7 @@ render_aggregate(report, console=None) -> None
5
5
  render_harvest_results(results, dry_run=False, console=None) -> None
6
6
  render_projects(projects, live_statuses=None, console=None) -> None
7
7
  render_sessions(project, live_statuses=None, console=None) -> None
8
+ render_efficacy_report(report, target_dir, project_dir, console=None) -> None
8
9
 
9
10
  Uses rich for formatting. Accepts an optional Console for testing.
10
11
  """
@@ -24,7 +25,7 @@ from cctx.models import KIND_LABEL, FindingKind, Severity
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from cctx.discovery import ProjectInfo
27
- from cctx.models import AggregateReport, Diagnosis, SessionTrace
28
+ from cctx.models import AggregateReport, Diagnosis, EfficacyReport, EfficacyRow, SessionTrace
28
29
 
29
30
  _SEVERITY_STYLE = {
30
31
  Severity.HIGH: "bold red",
@@ -39,6 +40,11 @@ def _default_console() -> Console:
39
40
  return Console()
40
41
 
41
42
 
43
+ def _wide_console() -> Console:
44
+ """Console with fixed wide width to prevent table cell wrapping."""
45
+ return Console(width=200)
46
+
47
+
42
48
  def render_diagnosis(
43
49
  diagnosis: Diagnosis,
44
50
  *,
@@ -407,3 +413,89 @@ def render_sessions(
407
413
  Text("cctx autopsy <path>", style="bold") +
408
414
  Text(" to diagnose a session", style="dim")
409
415
  )
416
+
417
+
418
+ def _efficacy_signal(row: EfficacyRow) -> str:
419
+ """Classify efficacy: ✓ effective | ↓ reduced | ✗ persisting | ? no baseline | ? not in git."""
420
+ if row.applied_at is None:
421
+ return "? not in git"
422
+ if row.sessions_before == 0:
423
+ return "? no baseline"
424
+ if row.total_after == 0:
425
+ return "? no post-patch data"
426
+ rate_before = row.sessions_before / max(row.weeks_before, 0.5)
427
+ rate_after = row.sessions_after / max(row.weeks_after, 0.5)
428
+ low = " (low sample)" if row.total_before < 3 or row.total_after < 3 else ""
429
+ if rate_after == 0 or rate_after < rate_before * 0.25:
430
+ return f"✓ effective{low}"
431
+ if rate_after < rate_before * 0.75:
432
+ return f"↓ reduced{low}"
433
+ return f"✗ persisting{low}"
434
+
435
+
436
+ def render_efficacy_report(
437
+ report: EfficacyReport,
438
+ target_dir: Path,
439
+ project_dir: Path,
440
+ *,
441
+ console: Console | None = None,
442
+ ) -> None:
443
+ """Render patch efficacy table to terminal."""
444
+ con = console or _default_console()
445
+
446
+ if report.total_sessions == 0:
447
+ con.print(f"No sessions found in {project_dir}.")
448
+ return
449
+
450
+ if not report.rows:
451
+ con.print(f"No managed headings found in CLAUDE.md at {target_dir / 'CLAUDE.md'}.")
452
+ return
453
+
454
+ range_str = ""
455
+ if report.oldest_session and report.newest_session:
456
+ oldest = report.oldest_session.strftime("%Y-%m-%d")
457
+ newest = report.newest_session.strftime("%Y-%m-%d")
458
+ range_str = f" Range: {oldest} — {newest}"
459
+ con.print(Rule("cctx harvest --efficacy"))
460
+ con.print(f"Sessions: {report.total_sessions}{range_str}")
461
+ con.print(f"CLAUDE.md: {target_dir / 'CLAUDE.md'}")
462
+ con.print()
463
+
464
+ _SIGNAL_STYLE = {
465
+ "✓": "bold green",
466
+ "↓": "bold yellow",
467
+ "✗": "bold red",
468
+ "?": "dim",
469
+ }
470
+
471
+ # Build table for heading + applied + before/after counts.
472
+ # Signal is printed as a separate styled line per row so it is never
473
+ # truncated or wrapped regardless of terminal width.
474
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
475
+ table.add_column("Heading", style="bold", no_wrap=True)
476
+ table.add_column("Applied", no_wrap=True)
477
+ table.add_column("Before", no_wrap=True)
478
+ table.add_column("After", no_wrap=True)
479
+
480
+ row_signals: list[tuple[str, str]] = [] # (signal_text, style)
481
+
482
+ for row in report.rows:
483
+ if row.applied_at is None:
484
+ applied_str = "(not in git)"
485
+ before_str = "—"
486
+ else:
487
+ applied_str = row.applied_at.strftime("%Y-%m-%d")
488
+ before_str = f"{row.sessions_before}/{row.total_before} sessions"
489
+ after_str = f"{row.sessions_after}/{row.total_after} sessions"
490
+ signal = _efficacy_signal(row)
491
+ first_char = signal[0] if signal else "?"
492
+ signal_style = _SIGNAL_STYLE.get(first_char, "")
493
+ table.add_row(row.heading, applied_str, before_str, after_str)
494
+ row_signals.append((signal, signal_style))
495
+
496
+ con.print(table)
497
+ con.print()
498
+ for (signal, style), row in zip(row_signals, report.rows, strict=True):
499
+ prefix = Text(f" {row.heading}: ", style="dim")
500
+ signal_text = Text(signal, style=style)
501
+ con.print(prefix + signal_text, soft_wrap=True)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctx-cli"
7
- version = "1.8.0"
7
+ version = "1.10.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"
@@ -488,17 +488,42 @@ def test_autopsy_json_outputs_valid_json(runner, session_jsonl):
488
488
  assert "findings" in data
489
489
 
490
490
 
491
- def test_autopsy_json_incompatible_with_since(runner, tmp_path):
492
- """--json + --since → non-zero exit (UsageError)."""
491
+ def test_autopsy_json_aggregate_outputs_valid_json(runner, tmp_path):
492
+ """--json + --since → valid aggregate JSON with expected top-level keys."""
493
493
  from cctx.cli import cli
494
494
 
495
495
  project_dir = tmp_path / "-Users-test-Projects-demo"
496
496
  project_dir.mkdir()
497
497
 
498
+ session_id = "json-agg-test-01"
499
+ line = {
500
+ "type": "user",
501
+ "uuid": f"{session_id}-u1",
502
+ "parentUuid": None,
503
+ "isSidechain": False,
504
+ "timestamp": "2026-05-14T10:00:00.000Z",
505
+ "sessionId": session_id,
506
+ "version": "2.1.138",
507
+ "cwd": "/Users/test/Projects/demo",
508
+ "gitBranch": "main",
509
+ "userType": "external",
510
+ "entrypoint": "cli",
511
+ "message": {"role": "user", "content": "hello"},
512
+ }
513
+ (project_dir / f"{session_id}.jsonl").write_text(json.dumps(line) + "\n")
514
+
498
515
  result = runner.invoke(
499
516
  cli, ["autopsy", str(project_dir), "--since", "7", "--json"],
517
+ catch_exceptions=False,
500
518
  )
501
- assert result.exit_code != 0
519
+ assert result.exit_code == 0
520
+ data = json.loads(result.output)
521
+ assert "sessions_analysed" in data
522
+ assert "total_cost_usd" in data
523
+ assert "waste_cost_usd" in data
524
+ assert "by_kind" in data
525
+ assert "patches" in data
526
+ assert "project_patterns" in data
502
527
 
503
528
 
504
529
  def test_autopsy_json_contains_cost(runner, session_jsonl):