cctx-cli 1.6.0__tar.gz → 1.7.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 (175) hide show
  1. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/CHANGELOG.md +41 -0
  2. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/PKG-INFO +1 -1
  3. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/__init__.py +1 -1
  4. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/__init__.py +54 -8
  5. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/exporters/jsonl.py +10 -0
  6. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/models.py +12 -0
  7. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/renderers/templates/autopsy.html.j2 +15 -0
  8. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/renderers/terminal.py +22 -0
  9. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/pyproject.toml +1 -1
  10. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/exporters/test_jsonl.py +37 -0
  11. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/renderers/test_report.py +23 -0
  12. cctx_cli-1.7.0/tests/test_diagnostician_subagents.py +298 -0
  13. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/.github/workflows/ci.yml +0 -0
  14. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/.github/workflows/publish.yml +0 -0
  15. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/.github/workflows/release.yml +0 -0
  16. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/.gitignore +0 -0
  17. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/CLAUDE.md +0 -0
  18. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/DESIGN.md +0 -0
  19. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/PRODUCT.md +0 -0
  20. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/README.md +0 -0
  21. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/action.yml +0 -0
  22. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/agents.py +0 -0
  23. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/cli.py +0 -0
  24. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/aggregate.py +0 -0
  25. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/inflection.py +0 -0
  26. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/patterns/__init__.py +0 -0
  27. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
  28. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/patterns/project_specific.py +0 -0
  29. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
  30. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
  31. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
  32. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
  33. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/discovery.py +0 -0
  34. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/exporters/__init__.py +0 -0
  35. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/exporters/csv.py +0 -0
  36. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/exporters/json.py +0 -0
  37. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/harvest.py +0 -0
  38. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/parsers/__init__.py +0 -0
  39. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/parsers/claude_code.py +0 -0
  40. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/pricing.py +0 -0
  41. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/recommender/__init__.py +0 -0
  42. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/recommender/claude_md.py +0 -0
  43. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/recommender/evidence.py +0 -0
  44. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/renderers/__init__.py +0 -0
  45. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/renderers/github.py +0 -0
  46. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/renderers/report.py +0 -0
  47. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/renderers/trace_tui.py +0 -0
  48. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/tokenizer.py +0 -0
  49. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx/watcher.py +0 -0
  50. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/cctx-project-brief.md +0 -0
  51. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/demo.gif +0 -0
  52. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/demo.tape +0 -0
  53. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
  54. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
  55. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
  56. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/product-reviews/2026-06-09-product-review.md +0 -0
  57. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
  58. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
  59. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
  60. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/plans/2026-05-17-harvest-check-depth.md +0 -0
  61. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +0 -0
  62. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/plans/2026-05-19-claude-agents-live-integration.md +0 -0
  63. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
  64. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
  65. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
  66. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
  67. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
  68. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-05-17-harvest-check-depth-design.md +0 -0
  69. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +0 -0
  70. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-05-19-claude-agents-live-integration-design.md +0 -0
  71. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/docs/superpowers/specs/2026-06-09-cross-agent-emit-design.md +0 -0
  72. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/__init__.py +0 -0
  73. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/conftest.py +0 -0
  74. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/__init__.py +0 -0
  75. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/conftest.py +0 -0
  76. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/test_dead_end.py +0 -0
  77. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/test_inflection.py +0 -0
  78. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/test_orchestrator.py +0 -0
  79. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/test_project_specific.py +0 -0
  80. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/test_retry_loop.py +0 -0
  81. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/test_scope_creep.py +0 -0
  82. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/test_stale_context.py +0 -0
  83. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/diagnostician/test_tool_thrash.py +0 -0
  84. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/exporters/__init__.py +0 -0
  85. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/exporters/test_csv.py +0 -0
  86. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/README.md +0 -0
  87. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
  88. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a0b4c2cf1dde0ca56.meta.json +0 -0
  89. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a116ae34b1b09c332.meta.json +0 -0
  90. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1c4c417b35658c9e.meta.json +0 -0
  91. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1e41a901de38f1b5.meta.json +0 -0
  92. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a338f8d0c74612a24.meta.json +0 -0
  93. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a34f6f3c0e7094186.meta.json +0 -0
  94. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a5a5a0cff4d13308b.meta.json +0 -0
  95. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a6b0a3da6a0484db5.meta.json +0 -0
  96. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f73f1790b02cde5.meta.json +0 -0
  97. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f7c17c38a9d8788.meta.json +0 -0
  98. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a853259e2cd7bbe8a.meta.json +0 -0
  99. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a8d9aedb0d0c6e12d.meta.json +0 -0
  100. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aa778bc1d59e4a441.meta.json +0 -0
  101. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aba869dedee4a12ba.meta.json +0 -0
  102. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-ada2746d9774b94db.meta.json +0 -0
  103. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea0132068c64d2dd.meta.json +0 -0
  104. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea215eff50874d5f.meta.json +0 -0
  105. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-afee21f2b3852a4a0.meta.json +0 -0
  106. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
  107. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
  108. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.meta.json +0 -0
  109. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
  110. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.meta.json +0 -0
  111. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
  112. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.meta.json +0 -0
  113. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
  114. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.meta.json +0 -0
  115. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
  116. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.meta.json +0 -0
  117. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
  118. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.meta.json +0 -0
  119. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
  120. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.meta.json +0 -0
  121. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
  122. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.meta.json +0 -0
  123. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
  124. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.meta.json +0 -0
  125. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
  126. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.meta.json +0 -0
  127. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
  128. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.meta.json +0 -0
  129. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
  130. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.meta.json +0 -0
  131. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
  132. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.meta.json +0 -0
  133. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
  134. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
  135. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.meta.json +0 -0
  136. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
  137. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.meta.json +0 -0
  138. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
  139. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.meta.json +0 -0
  140. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
  141. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
  142. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
  143. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results/tool-results/bosbkda0h.txt +0 -0
  144. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
  145. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/scrub.py +0 -0
  146. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
  147. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
  148. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
  149. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
  150. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
  151. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/parsers/__init__.py +0 -0
  152. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/parsers/test_claude_code.py +0 -0
  153. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/parsers/test_claude_code_integration.py +0 -0
  154. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/recommender/__init__.py +0 -0
  155. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/recommender/test_claude_md.py +0 -0
  156. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/recommender/test_evidence.py +0 -0
  157. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/renderers/__init__.py +0 -0
  158. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
  159. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_agents.py +0 -0
  160. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_aggregate.py +0 -0
  161. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_cli.py +0 -0
  162. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_cli_export.py +0 -0
  163. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_discovery.py +0 -0
  164. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_github_summary.py +0 -0
  165. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_harvest.py +0 -0
  166. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_harvest_check.py +0 -0
  167. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_harvest_emit.py +0 -0
  168. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_models.py +0 -0
  169. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_models_project_pattern.py +0 -0
  170. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_recommender.py +0 -0
  171. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_smoke.py +0 -0
  172. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_terminal_renderer.py +0 -0
  173. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_tokenizer.py +0 -0
  174. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_trace_tui.py +0 -0
  175. {cctx_cli-1.6.0 → cctx_cli-1.7.0}/tests/test_watcher.py +0 -0
@@ -2,6 +2,47 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v1.7.0 (2026-06-11)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Jsonl exporter — subagent_costs in dict literal; fix import sort
10
+ ([#109](https://github.com/jacquardlabs/cctx/pull/109),
11
+ [`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
12
+
13
+ - Remove unused ToolResult import in test_diagnostician_subagents
14
+ ([#109](https://github.com/jacquardlabs/cctx/pull/109),
15
+ [`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
16
+
17
+ ### Documentation
18
+
19
+ - Restore billing-rate explanation in _compute_own_cost
20
+ ([#109](https://github.com/jacquardlabs/cctx/pull/109),
21
+ [`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
22
+
23
+ ### Features
24
+
25
+ - Diagnostician — inclusive cost + per-subagent attribution
26
+ ([#109](https://github.com/jacquardlabs/cctx/pull/109),
27
+ [`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
28
+
29
+ - HTML report + JSON exporter — subagent_costs output
30
+ ([#109](https://github.com/jacquardlabs/cctx/pull/109),
31
+ [`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
32
+
33
+ - Per-subagent cost attribution in autopsy (#88)
34
+ ([#109](https://github.com/jacquardlabs/cctx/pull/109),
35
+ [`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
36
+
37
+ - SubagentAttribution model + Diagnosis.subagent_costs field
38
+ ([#109](https://github.com/jacquardlabs/cctx/pull/109),
39
+ [`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
40
+
41
+ - Terminal renderer — subagent cost table in autopsy output
42
+ ([#109](https://github.com/jacquardlabs/cctx/pull/109),
43
+ [`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
44
+
45
+
5
46
  ## v1.6.0 (2026-06-10)
6
47
 
7
48
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctx-cli
3
- Version: 1.6.0
3
+ Version: 1.7.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.6.0"
3
+ __version__ = "1.7.0"
@@ -1,9 +1,9 @@
1
1
  """Autopsy diagnostician — public entry point.
2
2
 
3
3
  run(trace) -> Diagnosis
4
- Runs all three pattern classifiers, detects inflection turn,
5
- patches cost attribution for stale_context findings, and returns
6
- a Diagnosis with patches=[].
4
+ Runs all pattern classifiers, detects inflection turn,
5
+ patches stale_context cost attribution, and returns
6
+ a Diagnosis with patches=[] and subagent_costs populated.
7
7
 
8
8
  The Recommender (cctx.recommender.claude_md) populates patches.
9
9
  """
@@ -21,7 +21,7 @@ from cctx.diagnostician.patterns import (
21
21
  stale_context,
22
22
  tool_thrash,
23
23
  )
24
- from cctx.models import Diagnosis, Finding, FindingKind
24
+ from cctx.models import Diagnosis, Finding, FindingKind, SubagentAttribution
25
25
  from cctx.pricing import price_per_tok as _price_per_tok
26
26
 
27
27
  if TYPE_CHECKING:
@@ -41,8 +41,8 @@ def _patch_costs(findings: list[Finding], model: str | None) -> list[Finding]:
41
41
  return result
42
42
 
43
43
 
44
- def _compute_total_cost(trace: SessionTrace, model: str | None) -> float:
45
- """Approximate total session cost including cache reads and writes.
44
+ def _compute_own_cost(trace: SessionTrace, model: str | None) -> float:
45
+ """Parent-turns-only cost does not recurse into subagents.
46
46
 
47
47
  Billing rates relative to base input price:
48
48
  cache_read: ×0.10 (read from prompt cache)
@@ -59,6 +59,50 @@ def _compute_total_cost(trace: SessionTrace, model: str | None) -> float:
59
59
  return round(total, 4)
60
60
 
61
61
 
62
+ def _compute_inclusive_cost(trace: SessionTrace) -> float:
63
+ """Recursive cost: own turns + all subagent turns at every depth."""
64
+ own = _compute_own_cost(trace, trace.primary_model)
65
+ return own + sum(_compute_inclusive_cost(sa) for sa in trace.subagents)
66
+
67
+
68
+ def _build_label_map(trace: SessionTrace) -> dict[str, str]:
69
+ """Map child session_id → display label from the parent's Agent ToolUse inputs."""
70
+ label_map: dict[str, str] = {}
71
+ for turn in trace.turns:
72
+ for tu in turn.tool_uses:
73
+ if tu.subagent_session_id:
74
+ ti = tu.tool_input
75
+ label_map[tu.subagent_session_id] = (
76
+ ti.get("description")
77
+ or (ti.get("prompt") or "")[:80]
78
+ or tu.subagent_session_id[:12]
79
+ )
80
+ return label_map
81
+
82
+
83
+ def _collect_attributions(
84
+ trace: SessionTrace,
85
+ depth: int = 1,
86
+ label_map: dict[str, str] | None = None,
87
+ ) -> list[SubagentAttribution]:
88
+ """Flat DFS list of SubagentAttribution, one per subagent at every depth."""
89
+ if label_map is None:
90
+ label_map = _build_label_map(trace)
91
+ result: list[SubagentAttribution] = []
92
+ for child in trace.subagents:
93
+ label = label_map.get(child.session_id, child.session_id[:12])
94
+ cost = _compute_inclusive_cost(child)
95
+ result.append(SubagentAttribution(
96
+ session_id=child.session_id,
97
+ label=label,
98
+ total_cost_usd=round(cost, 4),
99
+ depth=depth,
100
+ model=child.primary_model,
101
+ ))
102
+ result.extend(_collect_attributions(child, depth + 1, None))
103
+ return result
104
+
105
+
62
106
  def run(trace: SessionTrace) -> Diagnosis:
63
107
  """Diagnose a single SessionTrace. Returns Diagnosis with patches=[]."""
64
108
  findings: list[Finding] = [
@@ -73,11 +117,12 @@ def run(trace: SessionTrace) -> Diagnosis:
73
117
  inflection_turn = inflection.detect(findings)
74
118
  findings = _patch_costs(findings, trace.primary_model)
75
119
 
76
- total_cost = _compute_total_cost(trace, trace.primary_model)
120
+ total_cost = round(_compute_inclusive_cost(trace), 4)
77
121
  waste_cost = sum(f.cost_usd for f in findings if f.cost_usd is not None)
78
- # Waste cannot exceed total session cost — cap as a logical invariant.
79
122
  waste_cost = min(waste_cost, total_cost)
80
123
 
124
+ subagent_costs = _collect_attributions(trace)
125
+
81
126
  return Diagnosis(
82
127
  session_id=trace.session_id,
83
128
  findings=findings,
@@ -86,4 +131,5 @@ def run(trace: SessionTrace) -> Diagnosis:
86
131
  total_cost_usd=total_cost,
87
132
  waste_cost_usd=round(waste_cost, 4),
88
133
  analysed_at=datetime.now(UTC),
134
+ subagent_costs=subagent_costs,
89
135
  )
@@ -50,6 +50,16 @@ def export_diagnosis(
50
50
  "patches": patches,
51
51
  "turn_count": len(trace.turns),
52
52
  "model": trace.primary_model,
53
+ "subagent_costs": [
54
+ {
55
+ "session_id": a.session_id,
56
+ "label": a.label,
57
+ "cost_usd": a.total_cost_usd,
58
+ "depth": a.depth,
59
+ "model": a.model,
60
+ }
61
+ for a in diagnosis.subagent_costs
62
+ ],
53
63
  }
54
64
  return json.dumps(obj)
55
65
 
@@ -233,6 +233,17 @@ class Patch:
233
233
  evidence_summary: str
234
234
 
235
235
 
236
+ @dataclass
237
+ class SubagentAttribution:
238
+ """Cost attribution for a single subagent session."""
239
+
240
+ session_id: str
241
+ label: str # from Agent tool_input['description'], else prompt[:80]
242
+ total_cost_usd: float # inclusive: this subagent + its own children
243
+ depth: int # 1 = direct child, 2 = grandchild, …
244
+ model: str | None
245
+
246
+
236
247
  @dataclass
237
248
  class Diagnosis:
238
249
  session_id: str
@@ -242,6 +253,7 @@ class Diagnosis:
242
253
  total_cost_usd: float
243
254
  waste_cost_usd: float
244
255
  analysed_at: datetime
256
+ subagent_costs: list[SubagentAttribution] = field(default_factory=list)
245
257
 
246
258
  @property
247
259
  def verdict(self) -> str:
@@ -176,6 +176,21 @@ footer {
176
176
  <dt>Waste attributed</dt><dd>~${{ "%.2f"|format(diag.waste_cost_usd) }}</dd>
177
177
  {% if diag.inflection_turn is not none %}<dt>Inflection turn</dt><dd>{{ diag.inflection_turn }}</dd>{% endif %}
178
178
  </dl>
179
+ {% if diag.subagent_costs %}
180
+ <details class="subagent-costs">
181
+ <summary>Subagents: {{ diag.subagent_costs | selectattr("depth", "equalto", 1) | list | length }} — ${{ "%.3f" % (diag.subagent_costs | selectattr("depth", "equalto", 1) | map(attribute="total_cost_usd") | sum) }}</summary>
182
+ <table>
183
+ <tr><th>Label</th><th>Depth</th><th>Cost</th></tr>
184
+ {% for a in diag.subagent_costs %}
185
+ <tr>
186
+ <td>{{ a.label | truncate(80) }}</td>
187
+ <td>{{ a.depth }}</td>
188
+ <td>${{ "%.3f" % a.total_cost_usd }}</td>
189
+ </tr>
190
+ {% endfor %}
191
+ </table>
192
+ </details>
193
+ {% endif %}
179
194
  <p class="meta">Costs are estimates (~85–95% of actual billing; system framing not observable in JSONL)</p>
180
195
  <p class="meta">Analysed {{ diag.analysed_at.strftime("%Y-%m-%d %H:%M UTC") }}</p>
181
196
  </div>
@@ -52,7 +52,13 @@ def render_diagnosis(
52
52
  verdict = diagnosis.verdict
53
53
  verdict_style = "bold green" if not diagnosis.findings else "bold red"
54
54
  con.print(Text(f"Verdict: {verdict}", style=verdict_style))
55
+ subagent_sum = sum(a.total_cost_usd for a in diagnosis.subagent_costs if a.depth == 1)
56
+ n_sub = len([a for a in diagnosis.subagent_costs if a.depth == 1])
55
57
  cost_line = f"Session cost: ~${diagnosis.total_cost_usd:.2f}"
58
+ if n_sub:
59
+ cost_line += (
60
+ f" (includes {n_sub} subagent{'s' if n_sub != 1 else ''}: ~${subagent_sum:.2f})"
61
+ )
56
62
  if diagnosis.waste_cost_usd > 0:
57
63
  pct = (
58
64
  diagnosis.waste_cost_usd / diagnosis.total_cost_usd * 100
@@ -65,6 +71,22 @@ def render_diagnosis(
65
71
  "~85–95% of actual billing; system framing not observable in JSONL", style="dim"
66
72
  ))
67
73
 
74
+ if diagnosis.subagent_costs:
75
+ show_depth = any(a.depth > 1 for a in diagnosis.subagent_costs)
76
+ tbl = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
77
+ tbl.add_column("Subagent", no_wrap=False, max_width=48)
78
+ if show_depth:
79
+ tbl.add_column("Depth", justify="right", width=6)
80
+ tbl.add_column("Cost", justify="right", width=8)
81
+ for a in diagnosis.subagent_costs:
82
+ label = a.label if len(a.label) <= 45 else a.label[:44] + "…"
83
+ cost_cell = f"${a.total_cost_usd:.3f}"
84
+ if show_depth:
85
+ tbl.add_row(label, str(a.depth), cost_cell)
86
+ else:
87
+ tbl.add_row(label, cost_cell)
88
+ con.print(tbl)
89
+
68
90
  if not diagnosis.findings:
69
91
  con.print("\nNo findings — session looks clean.")
70
92
  return
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctx-cli"
7
- version = "1.6.0"
7
+ version = "1.7.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"
@@ -229,6 +229,43 @@ def test_write_produces_one_line_per_session() -> None:
229
229
  assert ids == {"sess-001", "sess-002"}
230
230
 
231
231
 
232
+ def test_export_diagnosis_includes_subagent_costs() -> None:
233
+ """JSON export includes subagent_costs array with correct fields."""
234
+ import dataclasses
235
+ import json
236
+
237
+ from cctx.exporters.jsonl import export_diagnosis
238
+ from cctx.models import SubagentAttribution
239
+
240
+ diag = _make_diagnosis()
241
+ trace = _make_trace()
242
+ diag = dataclasses.replace(diag, subagent_costs=[
243
+ SubagentAttribution(
244
+ session_id="child-1",
245
+ label="My task",
246
+ total_cost_usd=0.020,
247
+ depth=1,
248
+ model="claude-sonnet-4",
249
+ )
250
+ ])
251
+ data = json.loads(export_diagnosis(diag, trace))
252
+ assert "subagent_costs" in data
253
+ assert len(data["subagent_costs"]) == 1
254
+ assert data["subagent_costs"][0]["session_id"] == "child-1"
255
+ assert data["subagent_costs"][0]["cost_usd"] == pytest.approx(0.020)
256
+ assert data["subagent_costs"][0]["depth"] == 1
257
+
258
+
259
+ def test_export_diagnosis_subagent_costs_empty_by_default() -> None:
260
+ """JSON export has subagent_costs: [] when no subagents."""
261
+ from cctx.exporters.jsonl import export_diagnosis
262
+
263
+ diag = _make_diagnosis()
264
+ trace = _make_trace()
265
+ data = json.loads(export_diagnosis(diag, trace))
266
+ assert data["subagent_costs"] == []
267
+
268
+
232
269
  def test_write_empty_list_produces_no_output() -> None:
233
270
  """write() with an empty list produces no output."""
234
271
  from cctx.exporters.jsonl import write
@@ -266,6 +266,29 @@ def test_autopsy_html_flag_writes_file(tmp_path):
266
266
  assert session_id in content
267
267
 
268
268
 
269
+ def test_html_includes_subagent_costs():
270
+ """HTML output contains subagent label and cost when subagent_costs present."""
271
+ import dataclasses
272
+
273
+ from cctx.models import SubagentAttribution
274
+ from cctx.renderers.report import render_html
275
+
276
+ diag = _make_diagnosis()
277
+ trace = _simple_trace()
278
+ diag = dataclasses.replace(diag, subagent_costs=[
279
+ SubagentAttribution(
280
+ session_id="child-1",
281
+ label="Analyze the database schema",
282
+ total_cost_usd=0.025,
283
+ depth=1,
284
+ model="claude-sonnet-4",
285
+ )
286
+ ])
287
+ html = render_html(diag, trace)
288
+ assert "Analyze the database schema" in html
289
+ assert "0.025" in html
290
+
291
+
269
292
  def test_autopsy_html_with_since_errors(tmp_path):
270
293
  """--html and --since together should error, not silently ignore --html."""
271
294
 
@@ -0,0 +1,298 @@
1
+ """Tests for per-subagent cost attribution (M16 #88)."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from cctx.models import Diagnosis, SessionTrace, ToolUse, Turn, Usage
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Helpers — synthetic trace builders (real fixtures have scrubbed tokens)
11
+ # ---------------------------------------------------------------------------
12
+
13
+ _TS = datetime(2026, 6, 10, tzinfo=timezone.utc)
14
+
15
+
16
+ def _make_usage(input_tokens: int) -> Usage:
17
+ return Usage(
18
+ input_tokens=input_tokens,
19
+ output_tokens=50,
20
+ cache_creation_5m=0,
21
+ cache_creation_1h=0,
22
+ cache_read=0,
23
+ service_tier=None,
24
+ )
25
+
26
+
27
+ def _make_trace(
28
+ session_id: str,
29
+ input_tokens: int,
30
+ *,
31
+ subagents: list[SessionTrace] | None = None,
32
+ model: str = "claude-sonnet-4",
33
+ tool_uses: list[ToolUse] | None = None,
34
+ ) -> SessionTrace:
35
+ turn = Turn(
36
+ turn_number=1,
37
+ uuid="u1",
38
+ parent_uuid=None,
39
+ role="assistant",
40
+ text="ok",
41
+ thinking="",
42
+ tool_uses=tool_uses or [],
43
+ tool_results=[],
44
+ usage=_make_usage(input_tokens),
45
+ model=model,
46
+ stop_reason="end_turn",
47
+ timestamp=_TS,
48
+ duration_ms=None,
49
+ )
50
+ return SessionTrace(
51
+ session_id=session_id,
52
+ parent_session_id=None,
53
+ project_path="/p",
54
+ cwd="/p",
55
+ primary_model=model,
56
+ claude_code_version=None,
57
+ turns=[turn],
58
+ subagents=subagents or [],
59
+ attachments=[],
60
+ raw_tool_result_files=[],
61
+ initial_context_tokens=0,
62
+ tool_names_loaded=[],
63
+ start_time=_TS,
64
+ end_time=_TS,
65
+ source_path=Path(f"/p/{session_id}.jsonl"),
66
+ subagent_meta={},
67
+ warnings=[],
68
+ subagent_parse_errors=[],
69
+ )
70
+
71
+
72
+ def _agent_tu(
73
+ session_id: str,
74
+ *,
75
+ description: str = "",
76
+ prompt: str = "",
77
+ ) -> ToolUse:
78
+ """Construct an Agent ToolUse linked to a child session."""
79
+ ti: dict = {"prompt": prompt}
80
+ if description:
81
+ ti["description"] = description
82
+ return ToolUse(
83
+ tool_name="Agent",
84
+ tool_use_id=f"tu_{session_id}",
85
+ tool_input=ti,
86
+ subagent_session_id=session_id,
87
+ )
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Tests
92
+ # ---------------------------------------------------------------------------
93
+
94
+ def test_no_subagents_cost_unchanged():
95
+ """With no subagents, total_cost_usd equals parent-only cost and subagent_costs is empty."""
96
+ from cctx.diagnostician import run
97
+ trace = _make_trace("parent", input_tokens=5_000)
98
+ diag = run(trace)
99
+ assert diag.subagent_costs == []
100
+ # parent has 5000 input tokens at sonnet-4 price ($3/Mtok) = $0.0150
101
+ assert abs(diag.total_cost_usd - 0.0150) < 0.001
102
+
103
+
104
+ def test_subagent_attribution_dataclass_exists():
105
+ from cctx.models import SubagentAttribution
106
+ a = SubagentAttribution(
107
+ session_id="child-1",
108
+ label="my label",
109
+ total_cost_usd=0.05,
110
+ depth=1,
111
+ model="claude-sonnet-4",
112
+ )
113
+ assert a.session_id == "child-1"
114
+ assert a.label == "my label"
115
+ assert a.depth == 1
116
+
117
+
118
+ def test_one_subagent_cost_inclusive():
119
+ """total_cost_usd includes direct child's cost."""
120
+ from cctx.diagnostician import run
121
+ child = _make_trace("child-1", input_tokens=10_000)
122
+ parent = _make_trace("parent", input_tokens=5_000, subagents=[child])
123
+ diag = run(parent)
124
+ # parent: 5000 * 3/1e6 = 0.015; child: 10000 * 3/1e6 = 0.030; total = 0.045
125
+ assert abs(diag.total_cost_usd - 0.045) < 0.001
126
+ assert len(diag.subagent_costs) == 1
127
+ assert diag.subagent_costs[0].session_id == "child-1"
128
+
129
+
130
+ def test_nested_subagents_cost_inclusive():
131
+ """total_cost_usd sums all levels (parent + child + grandchild)."""
132
+ from cctx.diagnostician import run
133
+ grandchild = _make_trace("grand", input_tokens=5_000)
134
+ child = _make_trace("child", input_tokens=10_000, subagents=[grandchild])
135
+ parent = _make_trace("parent", input_tokens=5_000, subagents=[child])
136
+ diag = run(parent)
137
+ # 5000 + 10000 + 5000 = 20000 tokens * 3/1e6 = 0.060
138
+ assert abs(diag.total_cost_usd - 0.060) < 0.001
139
+ assert len(diag.subagent_costs) == 2
140
+
141
+
142
+ def test_attribution_depth_1():
143
+ """Direct child has depth == 1."""
144
+ from cctx.diagnostician import run
145
+ child = _make_trace("child-1", input_tokens=1_000)
146
+ parent = _make_trace("parent", input_tokens=1_000, subagents=[child])
147
+ diag = run(parent)
148
+ assert diag.subagent_costs[0].depth == 1
149
+
150
+
151
+ def test_attribution_depth_2():
152
+ """Grandchild has depth == 2."""
153
+ from cctx.diagnostician import run
154
+ grandchild = _make_trace("grand", input_tokens=1_000)
155
+ child = _make_trace("child", input_tokens=1_000, subagents=[grandchild])
156
+ parent = _make_trace("parent", input_tokens=1_000, subagents=[child])
157
+ diag = run(parent)
158
+ depths = {a.session_id: a.depth for a in diag.subagent_costs}
159
+ assert depths["child"] == 1
160
+ assert depths["grand"] == 2
161
+
162
+
163
+ def test_attribution_label_from_description():
164
+ """Label comes from Agent tool_input['description'] when present."""
165
+ from cctx.diagnostician import run
166
+ child = _make_trace("child-1", input_tokens=1_000)
167
+ tu = _agent_tu("child-1", description="Explore the codebase", prompt="Do something long")
168
+ parent = _make_trace("parent", input_tokens=1_000, subagents=[child], tool_uses=[tu])
169
+ diag = run(parent)
170
+ assert diag.subagent_costs[0].label == "Explore the codebase"
171
+
172
+
173
+ def test_attribution_label_from_prompt_fallback():
174
+ """When no 'description', label is prompt[:80]."""
175
+ from cctx.diagnostician import run
176
+ child = _make_trace("child-1", input_tokens=1_000)
177
+ long_prompt = "A" * 200
178
+ tu = _agent_tu("child-1", prompt=long_prompt)
179
+ parent = _make_trace("parent", input_tokens=1_000, subagents=[child], tool_uses=[tu])
180
+ diag = run(parent)
181
+ assert diag.subagent_costs[0].label == long_prompt[:80]
182
+
183
+
184
+ def test_attribution_label_orphan_fallback():
185
+ """Unlinked subagent (no matching ToolUse) gets session_id[:12] as label."""
186
+ from cctx.diagnostician import run
187
+ child = _make_trace("child-unlinked-session", input_tokens=1_000)
188
+ # Parent has no Agent ToolUse linking to this child
189
+ parent = _make_trace("parent", input_tokens=1_000, subagents=[child])
190
+ diag = run(parent)
191
+ assert diag.subagent_costs[0].label == "child-unlink" # first 12 chars
192
+
193
+
194
+ def test_subagent_cost_no_double_count():
195
+ """Two direct subagents: total equals parent + child1 + child2."""
196
+ from cctx.diagnostician import run
197
+ child1 = _make_trace("c1", input_tokens=10_000)
198
+ child2 = _make_trace("c2", input_tokens=20_000)
199
+ parent = _make_trace("parent", input_tokens=5_000, subagents=[child1, child2])
200
+ diag = run(parent)
201
+ expected = (5_000 + 10_000 + 20_000) * 3 / 1_000_000
202
+ assert abs(diag.total_cost_usd - expected) < 0.001
203
+ assert len(diag.subagent_costs) == 2
204
+
205
+
206
+ def test_total_cost_not_less_than_depth1_sum():
207
+ """Invariant: total_cost >= sum of direct-child costs."""
208
+ from cctx.diagnostician import run
209
+ child1 = _make_trace("c1", input_tokens=10_000)
210
+ child2 = _make_trace("c2", input_tokens=20_000)
211
+ parent = _make_trace("parent", input_tokens=5_000, subagents=[child1, child2])
212
+ diag = run(parent)
213
+ depth1_sum = sum(a.total_cost_usd for a in diag.subagent_costs if a.depth == 1)
214
+ assert diag.total_cost_usd >= depth1_sum
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Renderer tests
219
+ # ---------------------------------------------------------------------------
220
+
221
+ def _make_diagnosis_with_subagents(n: int = 2) -> Diagnosis:
222
+ from cctx.models import Diagnosis, SubagentAttribution
223
+ attributions = [
224
+ SubagentAttribution(
225
+ session_id=f"child-{i}",
226
+ label=f"Task {i}: do something useful",
227
+ total_cost_usd=round(0.010 * (i + 1), 4),
228
+ depth=1,
229
+ model="claude-sonnet-4",
230
+ )
231
+ for i in range(n)
232
+ ]
233
+ return Diagnosis(
234
+ session_id="parent-session",
235
+ findings=[],
236
+ inflection_turn=None,
237
+ patches=[],
238
+ total_cost_usd=round(0.030 + sum(a.total_cost_usd for a in attributions), 4),
239
+ waste_cost_usd=0.0,
240
+ analysed_at=_TS,
241
+ subagent_costs=attributions,
242
+ )
243
+
244
+
245
+ def test_render_diagnosis_shows_subagent_summary():
246
+ """Cost line mentions subagent count and sum when subagents present."""
247
+ from io import StringIO
248
+
249
+ from rich.console import Console
250
+
251
+ from cctx.renderers.terminal import render_diagnosis
252
+ buf = StringIO()
253
+ con = Console(file=buf, no_color=True, width=120)
254
+ diag = _make_diagnosis_with_subagents(2)
255
+ render_diagnosis(diag, console=con)
256
+ out = buf.getvalue()
257
+ assert "2 subagent" in out
258
+ assert "$0.03" in out # subagent sum = 0.010 + 0.020 = 0.030
259
+
260
+
261
+ def test_render_diagnosis_shows_subagent_table():
262
+ """Subagent table lists each agent's label and cost."""
263
+ from io import StringIO
264
+
265
+ from rich.console import Console
266
+
267
+ from cctx.renderers.terminal import render_diagnosis
268
+ buf = StringIO()
269
+ con = Console(file=buf, no_color=True, width=120)
270
+ diag = _make_diagnosis_with_subagents(2)
271
+ render_diagnosis(diag, console=con)
272
+ out = buf.getvalue()
273
+ assert "Task 0: do something useful" in out
274
+ assert "Task 1: do something useful" in out
275
+
276
+
277
+ def test_render_diagnosis_no_subagents_no_table():
278
+ """When subagent_costs is empty, no subagent table is shown."""
279
+ from io import StringIO
280
+
281
+ from rich.console import Console
282
+
283
+ from cctx.models import Diagnosis
284
+ from cctx.renderers.terminal import render_diagnosis
285
+ buf = StringIO()
286
+ con = Console(file=buf, no_color=True, width=120)
287
+ diag = Diagnosis(
288
+ session_id="s1",
289
+ findings=[],
290
+ inflection_turn=None,
291
+ patches=[],
292
+ total_cost_usd=0.05,
293
+ waste_cost_usd=0.0,
294
+ analysed_at=_TS,
295
+ )
296
+ render_diagnosis(diag, console=con)
297
+ out = buf.getvalue()
298
+ assert "subagent" not in out.lower()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes