cctx-cli 1.0.0__tar.gz → 1.2.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 (159) hide show
  1. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/.github/workflows/publish.yml +2 -14
  2. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/CHANGELOG.md +33 -0
  3. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/PKG-INFO +1 -1
  4. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/__init__.py +1 -1
  5. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/cli.py +80 -12
  6. cctx_cli-1.2.0/cctx/exporters/json.py +23 -0
  7. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/models.py +7 -0
  8. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/terminal.py +54 -1
  9. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/pyproject.toml +2 -1
  10. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_cli.py +239 -0
  11. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_models.py +54 -0
  12. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_terminal_renderer.py +100 -0
  13. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/.github/workflows/ci.yml +0 -0
  14. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/.github/workflows/release.yml +0 -0
  15. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/.gitignore +0 -0
  16. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/CLAUDE.md +0 -0
  17. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/DESIGN.md +0 -0
  18. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/PRODUCT.md +0 -0
  19. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/README.md +0 -0
  20. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/action.yml +0 -0
  21. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/__init__.py +0 -0
  22. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/aggregate.py +0 -0
  23. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/inflection.py +0 -0
  24. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/__init__.py +0 -0
  25. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
  26. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
  27. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
  28. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
  29. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
  30. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/discovery.py +0 -0
  31. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/exporters/__init__.py +0 -0
  32. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/exporters/csv.py +0 -0
  33. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/exporters/jsonl.py +0 -0
  34. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/harvest.py +0 -0
  35. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/parsers/__init__.py +0 -0
  36. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/parsers/claude_code.py +0 -0
  37. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/pricing.py +0 -0
  38. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/recommender/__init__.py +0 -0
  39. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/recommender/claude_md.py +0 -0
  40. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/recommender/evidence.py +0 -0
  41. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/__init__.py +0 -0
  42. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/github.py +0 -0
  43. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/report.py +0 -0
  44. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
  45. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/trace_tui.py +0 -0
  46. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/tokenizer.py +0 -0
  47. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/watcher.py +0 -0
  48. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx-project-brief.md +0 -0
  49. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/demo.gif +0 -0
  50. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/demo.tape +0 -0
  51. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
  52. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
  53. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
  54. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
  55. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
  56. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
  57. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
  58. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
  59. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
  60. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
  61. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
  62. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/__init__.py +0 -0
  63. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/conftest.py +0 -0
  64. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/__init__.py +0 -0
  65. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/conftest.py +0 -0
  66. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_dead_end.py +0 -0
  67. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_inflection.py +0 -0
  68. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_orchestrator.py +0 -0
  69. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_retry_loop.py +0 -0
  70. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_scope_creep.py +0 -0
  71. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_stale_context.py +0 -0
  72. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_tool_thrash.py +0 -0
  73. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/exporters/__init__.py +0 -0
  74. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/exporters/test_csv.py +0 -0
  75. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/exporters/test_jsonl.py +0 -0
  76. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/README.md +0 -0
  77. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
  78. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a0b4c2cf1dde0ca56.meta.json +0 -0
  79. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a116ae34b1b09c332.meta.json +0 -0
  80. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1c4c417b35658c9e.meta.json +0 -0
  81. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1e41a901de38f1b5.meta.json +0 -0
  82. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a338f8d0c74612a24.meta.json +0 -0
  83. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a34f6f3c0e7094186.meta.json +0 -0
  84. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a5a5a0cff4d13308b.meta.json +0 -0
  85. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a6b0a3da6a0484db5.meta.json +0 -0
  86. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f73f1790b02cde5.meta.json +0 -0
  87. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f7c17c38a9d8788.meta.json +0 -0
  88. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a853259e2cd7bbe8a.meta.json +0 -0
  89. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a8d9aedb0d0c6e12d.meta.json +0 -0
  90. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aa778bc1d59e4a441.meta.json +0 -0
  91. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aba869dedee4a12ba.meta.json +0 -0
  92. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-ada2746d9774b94db.meta.json +0 -0
  93. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea0132068c64d2dd.meta.json +0 -0
  94. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea215eff50874d5f.meta.json +0 -0
  95. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-afee21f2b3852a4a0.meta.json +0 -0
  96. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
  97. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
  98. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.meta.json +0 -0
  99. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
  100. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.meta.json +0 -0
  101. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
  102. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.meta.json +0 -0
  103. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
  104. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.meta.json +0 -0
  105. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
  106. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.meta.json +0 -0
  107. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
  108. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.meta.json +0 -0
  109. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
  110. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.meta.json +0 -0
  111. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
  112. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.meta.json +0 -0
  113. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
  114. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.meta.json +0 -0
  115. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
  116. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.meta.json +0 -0
  117. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
  118. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.meta.json +0 -0
  119. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
  120. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.meta.json +0 -0
  121. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
  122. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.meta.json +0 -0
  123. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
  124. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
  125. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.meta.json +0 -0
  126. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
  127. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.meta.json +0 -0
  128. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
  129. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.meta.json +0 -0
  130. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
  131. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
  132. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
  133. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results/tool-results/bosbkda0h.txt +0 -0
  134. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
  135. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/scrub.py +0 -0
  136. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
  137. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
  138. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
  139. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
  140. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
  141. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/parsers/__init__.py +0 -0
  142. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/parsers/test_claude_code.py +0 -0
  143. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/parsers/test_claude_code_integration.py +0 -0
  144. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/recommender/__init__.py +0 -0
  145. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/recommender/test_claude_md.py +0 -0
  146. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/recommender/test_evidence.py +0 -0
  147. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/renderers/__init__.py +0 -0
  148. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/renderers/test_report.py +0 -0
  149. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
  150. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_aggregate.py +0 -0
  151. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_cli_export.py +0 -0
  152. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_discovery.py +0 -0
  153. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_github_summary.py +0 -0
  154. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_harvest.py +0 -0
  155. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_harvest_check.py +0 -0
  156. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_smoke.py +0 -0
  157. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_tokenizer.py +0 -0
  158. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_trace_tui.py +0 -0
  159. {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_watcher.py +0 -0
@@ -46,18 +46,6 @@ jobs:
46
46
 
47
47
  - name: Publish
48
48
  uses: pypa/gh-action-pypi-publish@release/v1
49
+ with:
50
+ skip-existing: true
49
51
 
50
- tag-action:
51
- name: Tag floating action version
52
- needs: [publish]
53
- runs-on: ubuntu-latest
54
- permissions:
55
- contents: write
56
- steps:
57
- - uses: actions/checkout@v4
58
-
59
- - name: Push floating major tag
60
- run: |
61
- MAJOR=$(echo "${{ github.event.release.tag_name }}" | cut -d. -f1)
62
- git tag -f "$MAJOR"
63
- git push origin "$MAJOR" --force
@@ -2,6 +2,39 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v1.2.0 (2026-05-17)
6
+
7
+ ### Features
8
+
9
+ - --until DATE, autopsy --json, export --format json (M12 #77 #78 #79)
10
+ ([#84](https://github.com/jacquardlabs/cctx/pull/84),
11
+ [`803b5f1`](https://github.com/jacquardlabs/cctx/commit/803b5f190404679ddef4cbbec7478d04c57b8413))
12
+
13
+
14
+ ## v1.1.0 (2026-05-17)
15
+
16
+ ### Chores
17
+
18
+ - Add skip-existing to pypi publish action
19
+ ([`23d7e16`](https://github.com/jacquardlabs/cctx/commit/23d7e16e18074da3c25899ba98298100ad3c1ad3))
20
+
21
+ ### Features
22
+
23
+ - M9 polish — verdict headline, --top N, --turn N
24
+ ([#83](https://github.com/jacquardlabs/cctx/pull/83),
25
+ [`b0d2f27`](https://github.com/jacquardlabs/cctx/commit/b0d2f273a373c5a2f52c9de3a3fb2721da59c4f5))
26
+
27
+ - M9 polish — verdict headline, --top N, and --turn N
28
+ ([#83](https://github.com/jacquardlabs/cctx/pull/83),
29
+ [`b0d2f27`](https://github.com/jacquardlabs/cctx/commit/b0d2f273a373c5a2f52c9de3a3fb2721da59c4f5))
30
+
31
+ ### Refactoring
32
+
33
+ - Cache verdict, fix markup=False bug, use reverse=True
34
+ ([#83](https://github.com/jacquardlabs/cctx/pull/83),
35
+ [`b0d2f27`](https://github.com/jacquardlabs/cctx/commit/b0d2f273a373c5a2f52c9de3a3fb2721da59c4f5))
36
+
37
+
5
38
  ## v1.0.0 (2026-05-17)
6
39
 
7
40
  ### Continuous Integration
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctx-cli
3
- Version: 1.0.0
3
+ Version: 1.2.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.0.0"
3
+ __version__ = "1.2.0"
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  from datetime import datetime, timedelta, timezone
16
16
  from pathlib import Path
17
+ from typing import IO
17
18
 
18
19
  import rich_click as click
19
20
 
@@ -31,6 +32,7 @@ from cctx.renderers.terminal import (
31
32
  render_harvest_results,
32
33
  render_projects,
33
34
  render_sessions,
35
+ render_turn,
34
36
  )
35
37
  from cctx.tokenizer import tokenize_session
36
38
 
@@ -217,6 +219,14 @@ def ls(project: Path | None) -> None:
217
219
  type=str,
218
220
  help="Cross-session mode: 7, 7d, 2w, 2026-05-01, or 2026-05-01..2026-05-15.",
219
221
  )
222
+ @click.option(
223
+ "--until",
224
+ "until_date",
225
+ default=None,
226
+ metavar="DATE",
227
+ type=str,
228
+ help="End date for --since window (YYYY-MM-DD). Requires --since.",
229
+ )
220
230
  @click.option(
221
231
  "--latest",
222
232
  is_flag=True,
@@ -245,13 +255,40 @@ def ls(project: Path | None) -> None:
245
255
  default=False,
246
256
  help="Exit 1 if any findings are detected (single-session only).",
247
257
  )
258
+ @click.option(
259
+ "--top",
260
+ "top_n",
261
+ default=None,
262
+ metavar="N",
263
+ type=click.IntRange(min=1),
264
+ help="Show only the top N patterns by session count (--since mode only).",
265
+ )
266
+ @click.option(
267
+ "--turn",
268
+ "turn_num",
269
+ default=None,
270
+ metavar="N",
271
+ type=click.IntRange(min=1),
272
+ help="Show details for turn N (single-session only).",
273
+ )
274
+ @click.option(
275
+ "--json",
276
+ "json_out",
277
+ is_flag=True,
278
+ default=False,
279
+ help="Output diagnosis as JSON to stdout (single-session only).",
280
+ )
248
281
  def autopsy(
249
282
  target: Path | None,
250
283
  since: str | None,
284
+ until_date: str | None,
251
285
  latest: bool,
252
286
  html_out: Path | None,
253
287
  github_summary: bool,
254
288
  fail_on_findings: bool,
289
+ top_n: int | None,
290
+ turn_num: int | None,
291
+ json_out: bool,
255
292
  ) -> None:
256
293
  """Diagnose a session or project directory.
257
294
 
@@ -267,6 +304,14 @@ def autopsy(
267
304
  raise click.UsageError("--latest and --since are mutually exclusive.")
268
305
  if fail_on_findings and since is not None:
269
306
  raise click.UsageError("--fail-on-findings is not supported with --since.")
307
+ if top_n is not None and since is None:
308
+ raise click.UsageError("--top requires --since.")
309
+ if turn_num is not None and since is not None:
310
+ raise click.UsageError("--turn is not supported with --since.")
311
+ if until_date is not None and since is None:
312
+ raise click.UsageError("--until requires --since.")
313
+ if json_out and since is not None:
314
+ raise click.UsageError("--json is not supported with --since.")
270
315
 
271
316
  if target is None:
272
317
  if not latest:
@@ -303,8 +348,20 @@ def autopsy(
303
348
  # Cross-session path
304
349
  project_dir = target if target.is_dir() else target.parent
305
350
  start, end, label = parse_since(since)
351
+ if until_date is not None:
352
+ try:
353
+ end = datetime.fromisoformat(until_date.strip()).replace(
354
+ tzinfo=UTC, hour=23, minute=59, second=59
355
+ )
356
+ except ValueError:
357
+ raise click.UsageError(
358
+ f"Invalid --until date '{until_date}'. Expected YYYY-MM-DD."
359
+ ) from None
360
+ label = f"{label} until {until_date.strip()}"
306
361
  diagnoses = aggregate.run(project_dir, start, end)
307
362
  ev = evidence_mod.accumulate(diagnoses)
363
+ if top_n is not None:
364
+ ev = dict(sorted(ev.items(), key=lambda x: x[1].session_count, reverse=True)[:top_n])
308
365
  patches = claude_md.generate_from_evidence(ev)
309
366
  report = AggregateReport(
310
367
  period_label=label,
@@ -327,14 +384,21 @@ def autopsy(
327
384
  trace = tokenize_session(parse_session(target))
328
385
  diagnosis = diagnostician.run(trace)
329
386
  diagnosis = claude_md.generate(diagnosis)
330
- if html_out is not None:
387
+ if json_out:
388
+ import json as _json
389
+
390
+ from cctx.exporters.jsonl import export_diagnosis as _export_diag
391
+ click.echo(_json.dumps(_json.loads(_export_diag(diagnosis, trace)), indent=2))
392
+ elif turn_num is not None:
393
+ render_turn(trace, diagnosis, turn_num)
394
+ elif html_out is not None:
331
395
  from cctx.renderers.report import render_html
332
396
  html_out.write_text(render_html(diagnosis, trace), encoding="utf-8")
333
397
  click.echo(f"HTML report written to {html_out}")
334
- if github_summary:
398
+ elif github_summary:
335
399
  from cctx.renderers.github import write_github_summary
336
400
  write_github_summary(diagnosis)
337
- if html_out is None and not github_summary:
401
+ else:
338
402
  render_diagnosis(diagnosis, session_path=target)
339
403
  if fail_on_findings and diagnosis.findings:
340
404
  raise SystemExit(1)
@@ -345,10 +409,10 @@ def autopsy(
345
409
  @click.option(
346
410
  "--format",
347
411
  "fmt",
348
- type=click.Choice(["jsonl", "csv"]),
412
+ type=click.Choice(["jsonl", "csv", "json"]),
349
413
  default="jsonl",
350
414
  show_default=True,
351
- help="Output format: jsonl (one object per session) or csv (one row per turn).",
415
+ help="Output format: jsonl (one object per session), csv (one row per turn), or json (array).",
352
416
  )
353
417
  @click.option(
354
418
  "--out",
@@ -370,6 +434,7 @@ def export(target: Path, fmt: str, out: Path | None, no_content: bool) -> None:
370
434
  import sys
371
435
 
372
436
  from cctx.exporters import csv as csv_mod
437
+ from cctx.exporters import json as json_mod
373
438
  from cctx.exporters import jsonl as jsonl_mod
374
439
 
375
440
  trace = tokenize_session(parse_session(target))
@@ -377,16 +442,19 @@ def export(target: Path, fmt: str, out: Path | None, no_content: bool) -> None:
377
442
  diagnosis = claude_md.generate(diagnosis)
378
443
  pairs = [(diagnosis, trace)]
379
444
 
445
+ def _write(fh: IO[str]) -> None:
446
+ if fmt == "jsonl":
447
+ jsonl_mod.write(pairs, fh, include_content=not no_content)
448
+ elif fmt == "json":
449
+ json_mod.write(pairs, fh, include_content=not no_content)
450
+ else:
451
+ csv_mod.write(pairs, fh)
452
+
380
453
  if out is not None:
381
454
  with open(out, "w", encoding="utf-8") as fh:
382
- if fmt == "jsonl":
383
- jsonl_mod.write(pairs, fh, include_content=not no_content)
384
- else:
385
- csv_mod.write(pairs, fh)
386
- elif fmt == "jsonl":
387
- jsonl_mod.write(pairs, sys.stdout, include_content=not no_content)
455
+ _write(fh)
388
456
  else:
389
- csv_mod.write(pairs, sys.stdout)
457
+ _write(sys.stdout)
390
458
 
391
459
 
392
460
  @cli.command()
@@ -0,0 +1,23 @@
1
+ """JSON exporter — full session array as pretty-printed JSON."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import IO, TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from cctx.models import Diagnosis, SessionTrace
9
+
10
+ from cctx.exporters.jsonl import export_diagnosis
11
+
12
+
13
+ def write(
14
+ diagnoses: list[tuple[Diagnosis, SessionTrace]],
15
+ out: IO[str],
16
+ *,
17
+ include_content: bool = True,
18
+ ) -> None:
19
+ objects = [
20
+ json.loads(export_diagnosis(diagnosis, trace, include_content=include_content))
21
+ for diagnosis, trace in diagnoses
22
+ ]
23
+ out.write(json.dumps(objects, indent=2) + "\n")
@@ -225,6 +225,13 @@ class Diagnosis:
225
225
  waste_cost_usd: float
226
226
  analysed_at: datetime
227
227
 
228
+ @property
229
+ def verdict(self) -> str:
230
+ if not self.findings:
231
+ return "clean session"
232
+ seen = dict.fromkeys(f.kind for f in self.findings)
233
+ return " + ".join(KIND_LABEL[k] for k in seen)
234
+
228
235
 
229
236
  @dataclass
230
237
  class KindEvidence:
@@ -24,7 +24,7 @@ from cctx.models import KIND_LABEL, FindingKind, Severity
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  from cctx.discovery import ProjectInfo
27
- from cctx.models import AggregateReport, Diagnosis
27
+ from cctx.models import AggregateReport, Diagnosis, SessionTrace
28
28
 
29
29
  _SEVERITY_STYLE = {
30
30
  Severity.HIGH: "bold red",
@@ -49,6 +49,9 @@ def render_diagnosis(
49
49
 
50
50
  # Header
51
51
  con.print(Rule(f"cctx autopsy — session {diagnosis.session_id}"))
52
+ verdict = diagnosis.verdict
53
+ verdict_style = "bold green" if not diagnosis.findings else "bold red"
54
+ con.print(Text(f"Verdict: {verdict}", style=verdict_style))
52
55
  cost_line = f"Session cost: ~${diagnosis.total_cost_usd:.2f}"
53
56
  if diagnosis.waste_cost_usd > 0:
54
57
  pct = (
@@ -169,6 +172,56 @@ def render_aggregate_drilldown(
169
172
  con.print("No matching findings.")
170
173
 
171
174
 
175
+ def render_turn(
176
+ trace: SessionTrace,
177
+ diagnosis: Diagnosis,
178
+ turn_num: int,
179
+ *,
180
+ console: Console | None = None,
181
+ ) -> None:
182
+ """Render details for a single turn N from a session."""
183
+ con = console or _default_console()
184
+
185
+ turn = next((t for t in trace.turns if t.turn_number == turn_num), None)
186
+ if turn is None:
187
+ con.print(Text(
188
+ f"Turn {turn_num} not found (session has {len(trace.turns)} turns).",
189
+ style="red",
190
+ ))
191
+ return
192
+
193
+ con.print(Rule(f"Turn {turn_num} — {turn.role} — {turn.timestamp.strftime('%H:%M:%S')}"))
194
+
195
+ text = turn.text
196
+ if text:
197
+ preview = text[:500]
198
+ if len(text) > 500:
199
+ preview += f"\n… [{len(text) - 500} more chars]"
200
+ con.print(preview)
201
+
202
+ for tu in turn.tool_uses:
203
+ con.print(Text(f" tool_use: {tu.tool_name}", style="cyan"))
204
+
205
+ for tr in turn.tool_results:
206
+ style = "red" if tr.is_error else "dim"
207
+ content = tr.content
208
+ preview = content[:200] + ("…" if len(content) > 200 else "")
209
+ con.print(Text(f" tool_result ({tr.tool_name}): {preview}", style=style))
210
+
211
+ # Findings that span this turn
212
+ touching = [
213
+ f for f in diagnosis.findings
214
+ if f.first_turn <= turn_num <= (f.last_turn or f.first_turn)
215
+ ]
216
+ if touching:
217
+ con.print()
218
+ con.print(Text("Findings active at this turn:", style="bold"))
219
+ for finding in touching:
220
+ style = _SEVERITY_STYLE.get(finding.severity, "")
221
+ label = _KIND_LABEL.get(finding.kind, finding.kind.value.upper())
222
+ con.print(Text(f" [{label}]", style=style), finding.summary)
223
+
224
+
172
225
  def render_harvest_results(
173
226
  results: list, # list[ApplyResult] — ApplyStatus is harvest-internal; imported lazily
174
227
  *,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctx-cli"
7
- version = "1.0.0"
7
+ version = "1.2.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"
@@ -47,6 +47,7 @@ version_toml = ["pyproject.toml:project.version"]
47
47
  version_variables = ["cctx/__init__.py:__version__"]
48
48
  tag_format = "v{version}"
49
49
  commit_message = "chore(release): v{version} [skip ci]"
50
+ major_on_zero = false
50
51
  build_command = "pip install build && python -m build"
51
52
  upload_to_vcs_release = true
52
53
 
@@ -309,3 +309,242 @@ def test_fail_on_findings_incompatible_with_since(runner):
309
309
  ["autopsy", str(FIXTURE_PATH.parent), "--since", "7", "--fail-on-findings"],
310
310
  )
311
311
  assert result.exit_code != 0
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # --top N tests (#75)
316
+ # ---------------------------------------------------------------------------
317
+
318
+
319
+ def test_top_requires_since(runner, session_jsonl):
320
+ """--top without --since → non-zero exit (UsageError)."""
321
+ from cctx.cli import cli
322
+
323
+ result = runner.invoke(cli, ["autopsy", str(session_jsonl), "--top", "3"])
324
+ assert result.exit_code != 0
325
+ assert "since" in result.output.lower() or "Error" in result.output
326
+
327
+
328
+ def test_top_with_since_accepted(runner, tmp_path):
329
+ """--top N with --since → exit 0."""
330
+ from cctx.cli import cli
331
+
332
+ project_dir = tmp_path / "-Users-test-Projects-demo"
333
+ project_dir.mkdir()
334
+ session_id = "top-test-sess"
335
+ line = {
336
+ "type": "user", "uuid": f"{session_id}-u1", "parentUuid": None,
337
+ "isSidechain": False, "timestamp": "2026-05-14T10:00:00.000Z",
338
+ "sessionId": session_id, "version": "2.1.138",
339
+ "cwd": "/Users/test/Projects/demo", "gitBranch": "main",
340
+ "userType": "external", "entrypoint": "cli",
341
+ "message": {"role": "user", "content": "hello"},
342
+ }
343
+ (project_dir / f"{session_id}.jsonl").write_text(json.dumps(line) + "\n")
344
+
345
+ result = runner.invoke(
346
+ cli, ["autopsy", str(project_dir), "--since", "7", "--top", "2"],
347
+ catch_exceptions=False,
348
+ )
349
+ assert result.exit_code == 0
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # --turn N tests (#76)
354
+ # ---------------------------------------------------------------------------
355
+
356
+
357
+ def test_turn_incompatible_with_since(runner, tmp_path):
358
+ """--turn N + --since → non-zero exit (UsageError)."""
359
+ from cctx.cli import cli
360
+
361
+ project_dir = tmp_path / "-Users-test-Projects-demo"
362
+ project_dir.mkdir()
363
+ result = runner.invoke(
364
+ cli, ["autopsy", str(project_dir), "--since", "7", "--turn", "3"],
365
+ )
366
+ assert result.exit_code != 0
367
+ assert "since" in result.output.lower() or "Error" in result.output
368
+
369
+
370
+ def test_turn_shows_turn_details(runner, session_jsonl):
371
+ """--turn 1 on a session with one turn prints turn details."""
372
+ from cctx.cli import cli
373
+
374
+ result = runner.invoke(
375
+ cli, ["autopsy", str(session_jsonl), "--turn", "1"],
376
+ catch_exceptions=False,
377
+ )
378
+ assert result.exit_code == 0
379
+ assert "Turn 1" in result.output
380
+
381
+
382
+ def test_turn_out_of_range_shows_not_found(runner, session_jsonl):
383
+ """--turn 999 on a one-turn session shows 'not found' message."""
384
+ from cctx.cli import cli
385
+
386
+ result = runner.invoke(
387
+ cli, ["autopsy", str(session_jsonl), "--turn", "999"],
388
+ catch_exceptions=False,
389
+ )
390
+ assert result.exit_code == 0
391
+ assert "not found" in result.output.lower() or "999" in result.output
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # --until DATE tests (#77)
396
+ # ---------------------------------------------------------------------------
397
+
398
+
399
+ def test_until_requires_since(runner, session_jsonl):
400
+ """--until without --since → non-zero exit (UsageError)."""
401
+ from cctx.cli import cli
402
+
403
+ result = runner.invoke(cli, ["autopsy", str(session_jsonl), "--until", "2026-05-15"])
404
+ assert result.exit_code != 0
405
+ assert "since" in result.output.lower() or "Error" in result.output
406
+
407
+
408
+ def test_until_with_since_accepted(runner, tmp_path):
409
+ """--until DATE + --since → exit 0."""
410
+ from cctx.cli import cli
411
+
412
+ project_dir = tmp_path / "-Users-test-Projects-demo"
413
+ project_dir.mkdir()
414
+ session_id = "until-test-sess"
415
+ line = {
416
+ "type": "user", "uuid": f"{session_id}-u1", "parentUuid": None,
417
+ "isSidechain": False, "timestamp": "2026-05-14T10:00:00.000Z",
418
+ "sessionId": session_id, "version": "2.1.138",
419
+ "cwd": "/Users/test/Projects/demo", "gitBranch": "main",
420
+ "userType": "external", "entrypoint": "cli",
421
+ "message": {"role": "user", "content": "hello"},
422
+ }
423
+ (project_dir / f"{session_id}.jsonl").write_text(json.dumps(line) + "\n")
424
+
425
+ result = runner.invoke(
426
+ cli,
427
+ ["autopsy", str(project_dir), "--since", "7", "--until", "2026-05-15"],
428
+ catch_exceptions=False,
429
+ )
430
+ assert result.exit_code == 0
431
+
432
+
433
+ def test_until_invalid_date(runner, tmp_path):
434
+ """--until with a non-date string → non-zero exit."""
435
+ from cctx.cli import cli
436
+
437
+ project_dir = tmp_path / "-Users-test-Projects-demo"
438
+ project_dir.mkdir()
439
+
440
+ result = runner.invoke(
441
+ cli, ["autopsy", str(project_dir), "--since", "7", "--until", "not-a-date"],
442
+ )
443
+ assert result.exit_code != 0
444
+
445
+
446
+ def test_until_label_includes_date(runner, tmp_path):
447
+ """--until DATE appears in the period label in aggregate output."""
448
+ from cctx.cli import cli
449
+
450
+ project_dir = tmp_path / "-Users-test-Projects-demo"
451
+ project_dir.mkdir()
452
+ session_id = "until-label-sess"
453
+ line = {
454
+ "type": "user", "uuid": f"{session_id}-u1", "parentUuid": None,
455
+ "isSidechain": False, "timestamp": "2026-05-10T10:00:00.000Z",
456
+ "sessionId": session_id, "version": "2.1.138",
457
+ "cwd": "/Users/test/Projects/demo", "gitBranch": "main",
458
+ "userType": "external", "entrypoint": "cli",
459
+ "message": {"role": "user", "content": "hello"},
460
+ }
461
+ (project_dir / f"{session_id}.jsonl").write_text(json.dumps(line) + "\n")
462
+
463
+ result = runner.invoke(
464
+ cli,
465
+ ["autopsy", str(project_dir), "--since", "30", "--until", "2026-05-15"],
466
+ catch_exceptions=False,
467
+ )
468
+ assert result.exit_code == 0
469
+ assert "2026-05-15" in result.output
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # autopsy --json tests (#78)
474
+ # ---------------------------------------------------------------------------
475
+
476
+
477
+ def test_autopsy_json_outputs_valid_json(runner, session_jsonl):
478
+ """--json flag produces valid JSON on stdout."""
479
+ from cctx.cli import cli
480
+
481
+ result = runner.invoke(
482
+ cli, ["autopsy", str(session_jsonl), "--json"],
483
+ catch_exceptions=False,
484
+ )
485
+ assert result.exit_code == 0
486
+ data = json.loads(result.output)
487
+ assert "session_id" in data
488
+ assert "findings" in data
489
+
490
+
491
+ def test_autopsy_json_incompatible_with_since(runner, tmp_path):
492
+ """--json + --since → non-zero exit (UsageError)."""
493
+ from cctx.cli import cli
494
+
495
+ project_dir = tmp_path / "-Users-test-Projects-demo"
496
+ project_dir.mkdir()
497
+
498
+ result = runner.invoke(
499
+ cli, ["autopsy", str(project_dir), "--since", "7", "--json"],
500
+ )
501
+ assert result.exit_code != 0
502
+
503
+
504
+ def test_autopsy_json_contains_cost(runner, session_jsonl):
505
+ """--json output includes cost fields."""
506
+ from cctx.cli import cli
507
+
508
+ result = runner.invoke(
509
+ cli, ["autopsy", str(session_jsonl), "--json"],
510
+ catch_exceptions=False,
511
+ )
512
+ assert result.exit_code == 0
513
+ data = json.loads(result.output)
514
+ assert "total_cost_usd" in data
515
+ assert "waste_cost_usd" in data
516
+
517
+
518
+ # ---------------------------------------------------------------------------
519
+ # export --format json tests (#79)
520
+ # ---------------------------------------------------------------------------
521
+
522
+
523
+ def test_export_json_produces_valid_json(runner, session_jsonl):
524
+ """export --format json produces a valid JSON array."""
525
+ from cctx.cli import cli
526
+
527
+ result = runner.invoke(
528
+ cli, ["export", str(session_jsonl), "--format", "json"],
529
+ catch_exceptions=False,
530
+ )
531
+ assert result.exit_code == 0
532
+ data = json.loads(result.output)
533
+ assert isinstance(data, list)
534
+ assert len(data) == 1
535
+ assert "session_id" in data[0]
536
+
537
+
538
+ def test_export_json_to_file(runner, session_jsonl, tmp_path):
539
+ """export --format json --out FILE writes a valid JSON file."""
540
+ from cctx.cli import cli
541
+
542
+ out_path = tmp_path / "out.json"
543
+ result = runner.invoke(
544
+ cli, ["export", str(session_jsonl), "--format", "json", "--out", str(out_path)],
545
+ catch_exceptions=False,
546
+ )
547
+ assert result.exit_code == 0
548
+ data = json.loads(out_path.read_text())
549
+ assert isinstance(data, list)
550
+ assert data[0]["session_id"] == "test-sess-01"
@@ -643,3 +643,57 @@ def test_aggregate_report_instantiates():
643
643
  )
644
644
  assert report.sessions_analysed == 12
645
645
  assert FindingKind.RETRY_LOOP in report.by_kind
646
+
647
+
648
+ # ---------------------------------------------------------------------------
649
+ # Diagnosis.verdict property (#74)
650
+ # ---------------------------------------------------------------------------
651
+
652
+
653
+ def _make_finding_of_kind(kind, first_turn=5):
654
+ from cctx.models import Confidence, Finding, Severity
655
+ return Finding(
656
+ kind=kind,
657
+ severity=Severity.MEDIUM,
658
+ confidence=Confidence.MEDIUM,
659
+ first_turn=first_turn,
660
+ last_turn=None,
661
+ evidence={},
662
+ cost_usd=None,
663
+ summary="test",
664
+ )
665
+
666
+
667
+ def _make_diag(findings):
668
+ from cctx.models import Diagnosis
669
+ return Diagnosis(
670
+ session_id="abc",
671
+ findings=findings,
672
+ inflection_turn=None,
673
+ patches=[],
674
+ total_cost_usd=1.0,
675
+ waste_cost_usd=0.0,
676
+ analysed_at=_utcnow(),
677
+ )
678
+
679
+
680
+ def test_verdict_clean_session():
681
+ d = _make_diag([])
682
+ assert d.verdict == "clean session"
683
+
684
+
685
+ def test_verdict_single_finding():
686
+ from cctx.models import FindingKind
687
+ d = _make_diag([_make_finding_of_kind(FindingKind.RETRY_LOOP)])
688
+ assert d.verdict == "RETRY LOOP"
689
+
690
+
691
+ def test_verdict_multiple_kinds_deduped_in_order():
692
+ from cctx.models import FindingKind
693
+ findings = [
694
+ _make_finding_of_kind(FindingKind.SCOPE_CREEP),
695
+ _make_finding_of_kind(FindingKind.RETRY_LOOP),
696
+ _make_finding_of_kind(FindingKind.SCOPE_CREEP), # duplicate kind
697
+ ]
698
+ d = _make_diag(findings)
699
+ assert d.verdict == "SCOPE CREEP + RETRY LOOP"