cctx-cli 1.5.1__tar.gz → 1.6.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 (174) hide show
  1. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/CHANGELOG.md +59 -0
  2. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/PKG-INFO +1 -1
  3. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/PRODUCT.md +1 -0
  4. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/__init__.py +1 -1
  5. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/cli.py +36 -1
  6. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/harvest.py +74 -4
  7. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/models.py +16 -0
  8. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-06-09-cross-agent-emit-design.md +22 -6
  9. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/pyproject.toml +1 -1
  10. cctx_cli-1.6.0/tests/test_harvest_emit.py +204 -0
  11. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/.github/workflows/ci.yml +0 -0
  12. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/.github/workflows/publish.yml +0 -0
  13. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/.github/workflows/release.yml +0 -0
  14. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/.gitignore +0 -0
  15. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/CLAUDE.md +0 -0
  16. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/DESIGN.md +0 -0
  17. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/README.md +0 -0
  18. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/action.yml +0 -0
  19. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/agents.py +0 -0
  20. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/__init__.py +0 -0
  21. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/aggregate.py +0 -0
  22. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/inflection.py +0 -0
  23. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/patterns/__init__.py +0 -0
  24. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
  25. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/patterns/project_specific.py +0 -0
  26. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
  27. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
  28. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
  29. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
  30. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/discovery.py +0 -0
  31. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/exporters/__init__.py +0 -0
  32. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/exporters/csv.py +0 -0
  33. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/exporters/json.py +0 -0
  34. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/exporters/jsonl.py +0 -0
  35. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/parsers/__init__.py +0 -0
  36. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/parsers/claude_code.py +0 -0
  37. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/pricing.py +0 -0
  38. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/recommender/__init__.py +0 -0
  39. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/recommender/claude_md.py +0 -0
  40. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/recommender/evidence.py +0 -0
  41. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/renderers/__init__.py +0 -0
  42. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/renderers/github.py +0 -0
  43. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/renderers/report.py +0 -0
  44. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/renderers/templates/autopsy.html.j2 +0 -0
  45. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/renderers/terminal.py +0 -0
  46. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/renderers/trace_tui.py +0 -0
  47. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/tokenizer.py +0 -0
  48. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx/watcher.py +0 -0
  49. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/cctx-project-brief.md +0 -0
  50. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/demo.gif +0 -0
  51. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/demo.tape +0 -0
  52. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
  53. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
  54. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
  55. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/product-reviews/2026-06-09-product-review.md +0 -0
  56. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
  57. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
  58. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
  59. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/plans/2026-05-17-harvest-check-depth.md +0 -0
  60. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +0 -0
  61. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/plans/2026-05-19-claude-agents-live-integration.md +0 -0
  62. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
  63. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
  64. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
  65. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
  66. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
  67. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-05-17-harvest-check-depth-design.md +0 -0
  68. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +0 -0
  69. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/docs/superpowers/specs/2026-05-19-claude-agents-live-integration-design.md +0 -0
  70. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/__init__.py +0 -0
  71. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/conftest.py +0 -0
  72. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/__init__.py +0 -0
  73. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/conftest.py +0 -0
  74. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/test_dead_end.py +0 -0
  75. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/test_inflection.py +0 -0
  76. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/test_orchestrator.py +0 -0
  77. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/test_project_specific.py +0 -0
  78. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/test_retry_loop.py +0 -0
  79. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/test_scope_creep.py +0 -0
  80. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/test_stale_context.py +0 -0
  81. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/diagnostician/test_tool_thrash.py +0 -0
  82. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/exporters/__init__.py +0 -0
  83. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/exporters/test_csv.py +0 -0
  84. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/exporters/test_jsonl.py +0 -0
  85. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/README.md +0 -0
  86. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
  87. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a0b4c2cf1dde0ca56.meta.json +0 -0
  88. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a116ae34b1b09c332.meta.json +0 -0
  89. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1c4c417b35658c9e.meta.json +0 -0
  90. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1e41a901de38f1b5.meta.json +0 -0
  91. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a338f8d0c74612a24.meta.json +0 -0
  92. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a34f6f3c0e7094186.meta.json +0 -0
  93. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a5a5a0cff4d13308b.meta.json +0 -0
  94. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a6b0a3da6a0484db5.meta.json +0 -0
  95. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f73f1790b02cde5.meta.json +0 -0
  96. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f7c17c38a9d8788.meta.json +0 -0
  97. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a853259e2cd7bbe8a.meta.json +0 -0
  98. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a8d9aedb0d0c6e12d.meta.json +0 -0
  99. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aa778bc1d59e4a441.meta.json +0 -0
  100. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aba869dedee4a12ba.meta.json +0 -0
  101. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-ada2746d9774b94db.meta.json +0 -0
  102. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea0132068c64d2dd.meta.json +0 -0
  103. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea215eff50874d5f.meta.json +0 -0
  104. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-afee21f2b3852a4a0.meta.json +0 -0
  105. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
  106. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
  107. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.meta.json +0 -0
  108. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
  109. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.meta.json +0 -0
  110. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
  111. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.meta.json +0 -0
  112. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
  113. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.meta.json +0 -0
  114. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
  115. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.meta.json +0 -0
  116. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
  117. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.meta.json +0 -0
  118. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
  119. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.meta.json +0 -0
  120. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
  121. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.meta.json +0 -0
  122. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
  123. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.meta.json +0 -0
  124. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
  125. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.meta.json +0 -0
  126. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
  127. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.meta.json +0 -0
  128. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
  129. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.meta.json +0 -0
  130. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
  131. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.meta.json +0 -0
  132. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
  133. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
  134. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.meta.json +0 -0
  135. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
  136. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.meta.json +0 -0
  137. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
  138. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.meta.json +0 -0
  139. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
  140. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
  141. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
  142. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results/tool-results/bosbkda0h.txt +0 -0
  143. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
  144. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/scrub.py +0 -0
  145. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
  146. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
  147. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
  148. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
  149. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
  150. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/parsers/__init__.py +0 -0
  151. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/parsers/test_claude_code.py +0 -0
  152. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/parsers/test_claude_code_integration.py +0 -0
  153. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/recommender/__init__.py +0 -0
  154. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/recommender/test_claude_md.py +0 -0
  155. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/recommender/test_evidence.py +0 -0
  156. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/renderers/__init__.py +0 -0
  157. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/renderers/test_report.py +0 -0
  158. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
  159. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_agents.py +0 -0
  160. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_aggregate.py +0 -0
  161. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_cli.py +0 -0
  162. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_cli_export.py +0 -0
  163. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_discovery.py +0 -0
  164. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_github_summary.py +0 -0
  165. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_harvest.py +0 -0
  166. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_harvest_check.py +0 -0
  167. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_models.py +0 -0
  168. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_models_project_pattern.py +0 -0
  169. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_recommender.py +0 -0
  170. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_smoke.py +0 -0
  171. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_terminal_renderer.py +0 -0
  172. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_tokenizer.py +0 -0
  173. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_trace_tui.py +0 -0
  174. {cctx_cli-1.5.1 → cctx_cli-1.6.0}/tests/test_watcher.py +0 -0
@@ -2,6 +2,65 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v1.6.0 (2026-06-10)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Harvest — preview_patches dedup per (target, heading) not heading-only
10
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
11
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
12
+
13
+ - Harvest — shorten local-import comment under 100-char line limit
14
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
15
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
16
+
17
+ ### Documentation
18
+
19
+ - Harvest — correct misleading local-import comment
20
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
21
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
22
+
23
+ - Spec deviation note (sync returns patches) + PRODUCT.md cross-agent emit row
24
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
25
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
26
+
27
+ ### Features
28
+
29
+ - Cctx harvest --emit — cross-agent layer to AGENTS.md (#82)
30
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
31
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
32
+
33
+ - Cli — harvest --emit / --sync cross-agent emit
34
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
35
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
36
+
37
+ - Harvest — EMIT_TARGETS + retarget_patches (fan-out to AGENTS.md)
38
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
39
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
40
+
41
+ - Harvest — sync_managed_sections backfills CLAUDE.md into emit target
42
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
43
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
44
+
45
+ - Models — MANAGED_HEADINGS registry for cctx-owned CLAUDE.md sections
46
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
47
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
48
+
49
+ ### Testing
50
+
51
+ - Emit + sync idempotency through apply_patches
52
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
53
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
54
+
55
+ - End-to-end fan-out to both targets; spec: reconcile sync error contract
56
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
57
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
58
+
59
+ - Lock MANAGED_HEADINGS registry to recommender templates
60
+ ([#108](https://github.com/jacquardlabs/cctx/pull/108),
61
+ [`afa964c`](https://github.com/jacquardlabs/cctx/commit/afa964c68b445030e1fafe9f41c67a0de4afcd2d))
62
+
63
+
5
64
  ## v1.5.1 (2026-06-10)
6
65
 
7
66
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctx-cli
3
- Version: 1.5.1
3
+ Version: 1.6.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
@@ -76,6 +76,7 @@ Six commands (`ls`, `autopsy`, `harvest`, `watch`, `trace`, `export`). No comman
76
76
  | Memory-hygiene depth | `harvest --check` + `--check-severity` | v1.4.0 (M13) |
77
77
  | Live session badges | `cctx ls` | unreleased |
78
78
  | Live session detection, early idle exit | `cctx watch` | unreleased |
79
+ | Cross-agent emit | `cctx harvest --emit agents [--sync]` | M15; mirror CLAUDE.md sections to AGENTS.md — unreleased |
79
80
 
80
81
  ### Pattern classifiers (v1.4.0)
81
82
 
@@ -1,3 +1,3 @@
1
1
  """cctx: profile, debug, and optimize Claude Code and Agent SDK sessions."""
2
2
 
3
- __version__ = "1.5.1"
3
+ __version__ = "1.6.0"
@@ -23,6 +23,7 @@ from cctx.agents import live_sessions as _live_sessions
23
23
  from cctx.diagnostician import aggregate
24
24
  from cctx.diagnostician.patterns import project_specific
25
25
  from cctx.discovery import complete_project as _complete_project
26
+ from cctx.harvest import EMIT_TARGETS
26
27
  from cctx.models import KIND_LABEL, AggregateReport
27
28
  from cctx.parsers.claude_code import parse_session
28
29
  from cctx.recommender import claude_md
@@ -570,6 +571,22 @@ def trace(target: Path | None, latest: bool) -> None:
570
571
  show_default=True,
571
572
  help="Minimum severity that causes --check to exit 1.",
572
573
  )
574
+ @click.option(
575
+ "--emit",
576
+ "emit_targets",
577
+ multiple=True,
578
+ type=click.Choice(list(EMIT_TARGETS)),
579
+ help="Also write applicable patches to another agent's instruction file "
580
+ "(e.g. AGENTS.md). Repeatable.",
581
+ )
582
+ @click.option(
583
+ "--sync",
584
+ "sync_mode",
585
+ is_flag=True,
586
+ default=False,
587
+ help="With --emit: also mirror already-harvested cctx-managed sections "
588
+ "from CLAUDE.md into the emit target.",
589
+ )
573
590
  def harvest(
574
591
  target: Path,
575
592
  since: str | None,
@@ -578,9 +595,20 @@ def harvest(
578
595
  target_dir: Path | None,
579
596
  check_mode: bool,
580
597
  check_severity: str,
598
+ emit_targets: tuple[str, ...],
599
+ sync_mode: bool,
581
600
  ) -> None:
582
601
  """Apply autopsy patches to CLAUDE.md."""
583
- from cctx.harvest import apply_patches, check_claude_md, preview_patches
602
+ from cctx.harvest import (
603
+ apply_patches,
604
+ check_claude_md,
605
+ preview_patches,
606
+ retarget_patches,
607
+ sync_managed_sections,
608
+ )
609
+
610
+ if sync_mode and not emit_targets:
611
+ raise click.UsageError("--sync requires --emit.")
584
612
 
585
613
  if check_mode:
586
614
  from cctx.harvest import CheckSeverity
@@ -626,6 +654,13 @@ def harvest(
626
654
  diagnosis = claude_md.generate(diagnosis)
627
655
  patches = diagnosis.patches
628
656
 
657
+ base = patches
658
+ for t in emit_targets:
659
+ emitted = retarget_patches(base, t)
660
+ if sync_mode:
661
+ emitted = emitted + sync_managed_sections(resolved_dir, t)
662
+ patches = patches + emitted
663
+
629
664
  if not patches:
630
665
  render_harvest_results([], dry_run=dry_run)
631
666
  return
@@ -13,6 +13,7 @@ Layering rules (MUST respect):
13
13
  """
14
14
  from __future__ import annotations
15
15
 
16
+ import dataclasses
16
17
  import re
17
18
  from collections import defaultdict
18
19
  from dataclasses import dataclass
@@ -20,6 +21,8 @@ from enum import Enum
20
21
  from pathlib import Path
21
22
  from typing import TYPE_CHECKING
22
23
 
24
+ from cctx.models import MANAGED_HEADING_PREFIX, MANAGED_HEADINGS
25
+
23
26
  if TYPE_CHECKING:
24
27
  from cctx.models import Patch
25
28
 
@@ -105,6 +108,70 @@ def _is_supported_target(patch: Patch) -> bool:
105
108
  # Public API
106
109
  # ---------------------------------------------------------------------------
107
110
 
111
+ # Maps an --emit target name to the destination filename. Single place to add
112
+ # future targets (Cursor, Windsurf, Copilot) when demand exists.
113
+ EMIT_TARGETS: dict[str, str] = {
114
+ "agents": "AGENTS.md",
115
+ }
116
+
117
+
118
+ def retarget_patches(patches: list[Patch], emit_target: str) -> list[Patch]:
119
+ """Clone CLAUDE.md-targeted patches to the emit target's file.
120
+
121
+ Only patches whose target_file is exactly "CLAUDE.md" are emitted —
122
+ .claude/rules/ and .claude/skills/ patches are Claude Code-specific and do
123
+ not translate to other agents. Returns clones; inputs are unmodified.
124
+ """
125
+ dest = EMIT_TARGETS[emit_target]
126
+ return [
127
+ dataclasses.replace(p, target_file=dest)
128
+ for p in patches
129
+ if p.target_file == "CLAUDE.md"
130
+ ]
131
+
132
+
133
+ # Reverse map: exact managed heading -> the FindingKind that owns it.
134
+ _HEADING_TO_KIND = {heading: kind for kind, heading in MANAGED_HEADINGS.items()}
135
+
136
+
137
+ def sync_managed_sections(target_dir: Path, emit_target: str) -> list[Patch]:
138
+ """Build synthetic patches mirroring CLAUDE.md's cctx-managed sections.
139
+
140
+ Reads CLAUDE.md in target_dir, keeps sections whose heading is an exact
141
+ MANAGED_HEADINGS value or starts with MANAGED_HEADING_PREFIX, and returns
142
+ one Patch per kept section targeting the emit file. Returns [] if CLAUDE.md
143
+ is absent. The CLI routes these through preview_patches / apply_patches, so
144
+ idempotency and dry-run come for free from the existing machinery.
145
+ """
146
+ from cctx.models import FindingKind, Patch # runtime use (Patch is TYPE_CHECKING-only above)
147
+
148
+ claude_md = target_dir / "CLAUDE.md"
149
+ if not claude_md.exists():
150
+ return []
151
+
152
+ dest = EMIT_TARGETS[emit_target]
153
+ content = claude_md.read_text(encoding="utf-8")
154
+ patches: list[Patch] = []
155
+
156
+ for heading, body in _parse_sections(content):
157
+ is_fixed = heading in _HEADING_TO_KIND
158
+ is_prefixed = heading.startswith(MANAGED_HEADING_PREFIX)
159
+ if not (is_fixed or is_prefixed):
160
+ continue
161
+
162
+ kind = _HEADING_TO_KIND[heading] if is_fixed else FindingKind.PROJECT_PATTERN
163
+ diff_lines = [heading] + body.splitlines()
164
+ unified_diff = "\n".join(f"+{line}" for line in diff_lines)
165
+ patches.append(Patch(
166
+ target_file=dest,
167
+ description=heading,
168
+ unified_diff=unified_diff,
169
+ finding_kind=kind,
170
+ evidence_summary="synced from CLAUDE.md",
171
+ ))
172
+
173
+ return patches
174
+
108
175
 
109
176
  def apply_patch(patch: Patch, target_dir: Path) -> ApplyResult:
110
177
  """Apply one patch. Never raises — errors go into ApplyResult(status=ERROR)."""
@@ -161,8 +228,10 @@ def apply_patch(patch: Patch, target_dir: Path) -> ApplyResult:
161
228
  def preview_patches(patches: list[Patch], target_dir: Path) -> list[ApplyResult]:
162
229
  """Compute what would happen without writing. Returns APPLIED or SKIPPED."""
163
230
  results = []
164
- # Track fingerprints already "seen" within this preview run (idempotency)
165
- seen_fingerprints: set[str] = set()
231
+ # Track (target_path, fingerprint) pairs already "seen" within this preview
232
+ # run (idempotency). Keyed by file so the same heading in two different
233
+ # target files is correctly treated as two independent patches.
234
+ seen_fingerprints: set[tuple[Path, str]] = set()
166
235
 
167
236
  for patch in patches:
168
237
  target_path = target_dir / patch.target_file
@@ -181,7 +250,8 @@ def preview_patches(patches: list[Patch], target_dir: Path) -> list[ApplyResult]
181
250
 
182
251
  content = target_path.read_text(encoding="utf-8") if target_path.exists() else ""
183
252
 
184
- if fp is not None and (_already_present(content, fp) or fp in seen_fingerprints):
253
+ already_seen = fp is not None and (target_path, fp) in seen_fingerprints
254
+ if fp is not None and (_already_present(content, fp) or already_seen):
185
255
  results.append(ApplyResult(
186
256
  patch=patch,
187
257
  status=ApplyStatus.SKIPPED,
@@ -190,7 +260,7 @@ def preview_patches(patches: list[Patch], target_dir: Path) -> list[ApplyResult]
190
260
  ))
191
261
  else:
192
262
  if fp is not None:
193
- seen_fingerprints.add(fp)
263
+ seen_fingerprints.add((target_path, fp))
194
264
  results.append(ApplyResult(
195
265
  patch=patch,
196
266
  status=ApplyStatus.APPLIED,
@@ -184,6 +184,22 @@ KIND_LABEL: dict[FindingKind, str] = {
184
184
  FindingKind.PROJECT_PATTERN: "PROJECT PATTERN",
185
185
  }
186
186
 
187
+ # Maps FindingKind to the exact ## heading emitted by its recommender patch
188
+ # template. Single source of truth for "which CLAUDE.md sections cctx owns."
189
+ # harvest.py imports this (never reaches into recommender/) so emit/sync can
190
+ # identify cctx-managed sections without depending on the patch generator.
191
+ MANAGED_HEADINGS: dict[FindingKind, str] = {
192
+ FindingKind.RETRY_LOOP: "## Retry discipline",
193
+ FindingKind.SCOPE_CREEP: "## Scope discipline",
194
+ FindingKind.STALE_CONTEXT: "## Context hygiene",
195
+ FindingKind.TOOL_THRASH: "## Tool-call discipline",
196
+ FindingKind.DEAD_END: "## Exploration discipline",
197
+ }
198
+
199
+ # Project-specific patterns use a heading that embeds tool+key, so the managed
200
+ # section is identified by prefix rather than exact match.
201
+ MANAGED_HEADING_PREFIX: str = "## Project-specific: "
202
+
187
203
 
188
204
  class Severity(str, Enum):
189
205
  HIGH = "high"
@@ -88,12 +88,21 @@ Clones patches suitable for emission:
88
88
  reach here given the above filter, but stated explicitly for clarity).
89
89
  - Returns clones via `dataclasses.replace(patch, target_file=EMIT_TARGETS[emit_target])`.
90
90
 
91
- ### `sync_managed_sections(target_dir, emit_target) -> list[ApplyResult]`
91
+ ### `sync_managed_sections(target_dir, emit_target) -> list[Patch]`
92
+
93
+ > **Implementation deviation (2026-06-10):** This function returns
94
+ > `list[Patch]` rather than applying patches inline (the original draft returned
95
+ > `list[ApplyResult]` and called `apply_patch` itself). The CLI appends these
96
+ > patches to the same list it routes through `preview_patches` / `apply_patches`.
97
+ > Returning patches keeps `--dry-run` write-free by construction and matches the
98
+ > codebase's "CLI decides preview vs. apply" layering — applying inline could not
99
+ > preview, contradicting the `--dry-run` requirement and `test_dry_run_no_writes`.
92
100
 
93
101
  1. Reads `CLAUDE.md` from `target_dir`. Returns empty list if absent.
94
102
  2. Calls `_parse_sections(content)` (already in `harvest.py`).
95
103
  3. Keeps sections whose heading is exactly in `MANAGED_HEADINGS.values()` OR
96
- starts with `MANAGED_HEADING_PREFIX`.
104
+ starts with `MANAGED_HEADING_PREFIX`. The leading `("(preamble)", …)` pair
105
+ matches neither branch and is skipped.
97
106
  4. For each kept section, constructs a synthetic `Patch` with:
98
107
  - `target_file = EMIT_TARGETS[emit_target]`
99
108
  - `unified_diff = "\n".join(f"+{line}" for line in [heading] + body.splitlines())`
@@ -102,8 +111,10 @@ Clones patches suitable for emission:
102
111
  `FindingKind.PROJECT_PATTERN` for `## Project-specific: …` prefixed headings
103
112
  - `description = heading`
104
113
  - `evidence_summary = "synced from CLAUDE.md"`
105
- 5. Applies each synthetic patch via `apply_patch`, which handles idempotency
106
- via `_already_present` (the `## Heading` line is the fingerprint).
114
+ 5. Returns the list of synthetic patches. The CLI routes them through the
115
+ existing `preview_patches` / `apply_patches` machinery, which handles
116
+ idempotency via `_already_present` (the `## Heading` line is the fingerprint)
117
+ and dry-run preview without writing.
107
118
 
108
119
  ---
109
120
 
@@ -143,8 +154,13 @@ under their full path.
143
154
  ## Error contract
144
155
 
145
156
  - Never raises. All failures return `ApplyResult(status=ERROR, message=...)`.
146
- - `--sync` with no CLAUDE.md: returns `[]` (not an error), logged as a
147
- single `SKIPPED` line: "CLAUDE.md not found nothing to sync."
157
+ - `--sync` with no CLAUDE.md: `sync_managed_sections` returns `[]` (not an
158
+ error). Because it returns patches rather than applying inline (see the
159
+ deviation note above), there is no `SKIPPED` line for the missing file — the
160
+ empty result simply contributes nothing, and if no other patches exist the CLI
161
+ prints its standard "No patches to apply." message. (The original draft
162
+ emitted a "CLAUDE.md not found — nothing to sync." line; that belonged to the
163
+ inline-apply design and no longer applies.)
148
164
  - Emit target directory is created by `apply_patch`'s existing `parent.mkdir`.
149
165
 
150
166
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctx-cli"
7
- version = "1.5.1"
7
+ version = "1.6.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"
@@ -0,0 +1,204 @@
1
+ """Tests for cctx/harvest.py cross-agent emit (M15) and the managed-heading registry."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def test_managed_headings_cover_the_five_diagnostic_kinds():
6
+ from cctx.models import MANAGED_HEADINGS, FindingKind
7
+ assert MANAGED_HEADINGS == {
8
+ FindingKind.RETRY_LOOP: "## Retry discipline",
9
+ FindingKind.SCOPE_CREEP: "## Scope discipline",
10
+ FindingKind.STALE_CONTEXT: "## Context hygiene",
11
+ FindingKind.TOOL_THRASH: "## Tool-call discipline",
12
+ FindingKind.DEAD_END: "## Exploration discipline",
13
+ }
14
+
15
+
16
+ def test_managed_heading_prefix_is_project_specific():
17
+ from cctx.models import MANAGED_HEADING_PREFIX
18
+ assert MANAGED_HEADING_PREFIX == "## Project-specific: "
19
+
20
+
21
+ def test_registry_matches_templates():
22
+ """Each MANAGED_HEADINGS value equals the first '+##' line of its template diff."""
23
+ from cctx.models import MANAGED_HEADINGS
24
+ from cctx.recommender.claude_md import _TEMPLATES
25
+ for kind, heading in MANAGED_HEADINGS.items():
26
+ assert kind in _TEMPLATES, f"{kind} missing from _TEMPLATES"
27
+ _desc, diff_body, _target = _TEMPLATES[kind]
28
+ first_line = diff_body.splitlines()[0]
29
+ assert first_line == f"+{heading}", (
30
+ f"{kind}: template heading {first_line!r} != registry {('+' + heading)!r}"
31
+ )
32
+
33
+
34
+ def _patch(target_file="CLAUDE.md", heading="## Retry discipline"):
35
+ from cctx.models import FindingKind, Patch
36
+ return Patch(
37
+ target_file=target_file,
38
+ description="desc",
39
+ unified_diff=f"+{heading}\n+\n+body line",
40
+ finding_kind=FindingKind.RETRY_LOOP,
41
+ evidence_summary="ev",
42
+ )
43
+
44
+
45
+ def test_retarget_clones_claude_md_patches_to_agents():
46
+ from cctx.harvest import retarget_patches
47
+ out = retarget_patches([_patch()], "agents")
48
+ assert len(out) == 1
49
+ assert out[0].target_file == "AGENTS.md"
50
+ assert out[0].unified_diff == _patch().unified_diff
51
+
52
+
53
+ def test_retarget_excludes_non_claude_md_patches():
54
+ from cctx.harvest import retarget_patches
55
+ rules_patch = _patch(target_file=".claude/rules/foo.md")
56
+ out = retarget_patches([_patch(), rules_patch], "agents")
57
+ assert len(out) == 1
58
+ assert out[0].target_file == "AGENTS.md"
59
+
60
+
61
+ def test_emit_targets_has_agents():
62
+ from cctx.harvest import EMIT_TARGETS
63
+ assert EMIT_TARGETS["agents"] == "AGENTS.md"
64
+
65
+
66
+ def test_sync_returns_managed_sections_only(tmp_path):
67
+ from cctx.harvest import sync_managed_sections
68
+ (tmp_path / "CLAUDE.md").write_text(
69
+ "# Project\n\n"
70
+ "## Retry discipline\n\nRetry rule body.\n\n"
71
+ "## My hand-written section\n\nNot managed by cctx.\n\n"
72
+ "## Project-specific: Bash(pnpm install)\n\nUse pnpm --filter.\n",
73
+ encoding="utf-8",
74
+ )
75
+ patches = sync_managed_sections(tmp_path, "agents")
76
+ headings = {p.unified_diff.splitlines()[0] for p in patches}
77
+ assert "+## Retry discipline" in headings
78
+ assert "+## Project-specific: Bash(pnpm install)" in headings
79
+ assert "+## My hand-written section" not in headings
80
+ assert all(p.target_file == "AGENTS.md" for p in patches)
81
+
82
+
83
+ def test_sync_finding_kind_reverse_lookup(tmp_path):
84
+ from cctx.harvest import sync_managed_sections
85
+ from cctx.models import FindingKind
86
+ (tmp_path / "CLAUDE.md").write_text(
87
+ "## Context hygiene\n\nbody\n\n"
88
+ "## Project-specific: Bash(x)\n\nbody\n",
89
+ encoding="utf-8",
90
+ )
91
+ patches = sync_managed_sections(tmp_path, "agents")
92
+ by_heading = {p.unified_diff.splitlines()[0]: p.finding_kind for p in patches}
93
+ assert by_heading["+## Context hygiene"] is FindingKind.STALE_CONTEXT
94
+ assert by_heading["+## Project-specific: Bash(x)"] is FindingKind.PROJECT_PATTERN
95
+
96
+
97
+ def test_sync_no_claude_md_returns_empty(tmp_path):
98
+ from cctx.harvest import sync_managed_sections
99
+ assert sync_managed_sections(tmp_path, "agents") == []
100
+
101
+
102
+ def test_emit_apply_then_reapply_is_idempotent(tmp_path):
103
+ from cctx.harvest import ApplyStatus, apply_patches, retarget_patches
104
+ patches = retarget_patches([_patch()], "agents")
105
+ first = apply_patches(patches, tmp_path)
106
+ assert [r.status for r in first] == [ApplyStatus.APPLIED]
107
+ second = apply_patches(patches, tmp_path)
108
+ assert [r.status for r in second] == [ApplyStatus.SKIPPED]
109
+ text = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
110
+ assert text.count("## Retry discipline") == 1
111
+
112
+
113
+ def test_sync_apply_then_reapply_is_idempotent(tmp_path):
114
+ from cctx.harvest import ApplyStatus, apply_patches, sync_managed_sections
115
+ (tmp_path / "CLAUDE.md").write_text(
116
+ "## Retry discipline\n\nRetry rule body.\n", encoding="utf-8"
117
+ )
118
+ patches = sync_managed_sections(tmp_path, "agents")
119
+ apply_patches(patches, tmp_path)
120
+ second = apply_patches(patches, tmp_path)
121
+ assert all(r.status is ApplyStatus.SKIPPED for r in second)
122
+ text = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
123
+ assert text.count("## Retry discipline") == 1
124
+
125
+
126
+ def test_preview_same_heading_different_targets_both_applied(tmp_path):
127
+ """Two patches with the same heading but different target files must both
128
+ preview as APPLIED — dedup is per-(file, heading), not heading-only."""
129
+ from cctx.harvest import ApplyStatus, preview_patches
130
+ from cctx.models import FindingKind, Patch
131
+ diff = "+## Retry discipline\n+\n+body"
132
+ patches = [
133
+ Patch("CLAUDE.md", "d", diff, FindingKind.RETRY_LOOP, "e"),
134
+ Patch("AGENTS.md", "d", diff, FindingKind.RETRY_LOOP, "e"),
135
+ ]
136
+ statuses = [r.status for r in preview_patches(patches, tmp_path)]
137
+ assert statuses == [ApplyStatus.APPLIED, ApplyStatus.APPLIED]
138
+
139
+
140
+ def test_preview_same_heading_same_target_dedups(tmp_path):
141
+ """Two patches with the same heading AND same target: second is SKIPPED."""
142
+ from cctx.harvest import ApplyStatus, preview_patches
143
+ from cctx.models import FindingKind, Patch
144
+ diff = "+## Retry discipline\n+\n+body"
145
+ patches = [
146
+ Patch("AGENTS.md", "d", diff, FindingKind.RETRY_LOOP, "e"),
147
+ Patch("AGENTS.md", "d", diff, FindingKind.RETRY_LOOP, "e"),
148
+ ]
149
+ statuses = [r.status for r in preview_patches(patches, tmp_path)]
150
+ assert statuses == [ApplyStatus.APPLIED, ApplyStatus.SKIPPED]
151
+
152
+
153
+ def test_sync_without_emit_errors(tmp_path):
154
+ from click.testing import CliRunner # noqa: I001
155
+ from cctx.cli import cli
156
+ (tmp_path / "CLAUDE.md").write_text("## Retry discipline\n\nbody\n", encoding="utf-8")
157
+ runner = CliRunner()
158
+ result = runner.invoke(cli, [
159
+ "harvest", str(tmp_path), "--since", "7",
160
+ "--sync", "--target-dir", str(tmp_path),
161
+ ])
162
+ assert result.exit_code != 0
163
+ assert "--sync" in result.output and "--emit" in result.output
164
+
165
+
166
+ def test_sync_dry_run_writes_nothing(tmp_path):
167
+ from click.testing import CliRunner # noqa: I001
168
+ from cctx.cli import cli
169
+ (tmp_path / "CLAUDE.md").write_text("## Retry discipline\n\nbody\n", encoding="utf-8")
170
+ runner = CliRunner()
171
+ result = runner.invoke(cli, [
172
+ "harvest", str(tmp_path), "--since", "7",
173
+ "--emit", "agents", "--sync", "--dry-run",
174
+ "--target-dir", str(tmp_path),
175
+ ])
176
+ assert result.exit_code == 0
177
+ assert not (tmp_path / "AGENTS.md").exists()
178
+
179
+
180
+ def test_sync_apply_creates_agents_md(tmp_path):
181
+ from click.testing import CliRunner # noqa: I001
182
+ from cctx.cli import cli
183
+ (tmp_path / "CLAUDE.md").write_text("## Retry discipline\n\nbody\n", encoding="utf-8")
184
+ runner = CliRunner()
185
+ result = runner.invoke(cli, [
186
+ "harvest", str(tmp_path), "--since", "7",
187
+ "--emit", "agents", "--sync", "--apply",
188
+ "--target-dir", str(tmp_path),
189
+ ])
190
+ assert result.exit_code == 0
191
+ assert (tmp_path / "AGENTS.md").exists()
192
+ assert "## Retry discipline" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
193
+
194
+
195
+ def test_emit_applies_both_targets(tmp_path):
196
+ """End-to-end fan-out: a CLAUDE.md patch and its retargeted clone both land,
197
+ one in CLAUDE.md and one in AGENTS.md (mirrors the CLI's base+retarget flow)."""
198
+ from cctx.harvest import ApplyStatus, apply_patches, retarget_patches
199
+ base = [_patch()] # one CLAUDE.md patch
200
+ combined = base + retarget_patches(base, "agents")
201
+ results = apply_patches(combined, tmp_path)
202
+ assert all(r.status is ApplyStatus.APPLIED for r in results)
203
+ assert "## Retry discipline" in (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
204
+ assert "## Retry discipline" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes