cctx-cli 1.1.0__tar.gz → 1.3.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.1.0 → cctx_cli-1.3.0}/CHANGELOG.md +68 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/PKG-INFO +1 -1
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/__init__.py +1 -1
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/cli.py +62 -13
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/aggregate.py +9 -6
- cctx_cli-1.3.0/cctx/diagnostician/patterns/project_specific.py +179 -0
- cctx_cli-1.3.0/cctx/exporters/json.py +23 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/models.py +15 -1
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/recommender/claude_md.py +25 -2
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/terminal.py +32 -12
- cctx_cli-1.3.0/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +1312 -0
- cctx_cli-1.3.0/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +235 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/pyproject.toml +1 -1
- cctx_cli-1.3.0/tests/diagnostician/test_project_specific.py +218 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_aggregate.py +6 -5
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_cli.py +288 -0
- cctx_cli-1.3.0/tests/test_models_project_pattern.py +37 -0
- cctx_cli-1.3.0/tests/test_recommender.py +56 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_terminal_renderer.py +74 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/.github/workflows/ci.yml +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/.github/workflows/publish.yml +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/.github/workflows/release.yml +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/.gitignore +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/CLAUDE.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/DESIGN.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/PRODUCT.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/README.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/action.yml +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/inflection.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/discovery.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/exporters/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/exporters/csv.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/exporters/jsonl.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/harvest.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/parsers/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/parsers/claude_code.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/pricing.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/recommender/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/recommender/evidence.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/github.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/report.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/trace_tui.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/tokenizer.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/watcher.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx-project-brief.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/demo.gif +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/demo.tape +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/conftest.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/conftest.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_dead_end.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_inflection.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_orchestrator.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_retry_loop.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_scope_creep.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_stale_context.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_tool_thrash.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/exporters/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/exporters/test_csv.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/exporters/test_jsonl.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/README.md +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a0b4c2cf1dde0ca56.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a116ae34b1b09c332.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1c4c417b35658c9e.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1e41a901de38f1b5.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a338f8d0c74612a24.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a34f6f3c0e7094186.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a5a5a0cff4d13308b.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a6b0a3da6a0484db5.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f73f1790b02cde5.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f7c17c38a9d8788.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a853259e2cd7bbe8a.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a8d9aedb0d0c6e12d.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aa778bc1d59e4a441.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aba869dedee4a12ba.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-ada2746d9774b94db.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea0132068c64d2dd.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea215eff50874d5f.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-afee21f2b3852a4a0.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.meta.json +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results/tool-results/bosbkda0h.txt +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/scrub.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/parsers/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/parsers/test_claude_code.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/parsers/test_claude_code_integration.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/recommender/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/recommender/test_claude_md.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/recommender/test_evidence.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/renderers/__init__.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/renderers/test_report.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_cli_export.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_discovery.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_github_summary.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_harvest.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_harvest_check.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_models.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_smoke.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_tokenizer.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_trace_tui.py +0 -0
- {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_watcher.py +0 -0
|
@@ -2,6 +2,74 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v1.3.0 (2026-05-17)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Drop unused turn_number from result_map in _find_pairs
|
|
10
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
11
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
12
|
+
|
|
13
|
+
- Restore WHY comment, fix_key != failure_key guard, tighten tuple annotation
|
|
14
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
15
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
16
|
+
|
|
17
|
+
- Ruff lint failures (E501, F401, E741, I001) ([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
18
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
- M14 project-pattern-detection implementation plan
|
|
23
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
24
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
25
|
+
|
|
26
|
+
- M14 project-specific pattern detection design spec
|
|
27
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
28
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
29
|
+
|
|
30
|
+
- Note why harvest --since skips project_specific.detect()
|
|
31
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
32
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
33
|
+
|
|
34
|
+
### Features
|
|
35
|
+
|
|
36
|
+
- Add ProjectPattern model, AggregateReport.project_patterns, FindingKind.PROJECT_PATTERN
|
|
37
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
38
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
39
|
+
|
|
40
|
+
- Aggregate.run() returns (Diagnosis, SessionTrace) pairs
|
|
41
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
42
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
43
|
+
|
|
44
|
+
- Generate_from_patterns() — CLAUDE.md patches from ProjectPatterns
|
|
45
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
46
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
47
|
+
|
|
48
|
+
- M14 project-specific pattern detection ([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
49
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
50
|
+
|
|
51
|
+
- Project_specific.detect() — cross-session failure/fix pattern detector
|
|
52
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
53
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
54
|
+
|
|
55
|
+
- Render_aggregate() shows project-specific patterns table
|
|
56
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
57
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
58
|
+
|
|
59
|
+
- Wire project_specific.detect() into autopsy and harvest --since paths
|
|
60
|
+
([#86](https://github.com/jacquardlabs/cctx/pull/86),
|
|
61
|
+
[`cefc438`](https://github.com/jacquardlabs/cctx/commit/cefc438f9ff638ba2abf529663b5e24707f03bbb))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
## v1.2.0 (2026-05-17)
|
|
65
|
+
|
|
66
|
+
### Features
|
|
67
|
+
|
|
68
|
+
- --until DATE, autopsy --json, export --format json (M12 #77 #78 #79)
|
|
69
|
+
([#84](https://github.com/jacquardlabs/cctx/pull/84),
|
|
70
|
+
[`803b5f1`](https://github.com/jacquardlabs/cctx/commit/803b5f190404679ddef4cbbec7478d04c57b8413))
|
|
71
|
+
|
|
72
|
+
|
|
5
73
|
## v1.1.0 (2026-05-17)
|
|
6
74
|
|
|
7
75
|
### Chores
|
|
@@ -14,11 +14,13 @@ 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
|
|
|
20
21
|
from cctx import diagnostician
|
|
21
22
|
from cctx.diagnostician import aggregate
|
|
23
|
+
from cctx.diagnostician.patterns import project_specific
|
|
22
24
|
from cctx.discovery import complete_project as _complete_project
|
|
23
25
|
from cctx.models import KIND_LABEL, AggregateReport
|
|
24
26
|
from cctx.parsers.claude_code import parse_session
|
|
@@ -218,6 +220,14 @@ def ls(project: Path | None) -> None:
|
|
|
218
220
|
type=str,
|
|
219
221
|
help="Cross-session mode: 7, 7d, 2w, 2026-05-01, or 2026-05-01..2026-05-15.",
|
|
220
222
|
)
|
|
223
|
+
@click.option(
|
|
224
|
+
"--until",
|
|
225
|
+
"until_date",
|
|
226
|
+
default=None,
|
|
227
|
+
metavar="DATE",
|
|
228
|
+
type=str,
|
|
229
|
+
help="End date for --since window (YYYY-MM-DD). Requires --since.",
|
|
230
|
+
)
|
|
221
231
|
@click.option(
|
|
222
232
|
"--latest",
|
|
223
233
|
is_flag=True,
|
|
@@ -262,15 +272,24 @@ def ls(project: Path | None) -> None:
|
|
|
262
272
|
type=click.IntRange(min=1),
|
|
263
273
|
help="Show details for turn N (single-session only).",
|
|
264
274
|
)
|
|
275
|
+
@click.option(
|
|
276
|
+
"--json",
|
|
277
|
+
"json_out",
|
|
278
|
+
is_flag=True,
|
|
279
|
+
default=False,
|
|
280
|
+
help="Output diagnosis as JSON to stdout (single-session only).",
|
|
281
|
+
)
|
|
265
282
|
def autopsy(
|
|
266
283
|
target: Path | None,
|
|
267
284
|
since: str | None,
|
|
285
|
+
until_date: str | None,
|
|
268
286
|
latest: bool,
|
|
269
287
|
html_out: Path | None,
|
|
270
288
|
github_summary: bool,
|
|
271
289
|
fail_on_findings: bool,
|
|
272
290
|
top_n: int | None,
|
|
273
291
|
turn_num: int | None,
|
|
292
|
+
json_out: bool,
|
|
274
293
|
) -> None:
|
|
275
294
|
"""Diagnose a session or project directory.
|
|
276
295
|
|
|
@@ -290,6 +309,10 @@ def autopsy(
|
|
|
290
309
|
raise click.UsageError("--top requires --since.")
|
|
291
310
|
if turn_num is not None and since is not None:
|
|
292
311
|
raise click.UsageError("--turn is not supported with --since.")
|
|
312
|
+
if until_date is not None and since is None:
|
|
313
|
+
raise click.UsageError("--until requires --since.")
|
|
314
|
+
if json_out and since is not None:
|
|
315
|
+
raise click.UsageError("--json is not supported with --since.")
|
|
293
316
|
|
|
294
317
|
if target is None:
|
|
295
318
|
if not latest:
|
|
@@ -326,11 +349,24 @@ def autopsy(
|
|
|
326
349
|
# Cross-session path
|
|
327
350
|
project_dir = target if target.is_dir() else target.parent
|
|
328
351
|
start, end, label = parse_since(since)
|
|
329
|
-
|
|
352
|
+
if until_date is not None:
|
|
353
|
+
try:
|
|
354
|
+
end = datetime.fromisoformat(until_date.strip()).replace(
|
|
355
|
+
tzinfo=UTC, hour=23, minute=59, second=59
|
|
356
|
+
)
|
|
357
|
+
except ValueError:
|
|
358
|
+
raise click.UsageError(
|
|
359
|
+
f"Invalid --until date '{until_date}'. Expected YYYY-MM-DD."
|
|
360
|
+
) from None
|
|
361
|
+
label = f"{label} until {until_date.strip()}"
|
|
362
|
+
pairs = aggregate.run(project_dir, start, end)
|
|
363
|
+
diagnoses = [d for d, _ in pairs]
|
|
330
364
|
ev = evidence_mod.accumulate(diagnoses)
|
|
331
365
|
if top_n is not None:
|
|
332
366
|
ev = dict(sorted(ev.items(), key=lambda x: x[1].session_count, reverse=True)[:top_n])
|
|
333
|
-
|
|
367
|
+
patterns = project_specific.detect(pairs)
|
|
368
|
+
pattern_patches = claude_md.generate_from_patterns(patterns)
|
|
369
|
+
patches = claude_md.generate_from_evidence(ev) + pattern_patches
|
|
334
370
|
report = AggregateReport(
|
|
335
371
|
period_label=label,
|
|
336
372
|
sessions_analysed=len(diagnoses),
|
|
@@ -339,6 +375,7 @@ def autopsy(
|
|
|
339
375
|
waste_cost_usd=sum(d.waste_cost_usd for d in diagnoses),
|
|
340
376
|
by_kind=ev,
|
|
341
377
|
patches=patches,
|
|
378
|
+
project_patterns=patterns,
|
|
342
379
|
)
|
|
343
380
|
render_aggregate(report)
|
|
344
381
|
_aggregate_drilldown(report, diagnoses)
|
|
@@ -352,7 +389,12 @@ def autopsy(
|
|
|
352
389
|
trace = tokenize_session(parse_session(target))
|
|
353
390
|
diagnosis = diagnostician.run(trace)
|
|
354
391
|
diagnosis = claude_md.generate(diagnosis)
|
|
355
|
-
if
|
|
392
|
+
if json_out:
|
|
393
|
+
import json as _json
|
|
394
|
+
|
|
395
|
+
from cctx.exporters.jsonl import export_diagnosis as _export_diag
|
|
396
|
+
click.echo(_json.dumps(_json.loads(_export_diag(diagnosis, trace)), indent=2))
|
|
397
|
+
elif turn_num is not None:
|
|
356
398
|
render_turn(trace, diagnosis, turn_num)
|
|
357
399
|
elif html_out is not None:
|
|
358
400
|
from cctx.renderers.report import render_html
|
|
@@ -372,10 +414,10 @@ def autopsy(
|
|
|
372
414
|
@click.option(
|
|
373
415
|
"--format",
|
|
374
416
|
"fmt",
|
|
375
|
-
type=click.Choice(["jsonl", "csv"]),
|
|
417
|
+
type=click.Choice(["jsonl", "csv", "json"]),
|
|
376
418
|
default="jsonl",
|
|
377
419
|
show_default=True,
|
|
378
|
-
help="Output format: jsonl (one object per session)
|
|
420
|
+
help="Output format: jsonl (one object per session), csv (one row per turn), or json (array).",
|
|
379
421
|
)
|
|
380
422
|
@click.option(
|
|
381
423
|
"--out",
|
|
@@ -397,6 +439,7 @@ def export(target: Path, fmt: str, out: Path | None, no_content: bool) -> None:
|
|
|
397
439
|
import sys
|
|
398
440
|
|
|
399
441
|
from cctx.exporters import csv as csv_mod
|
|
442
|
+
from cctx.exporters import json as json_mod
|
|
400
443
|
from cctx.exporters import jsonl as jsonl_mod
|
|
401
444
|
|
|
402
445
|
trace = tokenize_session(parse_session(target))
|
|
@@ -404,16 +447,19 @@ def export(target: Path, fmt: str, out: Path | None, no_content: bool) -> None:
|
|
|
404
447
|
diagnosis = claude_md.generate(diagnosis)
|
|
405
448
|
pairs = [(diagnosis, trace)]
|
|
406
449
|
|
|
450
|
+
def _write(fh: IO[str]) -> None:
|
|
451
|
+
if fmt == "jsonl":
|
|
452
|
+
jsonl_mod.write(pairs, fh, include_content=not no_content)
|
|
453
|
+
elif fmt == "json":
|
|
454
|
+
json_mod.write(pairs, fh, include_content=not no_content)
|
|
455
|
+
else:
|
|
456
|
+
csv_mod.write(pairs, fh)
|
|
457
|
+
|
|
407
458
|
if out is not None:
|
|
408
459
|
with open(out, "w", encoding="utf-8") as fh:
|
|
409
|
-
|
|
410
|
-
jsonl_mod.write(pairs, fh, include_content=not no_content)
|
|
411
|
-
else:
|
|
412
|
-
csv_mod.write(pairs, fh)
|
|
413
|
-
elif fmt == "jsonl":
|
|
414
|
-
jsonl_mod.write(pairs, sys.stdout, include_content=not no_content)
|
|
460
|
+
_write(fh)
|
|
415
461
|
else:
|
|
416
|
-
|
|
462
|
+
_write(sys.stdout)
|
|
417
463
|
|
|
418
464
|
|
|
419
465
|
@cli.command()
|
|
@@ -530,8 +576,11 @@ def harvest(
|
|
|
530
576
|
if since is not None:
|
|
531
577
|
project_dir = target if target.is_dir() else target.parent
|
|
532
578
|
start, end, _label = parse_since(since)
|
|
533
|
-
|
|
579
|
+
pairs = aggregate.run(project_dir, start, end)
|
|
580
|
+
diagnoses = [d for d, _ in pairs]
|
|
534
581
|
ev = evidence_mod.accumulate(diagnoses)
|
|
582
|
+
# project_specific.detect() intentionally omitted: pattern patches need human review
|
|
583
|
+
# (autopsy shows them; harvest doesn't auto-apply).
|
|
535
584
|
patches = claude_md.generate_from_evidence(ev)
|
|
536
585
|
else:
|
|
537
586
|
if target.is_dir():
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Cross-session aggregator.
|
|
2
2
|
|
|
3
|
-
run(project_dir, start, end) -> list[Diagnosis]
|
|
3
|
+
run(project_dir, start, end) -> list[tuple[Diagnosis, SessionTrace]]
|
|
4
4
|
|
|
5
5
|
Discovers session JSONL files in project_dir modified within [start, end],
|
|
6
|
-
parses each one, runs the per-session diagnostician, and returns
|
|
7
|
-
|
|
6
|
+
parses each one, runs the per-session diagnostician, and returns
|
|
7
|
+
(Diagnosis, SessionTrace) pairs. The CLI orchestrates recommender and
|
|
8
|
+
project-specific detection separately.
|
|
8
9
|
"""
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
@@ -17,12 +18,14 @@ from cctx.parsers.claude_code import parse_session
|
|
|
17
18
|
from cctx.tokenizer import tokenize_session
|
|
18
19
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
20
|
-
from cctx.models import Diagnosis
|
|
21
|
+
from cctx.models import Diagnosis, SessionTrace
|
|
21
22
|
|
|
22
23
|
UTC = timezone.utc
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def run(
|
|
26
|
+
def run(
|
|
27
|
+
project_dir: Path, start: datetime, end: datetime
|
|
28
|
+
) -> list[tuple[Diagnosis, SessionTrace]]:
|
|
26
29
|
paths = sorted(project_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
27
30
|
|
|
28
31
|
result = []
|
|
@@ -33,7 +36,7 @@ def run(project_dir: Path, start: datetime, end: datetime) -> list[Diagnosis]:
|
|
|
33
36
|
try:
|
|
34
37
|
trace = tokenize_session(parse_session(path))
|
|
35
38
|
diagnosis = diagnostician.run(trace)
|
|
36
|
-
result.append(diagnosis)
|
|
39
|
+
result.append((diagnosis, trace))
|
|
37
40
|
except Exception:
|
|
38
41
|
continue # skip corrupt sessions; don't fail the whole run
|
|
39
42
|
return result
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Project-specific pattern detector.
|
|
2
|
+
|
|
3
|
+
detect(pairs) -> list[ProjectPattern]
|
|
4
|
+
|
|
5
|
+
Finds (tool_name, failure_key, fix_key) triples that recur in 3+ sessions.
|
|
6
|
+
Bash normalization uses first 3 tokens for cross-session fuzzy matching
|
|
7
|
+
(intentionally looser than retry_loop). No LLM calls.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from cctx.models import ProjectPattern
|
|
16
|
+
from cctx.pricing import price_per_tok
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from cctx.models import Diagnosis, SessionTrace, ToolResult
|
|
20
|
+
|
|
21
|
+
MIN_SESSIONS = 3
|
|
22
|
+
FIX_WINDOW = 10 # turns after last failure to search for the fix
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize_key(tool_name: str, tool_input: dict) -> str:
|
|
26
|
+
match tool_name:
|
|
27
|
+
case "Bash":
|
|
28
|
+
tokens = tool_input.get("command", "").strip().split()
|
|
29
|
+
return " ".join(tokens[:3])
|
|
30
|
+
case "Edit" | "Read" | "Write":
|
|
31
|
+
return tool_input.get("file_path", "")
|
|
32
|
+
case "Grep" | "Glob":
|
|
33
|
+
return tool_input.get("pattern", "")
|
|
34
|
+
case _:
|
|
35
|
+
return json.dumps(tool_input, sort_keys=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_error(result: ToolResult) -> bool:
|
|
39
|
+
if result.is_error:
|
|
40
|
+
return True
|
|
41
|
+
c = result.content
|
|
42
|
+
return c.startswith("Error:") or c.startswith("error:") or c.startswith("FAILED")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_pairs(trace: SessionTrace) -> list[dict]:
|
|
46
|
+
"""Find failure/fix pairs within one session."""
|
|
47
|
+
result_map: dict[str, ToolResult] = {}
|
|
48
|
+
for turn in trace.turns:
|
|
49
|
+
for tr in turn.tool_results:
|
|
50
|
+
result_map[tr.tool_use_id] = tr
|
|
51
|
+
|
|
52
|
+
records = []
|
|
53
|
+
for turn in trace.turns:
|
|
54
|
+
if turn.role != "assistant":
|
|
55
|
+
continue
|
|
56
|
+
for tu in turn.tool_uses:
|
|
57
|
+
result = result_map.get(tu.tool_use_id)
|
|
58
|
+
if result is None:
|
|
59
|
+
continue
|
|
60
|
+
key = _normalize_key(tu.tool_name, tu.tool_input)
|
|
61
|
+
records.append({
|
|
62
|
+
"tool_name": tu.tool_name,
|
|
63
|
+
"key": key,
|
|
64
|
+
"turn": turn.turn_number,
|
|
65
|
+
"is_error": _is_error(result),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
groups: dict[tuple, list] = defaultdict(list)
|
|
69
|
+
for r in records:
|
|
70
|
+
groups[(r["tool_name"], r["key"])].append(r)
|
|
71
|
+
|
|
72
|
+
found: list[dict] = []
|
|
73
|
+
seen_pairs: set[tuple] = set()
|
|
74
|
+
|
|
75
|
+
for (tool_name, failure_key), group in groups.items():
|
|
76
|
+
errors = [r for r in group if r["is_error"]]
|
|
77
|
+
if len(errors) < 2:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
first_err_turn = errors[0]["turn"]
|
|
81
|
+
last_err_turn = errors[-1]["turn"]
|
|
82
|
+
|
|
83
|
+
intervening = any(
|
|
84
|
+
r for r in group
|
|
85
|
+
if not r["is_error"] and first_err_turn < r["turn"] < last_err_turn
|
|
86
|
+
)
|
|
87
|
+
if intervening:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
fix = next(
|
|
91
|
+
(
|
|
92
|
+
r for r in records
|
|
93
|
+
if r["tool_name"] == tool_name
|
|
94
|
+
and not r["is_error"]
|
|
95
|
+
and r["key"] != failure_key
|
|
96
|
+
and last_err_turn < r["turn"] <= last_err_turn + FIX_WINDOW
|
|
97
|
+
),
|
|
98
|
+
None,
|
|
99
|
+
)
|
|
100
|
+
if fix is None:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
pair_key = (tool_name, failure_key, fix["key"])
|
|
104
|
+
if pair_key in seen_pairs:
|
|
105
|
+
continue
|
|
106
|
+
seen_pairs.add(pair_key)
|
|
107
|
+
|
|
108
|
+
found.append({
|
|
109
|
+
"tool_name": tool_name,
|
|
110
|
+
"failure_key": failure_key,
|
|
111
|
+
"fix_key": fix["key"],
|
|
112
|
+
"first_failure_turn": first_err_turn,
|
|
113
|
+
"fix_turn": fix["turn"],
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return found
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _compute_waste(trace: SessionTrace, first_failure_turn: int, fix_turn: int) -> float:
|
|
120
|
+
price = price_per_tok(trace.primary_model)
|
|
121
|
+
total = 0.0
|
|
122
|
+
for turn in trace.turns:
|
|
123
|
+
if (
|
|
124
|
+
turn.role == "assistant"
|
|
125
|
+
and first_failure_turn <= turn.turn_number <= fix_turn
|
|
126
|
+
and turn.usage is not None
|
|
127
|
+
):
|
|
128
|
+
total += turn.usage.input_tokens * price
|
|
129
|
+
return round(total, 4)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def detect(pairs: list[tuple[Diagnosis, SessionTrace]]) -> list[ProjectPattern]:
|
|
133
|
+
"""Detect recurring failure/fix patterns across sessions."""
|
|
134
|
+
session_records: list[dict] = []
|
|
135
|
+
for _diagnosis, trace in pairs:
|
|
136
|
+
for p in _find_pairs(trace):
|
|
137
|
+
session_records.append({
|
|
138
|
+
"session_id": trace.session_id,
|
|
139
|
+
"tool_name": p["tool_name"],
|
|
140
|
+
"failure_key": p["failure_key"],
|
|
141
|
+
"fix_key": p["fix_key"],
|
|
142
|
+
"first_failure_turn": p["first_failure_turn"],
|
|
143
|
+
"fix_turn": p["fix_turn"],
|
|
144
|
+
"trace": trace,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
groups: dict[tuple, list] = defaultdict(list)
|
|
148
|
+
for r in session_records:
|
|
149
|
+
groups[(r["tool_name"], r["failure_key"], r["fix_key"])].append(r)
|
|
150
|
+
|
|
151
|
+
result: list[ProjectPattern] = []
|
|
152
|
+
for (tool_name, failure_key, fix_key), records in groups.items():
|
|
153
|
+
seen: dict[str, dict] = {}
|
|
154
|
+
for r in records:
|
|
155
|
+
if r["session_id"] not in seen:
|
|
156
|
+
seen[r["session_id"]] = r
|
|
157
|
+
|
|
158
|
+
if len(seen) < MIN_SESSIONS:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
unique = list(seen.values())
|
|
162
|
+
wasted = [r["fix_turn"] - r["first_failure_turn"] for r in unique]
|
|
163
|
+
avg_wasted_turns = sum(wasted) / len(wasted)
|
|
164
|
+
total_waste_usd = sum(
|
|
165
|
+
_compute_waste(r["trace"], r["first_failure_turn"], r["fix_turn"])
|
|
166
|
+
for r in unique
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
result.append(ProjectPattern(
|
|
170
|
+
tool_name=tool_name,
|
|
171
|
+
failure_key=failure_key,
|
|
172
|
+
fix_key=fix_key,
|
|
173
|
+
session_count=len(seen),
|
|
174
|
+
avg_wasted_turns=round(avg_wasted_turns, 1),
|
|
175
|
+
total_waste_usd=round(total_waste_usd, 4),
|
|
176
|
+
example_sessions=sorted(r["session_id"] for r in unique)[:3],
|
|
177
|
+
))
|
|
178
|
+
|
|
179
|
+
return result
|
|
@@ -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")
|
|
@@ -9,7 +9,7 @@ cctx.renderers, cctx.exporters, cctx.tokenizer.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from dataclasses import dataclass
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
13
|
from datetime import datetime
|
|
14
14
|
from enum import Enum
|
|
15
15
|
from pathlib import Path
|
|
@@ -172,6 +172,7 @@ class FindingKind(str, Enum):
|
|
|
172
172
|
STALE_CONTEXT = "stale_context"
|
|
173
173
|
TOOL_THRASH = "tool_thrash"
|
|
174
174
|
DEAD_END = "dead_end"
|
|
175
|
+
PROJECT_PATTERN = "project_pattern"
|
|
175
176
|
|
|
176
177
|
|
|
177
178
|
KIND_LABEL: dict[FindingKind, str] = {
|
|
@@ -180,6 +181,7 @@ KIND_LABEL: dict[FindingKind, str] = {
|
|
|
180
181
|
FindingKind.STALE_CONTEXT: "STALE CONTEXT",
|
|
181
182
|
FindingKind.TOOL_THRASH: "TOOL THRASH",
|
|
182
183
|
FindingKind.DEAD_END: "DEAD END",
|
|
184
|
+
FindingKind.PROJECT_PATTERN: "PROJECT PATTERN",
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
|
|
@@ -241,6 +243,17 @@ class KindEvidence:
|
|
|
241
243
|
example_summaries: list[str]
|
|
242
244
|
|
|
243
245
|
|
|
246
|
+
@dataclass
|
|
247
|
+
class ProjectPattern:
|
|
248
|
+
tool_name: str
|
|
249
|
+
failure_key: str
|
|
250
|
+
fix_key: str
|
|
251
|
+
session_count: int
|
|
252
|
+
avg_wasted_turns: float
|
|
253
|
+
total_waste_usd: float
|
|
254
|
+
example_sessions: list[str]
|
|
255
|
+
|
|
256
|
+
|
|
244
257
|
@dataclass
|
|
245
258
|
class AggregateReport:
|
|
246
259
|
period_label: str # human-readable, e.g. "last 7 days" or "2026-05-01..2026-05-15"
|
|
@@ -250,6 +263,7 @@ class AggregateReport:
|
|
|
250
263
|
waste_cost_usd: float
|
|
251
264
|
by_kind: dict[FindingKind, KindEvidence]
|
|
252
265
|
patches: list[Patch]
|
|
266
|
+
project_patterns: list[ProjectPattern] = field(default_factory=list)
|
|
253
267
|
|
|
254
268
|
|
|
255
269
|
# ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Patch generator — turns Findings into copy-pasteable CLAUDE.md diffs.
|
|
2
2
|
|
|
3
3
|
generate(diagnosis) -> Diagnosis (single-session path)
|
|
4
|
-
generate_from_evidence(evidence) -> list[Patch] (cross-session path)
|
|
4
|
+
generate_from_evidence(evidence) -> list[Patch] (cross-session path, generic findings)
|
|
5
|
+
generate_from_patterns(patterns) -> list[Patch] (cross-session path, project patterns)
|
|
5
6
|
"""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
@@ -11,7 +12,7 @@ from typing import TYPE_CHECKING
|
|
|
11
12
|
from cctx.models import FindingKind, Patch
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
|
-
from cctx.models import Diagnosis, Finding, KindEvidence
|
|
15
|
+
from cctx.models import Diagnosis, Finding, KindEvidence, ProjectPattern
|
|
15
16
|
|
|
16
17
|
# ---------------------------------------------------------------------------
|
|
17
18
|
# Patch templates (append-style unified diffs, v0)
|
|
@@ -129,3 +130,25 @@ def generate_from_evidence(
|
|
|
129
130
|
evidence_summary=example,
|
|
130
131
|
))
|
|
131
132
|
return patches
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def generate_from_patterns(patterns: list[ProjectPattern]) -> list[Patch]:
|
|
136
|
+
"""Generate CLAUDE.md patches from cross-session ProjectPatterns."""
|
|
137
|
+
patches = []
|
|
138
|
+
for p in patterns:
|
|
139
|
+
diff = (
|
|
140
|
+
f"+## Project-specific: {p.tool_name}({p.failure_key})\n"
|
|
141
|
+
f"+When `{p.failure_key}` fails, use `{p.fix_key}` instead.\n"
|
|
142
|
+
f"+Re-discovered in {p.session_count} sessions "
|
|
143
|
+
f"(~${p.total_waste_usd:.2f} wasted)."
|
|
144
|
+
)
|
|
145
|
+
patches.append(Patch(
|
|
146
|
+
target_file="CLAUDE.md",
|
|
147
|
+
description=f"Project-specific: {p.failure_key} → {p.fix_key}",
|
|
148
|
+
unified_diff=diff,
|
|
149
|
+
finding_kind=FindingKind.PROJECT_PATTERN,
|
|
150
|
+
evidence_summary=(
|
|
151
|
+
f"Seen in {p.session_count} sessions, ~${p.total_waste_usd:.2f} wasted"
|
|
152
|
+
),
|
|
153
|
+
))
|
|
154
|
+
return patches
|
|
@@ -112,22 +112,23 @@ def render_aggregate(report: AggregateReport, *, console: Console | None = None)
|
|
|
112
112
|
f"Waste: ${report.waste_cost_usd:.2f}"
|
|
113
113
|
)
|
|
114
114
|
|
|
115
|
-
if not report.by_kind:
|
|
115
|
+
if not report.by_kind and not report.project_patterns:
|
|
116
116
|
con.print("\nNo findings across sessions.")
|
|
117
117
|
return
|
|
118
118
|
|
|
119
119
|
# Summary table
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
if report.by_kind:
|
|
121
|
+
table = Table(title="Finding frequency")
|
|
122
|
+
table.add_column("Pattern")
|
|
123
|
+
table.add_column("Sessions", justify="right")
|
|
124
|
+
table.add_column("Waste ($)", justify="right")
|
|
125
|
+
for kind, ev in report.by_kind.items():
|
|
126
|
+
table.add_row(
|
|
127
|
+
_KIND_LABEL.get(kind, kind.value),
|
|
128
|
+
str(ev.session_count),
|
|
129
|
+
f"${ev.total_waste_usd:.2f}",
|
|
130
|
+
)
|
|
131
|
+
con.print(table)
|
|
131
132
|
|
|
132
133
|
# Patches
|
|
133
134
|
if report.patches:
|
|
@@ -137,6 +138,25 @@ def render_aggregate(report: AggregateReport, *, console: Console | None = None)
|
|
|
137
138
|
syntax = Syntax(patch.unified_diff, "diff", theme="monokai", word_wrap=True)
|
|
138
139
|
con.print(syntax)
|
|
139
140
|
|
|
141
|
+
# Project-specific patterns
|
|
142
|
+
if report.project_patterns:
|
|
143
|
+
con.print()
|
|
144
|
+
pp_table = Table(title="Project-specific patterns")
|
|
145
|
+
pp_table.add_column("Failure", style="bold")
|
|
146
|
+
pp_table.add_column("Fix")
|
|
147
|
+
pp_table.add_column("Sessions", justify="right", style="dim")
|
|
148
|
+
pp_table.add_column("Avg turns", justify="right", style="dim")
|
|
149
|
+
pp_table.add_column("Waste", justify="right")
|
|
150
|
+
for pp in report.project_patterns:
|
|
151
|
+
pp_table.add_row(
|
|
152
|
+
pp.failure_key,
|
|
153
|
+
pp.fix_key,
|
|
154
|
+
str(pp.session_count),
|
|
155
|
+
f"{pp.avg_wasted_turns:.1f}",
|
|
156
|
+
f"~${pp.total_waste_usd:.2f}",
|
|
157
|
+
)
|
|
158
|
+
con.print(pp_table)
|
|
159
|
+
|
|
140
160
|
|
|
141
161
|
def render_aggregate_drilldown(
|
|
142
162
|
diagnoses: list,
|