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.
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/.github/workflows/publish.yml +2 -14
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/CHANGELOG.md +33 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/PKG-INFO +1 -1
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/__init__.py +1 -1
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/cli.py +80 -12
- cctx_cli-1.2.0/cctx/exporters/json.py +23 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/models.py +7 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/terminal.py +54 -1
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/pyproject.toml +2 -1
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_cli.py +239 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_models.py +54 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_terminal_renderer.py +100 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/.github/workflows/ci.yml +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/.github/workflows/release.yml +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/.gitignore +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/CLAUDE.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/DESIGN.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/PRODUCT.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/README.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/action.yml +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/aggregate.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/inflection.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/discovery.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/exporters/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/exporters/csv.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/exporters/jsonl.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/harvest.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/parsers/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/parsers/claude_code.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/pricing.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/recommender/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/recommender/claude_md.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/recommender/evidence.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/github.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/report.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/renderers/trace_tui.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/tokenizer.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx/watcher.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/cctx-project-brief.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/demo.gif +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/demo.tape +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/conftest.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/conftest.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_dead_end.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_inflection.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_orchestrator.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_retry_loop.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_scope_creep.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_stale_context.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/diagnostician/test_tool_thrash.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/exporters/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/exporters/test_csv.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/exporters/test_jsonl.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/README.md +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
- {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
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/scrub.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/parsers/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/parsers/test_claude_code.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/parsers/test_claude_code_integration.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/recommender/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/recommender/test_claude_md.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/recommender/test_evidence.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/renderers/__init__.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/renderers/test_report.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_aggregate.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_cli_export.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_discovery.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_github_summary.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_harvest.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_harvest_check.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_smoke.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_tokenizer.py +0 -0
- {cctx_cli-1.0.0 → cctx_cli-1.2.0}/tests/test_trace_tui.py +0 -0
- {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
|
|
@@ -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
|
|
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
|
-
|
|
398
|
+
elif github_summary:
|
|
335
399
|
from cctx.renderers.github import write_github_summary
|
|
336
400
|
write_github_summary(diagnosis)
|
|
337
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|