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.
Files changed (165) hide show
  1. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/CHANGELOG.md +68 -0
  2. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/PKG-INFO +1 -1
  3. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/__init__.py +1 -1
  4. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/cli.py +62 -13
  5. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/aggregate.py +9 -6
  6. cctx_cli-1.3.0/cctx/diagnostician/patterns/project_specific.py +179 -0
  7. cctx_cli-1.3.0/cctx/exporters/json.py +23 -0
  8. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/models.py +15 -1
  9. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/recommender/claude_md.py +25 -2
  10. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/terminal.py +32 -12
  11. cctx_cli-1.3.0/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +1312 -0
  12. cctx_cli-1.3.0/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +235 -0
  13. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/pyproject.toml +1 -1
  14. cctx_cli-1.3.0/tests/diagnostician/test_project_specific.py +218 -0
  15. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_aggregate.py +6 -5
  16. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_cli.py +288 -0
  17. cctx_cli-1.3.0/tests/test_models_project_pattern.py +37 -0
  18. cctx_cli-1.3.0/tests/test_recommender.py +56 -0
  19. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_terminal_renderer.py +74 -0
  20. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/.github/workflows/ci.yml +0 -0
  21. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/.github/workflows/publish.yml +0 -0
  22. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/.github/workflows/release.yml +0 -0
  23. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/.gitignore +0 -0
  24. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/CLAUDE.md +0 -0
  25. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/DESIGN.md +0 -0
  26. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/PRODUCT.md +0 -0
  27. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/README.md +0 -0
  28. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/action.yml +0 -0
  29. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/__init__.py +0 -0
  30. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/inflection.py +0 -0
  31. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/__init__.py +0 -0
  32. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
  33. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
  34. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
  35. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
  36. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
  37. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/discovery.py +0 -0
  38. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/exporters/__init__.py +0 -0
  39. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/exporters/csv.py +0 -0
  40. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/exporters/jsonl.py +0 -0
  41. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/harvest.py +0 -0
  42. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/parsers/__init__.py +0 -0
  43. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/parsers/claude_code.py +0 -0
  44. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/pricing.py +0 -0
  45. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/recommender/__init__.py +0 -0
  46. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/recommender/evidence.py +0 -0
  47. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/__init__.py +0 -0
  48. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/github.py +0 -0
  49. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/report.py +0 -0
  50. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
  51. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/renderers/trace_tui.py +0 -0
  52. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/tokenizer.py +0 -0
  53. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx/watcher.py +0 -0
  54. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/cctx-project-brief.md +0 -0
  55. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/demo.gif +0 -0
  56. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/demo.tape +0 -0
  57. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
  58. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
  59. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
  60. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
  61. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
  62. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
  63. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
  64. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
  65. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
  66. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
  67. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
  68. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/__init__.py +0 -0
  69. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/conftest.py +0 -0
  70. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/__init__.py +0 -0
  71. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/conftest.py +0 -0
  72. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_dead_end.py +0 -0
  73. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_inflection.py +0 -0
  74. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_orchestrator.py +0 -0
  75. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_retry_loop.py +0 -0
  76. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_scope_creep.py +0 -0
  77. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_stale_context.py +0 -0
  78. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/diagnostician/test_tool_thrash.py +0 -0
  79. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/exporters/__init__.py +0 -0
  80. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/exporters/test_csv.py +0 -0
  81. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/exporters/test_jsonl.py +0 -0
  82. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/README.md +0 -0
  83. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
  84. {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
  85. {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
  86. {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
  87. {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
  88. {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
  89. {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
  90. {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
  91. {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
  92. {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
  93. {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
  94. {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
  95. {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
  96. {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
  97. {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
  98. {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
  99. {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
  100. {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
  101. {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
  102. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
  103. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
  104. {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
  105. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
  106. {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
  107. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
  108. {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
  109. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
  110. {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
  111. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
  112. {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
  113. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
  114. {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
  115. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
  116. {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
  117. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
  118. {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
  119. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
  120. {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
  121. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
  122. {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
  123. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
  124. {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
  125. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
  126. {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
  127. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
  128. {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
  129. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
  130. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
  131. {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
  132. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
  133. {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
  134. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
  135. {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
  136. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
  137. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
  138. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
  139. {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
  140. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
  141. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/scrub.py +0 -0
  142. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
  143. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
  144. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
  145. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
  146. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
  147. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/parsers/__init__.py +0 -0
  148. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/parsers/test_claude_code.py +0 -0
  149. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/parsers/test_claude_code_integration.py +0 -0
  150. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/recommender/__init__.py +0 -0
  151. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/recommender/test_claude_md.py +0 -0
  152. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/recommender/test_evidence.py +0 -0
  153. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/renderers/__init__.py +0 -0
  154. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/renderers/test_report.py +0 -0
  155. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
  156. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_cli_export.py +0 -0
  157. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_discovery.py +0 -0
  158. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_github_summary.py +0 -0
  159. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_harvest.py +0 -0
  160. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_harvest_check.py +0 -0
  161. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_models.py +0 -0
  162. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_smoke.py +0 -0
  163. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_tokenizer.py +0 -0
  164. {cctx_cli-1.1.0 → cctx_cli-1.3.0}/tests/test_trace_tui.py +0 -0
  165. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctx-cli
3
- Version: 1.1.0
3
+ Version: 1.3.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.1.0"
3
+ __version__ = "1.3.0"
@@ -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
- diagnoses = aggregate.run(project_dir, start, end)
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
- patches = claude_md.generate_from_evidence(ev)
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 turn_num is not None:
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) or csv (one row per turn).",
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
- if fmt == "jsonl":
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
- csv_mod.write(pairs, sys.stdout)
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
- diagnoses = aggregate.run(project_dir, start, end)
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 the list of
7
- Diagnoses. The CLI orchestrates the recommender call separately.
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(project_dir: Path, start: datetime, end: datetime) -> list[Diagnosis]:
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
- table = Table(title="Finding frequency")
121
- table.add_column("Pattern")
122
- table.add_column("Sessions", justify="right")
123
- table.add_column("Waste ($)", justify="right")
124
- for kind, ev in report.by_kind.items():
125
- table.add_row(
126
- _KIND_LABEL.get(kind, kind.value),
127
- str(ev.session_count),
128
- f"${ev.total_waste_usd:.2f}",
129
- )
130
- con.print(table)
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,