cc-session-tools 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. {cc_session_tools-2.1.0/src/cc_session_tools.egg-info → cc_session_tools-2.2.0}/PKG-INFO +1 -1
  2. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/pyproject.toml +1 -1
  3. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccr.py +25 -0
  4. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccs.py +60 -4
  5. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccst.py +83 -0
  6. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/doctor.py +33 -0
  7. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/sessions_db.py +37 -0
  8. cc_session_tools-2.2.0/src/cc_session_tools/lib/sessions_repair.py +128 -0
  9. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0/src/cc_session_tools.egg-info}/PKG-INFO +1 -1
  10. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/SOURCES.txt +3 -0
  11. cc_session_tools-2.2.0/src/cccs_hooks/after_response.py +46 -0
  12. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/session_tag.py +27 -8
  13. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_after_response.py +21 -0
  14. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_sentinel_sort.py +120 -0
  15. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_doctor.py +73 -0
  16. cc_session_tools-2.2.0/tests/test_ccst_repair_sessions_cli.py +147 -0
  17. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_ccr.py +71 -0
  18. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_session_tag.py +26 -0
  19. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_sessions_db.py +30 -0
  20. cc_session_tools-2.2.0/tests/test_sessions_repair.py +208 -0
  21. cc_session_tools-2.1.0/src/cccs_hooks/after_response.py +0 -34
  22. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/LICENSE +0 -0
  23. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/README.md +0 -0
  24. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/setup.cfg +0 -0
  25. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/__init__.py +0 -0
  26. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/__init__.py +0 -0
  27. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccd.py +0 -0
  28. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccmsg.py +0 -0
  29. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccsched.py +0 -0
  30. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/migrate_ccmsg.py +0 -0
  31. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/migrate_ccsched.py +0 -0
  32. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/migrate_sessions_db.py +0 -0
  33. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/migrate_telemetry.py +0 -0
  34. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/hooks_install.py +0 -0
  35. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/__init__.py +0 -0
  36. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/claude_flags.py +0 -0
  37. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/claude_md_install.py +0 -0
  38. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/db.py +0 -0
  39. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/debug.py +0 -0
  40. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/doctor_mutes.py +0 -0
  41. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/hook_registry.py +0 -0
  42. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/levenshtein.py +0 -0
  43. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/__init__.py +0 -0
  44. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/addressing.py +0 -0
  45. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/cursor.py +0 -0
  46. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/lock.py +0 -0
  47. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/message.py +0 -0
  48. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/move_safety.py +0 -0
  49. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/repository.py +0 -0
  50. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/retention.py +0 -0
  51. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/service.py +0 -0
  52. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/store.py +0 -0
  53. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/paths.py +0 -0
  54. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/__init__.py +0 -0
  55. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/backup.py +0 -0
  56. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/classify.py +0 -0
  57. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/cutover.py +0 -0
  58. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/formatting.py +0 -0
  59. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/importers.py +0 -0
  60. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/init_paths.py +0 -0
  61. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/init_service.py +0 -0
  62. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/manifest.py +0 -0
  63. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/naming.py +0 -0
  64. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/repository.py +0 -0
  65. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/service.py +0 -0
  66. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/session_output.py +0 -0
  67. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/store.py +0 -0
  68. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/verify.py +0 -0
  69. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/picker.py +0 -0
  70. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/prompts.py +0 -0
  71. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/roots.py +0 -0
  72. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/rules.py +0 -0
  73. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/__init__.py +0 -0
  74. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/bundled_jobs.py +0 -0
  75. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/cadence.py +0 -0
  76. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/cursor.py +0 -0
  77. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/digest.py +0 -0
  78. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/due.py +0 -0
  79. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/duration.py +0 -0
  80. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/jobspec.py +0 -0
  81. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/ledger.py +0 -0
  82. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/lock.py +0 -0
  83. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/notify.py +0 -0
  84. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/reconcile.py +0 -0
  85. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/registry.py +0 -0
  86. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/runner.py +0 -0
  87. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/state.py +0 -0
  88. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/store.py +0 -0
  89. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/surface.py +0 -0
  90. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/throttle.py +0 -0
  91. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/worker.py +0 -0
  92. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/session_gc.py +0 -0
  93. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/sessions.py +0 -0
  94. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/shell_install.py +0 -0
  95. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/tasklist.py +0 -0
  96. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/telemetry_store.py +0 -0
  97. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/dependency_links.txt +0 -0
  98. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/entry_points.txt +0 -0
  99. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/requires.txt +0 -0
  100. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/top_level.txt +0 -0
  101. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/__init__.py +0 -0
  102. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/bash_hard_deny.py +0 -0
  103. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/bash_security_review.py +0 -0
  104. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/cache.py +0 -0
  105. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/catchup.py +0 -0
  106. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/confirm_8digit.py +0 -0
  107. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/last_screenshot.py +0 -0
  108. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/marker_allow.py +0 -0
  109. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/markers.py +0 -0
  110. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/messaging_deliver.py +0 -0
  111. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/normalise.py +0 -0
  112. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/pending_migration.py +0 -0
  113. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/stats.py +0 -0
  114. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/telemetry.py +0 -0
  115. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/telemetry_query.py +0 -0
  116. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/telemetry_trim.py +0 -0
  117. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/transcript.py +0 -0
  118. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/worklog_guard.py +0 -0
  119. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/__init__.py +0 -0
  120. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/attribution.py +0 -0
  121. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/cache.py +0 -0
  122. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/ccusage_wrapper.py +0 -0
  123. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/cli.py +0 -0
  124. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/data/pricing.json +0 -0
  125. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/parent_inference.py +0 -0
  126. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/parser.py +0 -0
  127. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/pricing.py +0 -0
  128. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/query.py +0 -0
  129. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/report.py +0 -0
  130. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/schema.py +0 -0
  131. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/session_names.py +0 -0
  132. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_attribution.py +0 -0
  133. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_bash_hard_deny.py +0 -0
  134. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_bash_security_review.py +0 -0
  135. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cache.py +0 -0
  136. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cache_sqlite.py +0 -0
  137. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccr_orphans.py +0 -0
  138. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_emptiness.py +0 -0
  139. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_eta.py +0 -0
  140. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_list_mode.py +0 -0
  141. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_scope_flags.py +0 -0
  142. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_session_counts.py +0 -0
  143. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_ccsched_jobs_cli.py +0 -0
  144. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_cli.py +0 -0
  145. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_gc_report.py +0 -0
  146. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_hook_dispatcher.py +0 -0
  147. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_hooks_install_prune.py +0 -0
  148. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_hooks_install_zero_arg.py +0 -0
  149. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_hooks_uninstall.py +0 -0
  150. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_install_everything.py +0 -0
  151. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_pdata_cli.py +0 -0
  152. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_pdata_init_cli.py +0 -0
  153. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_pdata_reconcile_cli.py +0 -0
  154. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_pdata_verify_cli.py +0 -0
  155. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_sessions_cli.py +0 -0
  156. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_shell_install.py +0 -0
  157. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_skills_install.py +0 -0
  158. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_skills_uninstall.py +0 -0
  159. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_telemetry_query.py +0 -0
  160. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_telemetry_trim.py +0 -0
  161. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccusage_wrapper.py +0 -0
  162. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_claude_flags.py +0 -0
  163. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_claude_md_install.py +0 -0
  164. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli.py +0 -0
  165. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_ccd.py +0 -0
  166. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_ccd_dry_run.py +0 -0
  167. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_ccs.py +0 -0
  168. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_version.py +0 -0
  169. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_confirm_8digit.py +0 -0
  170. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_db.py +0 -0
  171. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_debug.py +0 -0
  172. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_doctor_mutes.py +0 -0
  173. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_empty_session.py +0 -0
  174. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_hook_cache.py +0 -0
  175. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_hooks_install.py +0 -0
  176. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_last_screenshot.py +0 -0
  177. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_levenshtein.py +0 -0
  178. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_marker_allow.py +0 -0
  179. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_migrate_ccmsg.py +0 -0
  180. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_migrate_ccsched.py +0 -0
  181. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_migrate_sessions_db.py +0 -0
  182. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_migrate_telemetry.py +0 -0
  183. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_normalise.py +0 -0
  184. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_parent_inference.py +0 -0
  185. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_parser.py +0 -0
  186. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_paths.py +0 -0
  187. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_pending_migration_hook.py +0 -0
  188. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_picker.py +0 -0
  189. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_pricing.py +0 -0
  190. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_prompts.py +0 -0
  191. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_query.py +0 -0
  192. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_report.py +0 -0
  193. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_roots.py +0 -0
  194. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_rules.py +0 -0
  195. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_scheduler_bundled_jobs.py +0 -0
  196. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_schema.py +0 -0
  197. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_session_names.py +0 -0
  198. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_sessions.py +0 -0
  199. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_tasklist.py +0 -0
  200. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_telemetry.py +0 -0
  201. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_telemetry_store.py +0 -0
  202. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_transcript.py +0 -0
  203. {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_worklog_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-tools
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Claude Code session management CLI tools (ccd, ccr, ccs, claude-code-usage, ccst) and hook library (cccs_hooks)
5
5
  Author: raffishquartan
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cc-session-tools"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Claude Code session management CLI tools (ccd, ccr, ccs, claude-code-usage, ccst) and hook library (cccs_hooks)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -23,6 +23,30 @@ def _format_size(num_bytes: int) -> str:
23
23
  return f"{size:.1f}GB"
24
24
 
25
25
 
26
+ def _warn_if_fragment_matches_corrupted_row(fragment: str) -> None:
27
+ """Print a stderr diagnostic when `fragment` matches a sessions.db row whose project_dir
28
+ is non-absolute. Such a row is invisible to every lookup path here — the exact-match fast
29
+ path and find_matching_sessions both filter by root, and find_orphan_transcripts skips it
30
+ because its cc-sessions/<name>/ directory already exists on disk — so without this it reads
31
+ as an ordinary "not found" rather than a fixable data problem. Called unconditionally
32
+ whenever `matches` is computed, not only when it's empty, so a corrupted sibling of a
33
+ resumable match is still surfaced. Mirrors ccs.py's _warn_if_corrupted_rows_present."""
34
+ from cc_session_tools.lib import sessions_repair
35
+
36
+ corrupted = [
37
+ row for row in sessions_repair.find_non_absolute_rows()
38
+ if fragment in row.basename
39
+ ]
40
+ if corrupted:
41
+ names = ", ".join(row.basename for row in corrupted)
42
+ print(
43
+ f"ccr: {len(corrupted)} session(s) matching '{fragment}' have an invalid "
44
+ f"(non-absolute) project_dir and can't be resumed by name: {names} — run "
45
+ "'ccst repair sessions --dry-run' to see them, then --execute to fix.",
46
+ file=sys.stderr,
47
+ )
48
+
49
+
26
50
  def _pick_duplicate_transcript(basename: str, candidates: list[Path]) -> Path | None:
27
51
  """Multiple JSONL transcripts share one session tag - e.g. hitting Ctrl-L
28
52
  twice mid-session clears one copy while `claude --resume` still lists it
@@ -114,6 +138,7 @@ def main(argv: list[str] | None = None) -> int:
114
138
  matches.extend(o for o in orphans if o.basename not in on_disk_basenames)
115
139
  debug(f"fragment: {args.fragment!r}")
116
140
  debug(f"matches: {[m.basename for m in matches]}")
141
+ _warn_if_fragment_matches_corrupted_row(args.fragment)
117
142
 
118
143
  if not matches:
119
144
  print(f"ccr: no sessions match '{args.fragment}'", file=sys.stderr)
@@ -398,8 +398,10 @@ def _collect_session_rows(
398
398
  order_by/limit push an indexed ORDER BY ... LIMIT into SQL (see
399
399
  sessions_db.list_sessions) when order_by is a DB-backed column - this is
400
400
  what makes "most recent N" an O(log n) lookup, not a fetch-everything-then-
401
- slice. For --global, the root filter is applied after the DB query, so with
402
- --limit the returned N rows are the N most recent *before* root filtering.
401
+ slice. For --global, the root filter is applied after the DB query — see
402
+ _fetch_global_limited() for how the limit case stays correct despite that,
403
+ and _warn_if_corrupted_rows_present() for the stderr diagnostic printed
404
+ whenever a non-absolute project_dir row is found and omitted.
403
405
 
404
406
  `order_by` uses ccs's flag vocabulary ("opened"/"active"); it is mapped
405
407
  here to the sessions.db column names ("last_opened"/"last_active").
@@ -413,8 +415,11 @@ def _collect_session_rows(
413
415
  except RootsConfigError as e:
414
416
  print(str(e), file=sys.stderr)
415
417
  sys.exit(1)
416
- rows = sessions_db.list_sessions(order_by=db_order_by, limit=db_limit)
417
- return [r for r in rows if r.project_dir.parent in roots]
418
+ if db_limit is None:
419
+ rows = sessions_db.list_sessions(order_by=db_order_by, limit=None)
420
+ _warn_if_corrupted_rows_present(rows)
421
+ return [r for r in rows if r.project_dir.parent in roots]
422
+ return _fetch_global_limited(roots, db_order_by, db_limit)
418
423
 
419
424
  cwd = Path.cwd().resolve()
420
425
  return sessions_db.list_sessions(
@@ -422,6 +427,49 @@ def _collect_session_rows(
422
427
  )
423
428
 
424
429
 
430
+ _GLOBAL_LIMIT_GROWTH_FACTOR = 4
431
+
432
+
433
+ def _warn_if_corrupted_rows_present(rows: list["sessions_db.SessionRow"]) -> None:
434
+ """Print a visible (user- and Claude-Code-visible, via stderr) heads-up when any fetched
435
+ row has a non-absolute project_dir — these are the specific rows `ccst repair sessions`
436
+ can fix, as opposed to ordinary out-of-root rows (a project simply not under a configured
437
+ root), which are normal scoping and not flagged."""
438
+ corrupted = sum(1 for r in rows if not r.project_dir.is_absolute())
439
+ if corrupted:
440
+ print(
441
+ f"ccs: {corrupted} session(s) in sessions.db have an invalid (non-absolute) "
442
+ "project_dir and are missing from this --global listing — run "
443
+ "'ccst repair sessions --dry-run' to see them, then --execute to fix.",
444
+ file=sys.stderr,
445
+ )
446
+
447
+
448
+ def _fetch_global_limited(
449
+ roots: list[Path], db_order_by: str, db_limit: int
450
+ ) -> list["sessions_db.SessionRow"]:
451
+ """Fetch the db_limit most-recent in-root rows for --global.
452
+
453
+ A plain `ORDER BY ... LIMIT db_limit` applied before the root filter can
454
+ starve the result: any out-of-root (or corrupted — see
455
+ _warn_if_corrupted_rows_present) row sitting ahead of a legitimate one in
456
+ last_active/last_opened order consumes a LIMIT slot that never gets
457
+ root-filtered back in. Grow the DB-side fetch window geometrically until
458
+ either db_limit in-root rows have survived the filter or the table is
459
+ exhausted (`len(rows) < fetch` signals exhaustion) — this keeps the
460
+ common case (most rows are in-root) at one query while staying correct
461
+ when it isn't. The corrupted-row warning fires once, on the final fetch.
462
+ """
463
+ fetch = db_limit
464
+ while True:
465
+ rows = sessions_db.list_sessions(order_by=db_order_by, limit=fetch)
466
+ filtered = [r for r in rows if r.project_dir.parent in roots]
467
+ if len(filtered) >= db_limit or len(rows) < fetch:
468
+ _warn_if_corrupted_rows_present(rows)
469
+ return filtered[:db_limit]
470
+ fetch *= _GLOBAL_LIMIT_GROWTH_FACTOR
471
+
472
+
425
473
  def _display_path(p: Path) -> str:
426
474
  home = Path.home()
427
475
  try:
@@ -1056,6 +1104,14 @@ def main(argv: list[str] | None = None) -> int:
1056
1104
  file=sys.stderr,
1057
1105
  )
1058
1106
  return 2
1107
+ if args.limit is not None and args.limit < 1:
1108
+ print(
1109
+ f"ccs: --limit must be a positive integer, got {args.limit} "
1110
+ "(SQLite treats a negative LIMIT as unbounded, and Python's "
1111
+ "list[:N] slicing would silently drop from the wrong end)",
1112
+ file=sys.stderr,
1113
+ )
1114
+ return 2
1059
1115
 
1060
1116
  # Validate date filter args eagerly so bad values are caught before any I/O.
1061
1117
  date_filter: tuple[str | None, str | None] | None = None
@@ -674,6 +674,7 @@ def _cmd_doctor(args: argparse.Namespace) -> int:
674
674
  legacy_migration_paths=legacy_migration_paths,
675
675
  projects_root=default_projects_root(),
676
676
  pdata_verify_projects=_pdata_verify.discover_projects(),
677
+ sessions_db_path=store_paths["sessions"],
677
678
  )
678
679
 
679
680
  if args.drift or getattr(args, "mode", None) == "drift":
@@ -1245,6 +1246,58 @@ def _cmd_sessions_list(args: argparse.Namespace) -> int:
1245
1246
  return 0
1246
1247
 
1247
1248
 
1249
+ def _cmd_repair_sessions(args: argparse.Namespace) -> int:
1250
+ from cc_session_tools.lib import db as db_lib
1251
+ from cc_session_tools.lib import sessions_db, sessions_repair
1252
+ from cc_session_tools.lib.roots import RootsConfigError, load_session_roots
1253
+
1254
+ db_path = Path(args.sessions_db) if args.sessions_db else sessions_db.default_db_path()
1255
+ try:
1256
+ roots = load_session_roots()
1257
+ except RootsConfigError as e:
1258
+ print(str(e), file=sys.stderr)
1259
+ return 1
1260
+
1261
+ if args.execute:
1262
+ if not db_path.exists():
1263
+ # sqlite3.connect() auto-creates an empty file, so without this check
1264
+ # db_lib.backup_to() below would silently back up (and repair() would open)
1265
+ # a brand-new, empty sessions.db instead of failing loudly on the mistake.
1266
+ print(f"No sessions.db found at {db_path} — nothing to back up or repair.", file=sys.stderr)
1267
+ return 1
1268
+ backup_dir = db_path.parent / "repair-backups"
1269
+ backup_dir.mkdir(parents=True, exist_ok=True)
1270
+ stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1271
+ backup_path = backup_dir / f"sessions-{stamp}.db"
1272
+ db_lib.backup_to(db_path, backup_path)
1273
+ print(f"Backed up sessions.db to {backup_path}")
1274
+
1275
+ report = sessions_repair.repair(roots, path=db_path, dry_run=not args.execute)
1276
+ if not any((report.repaired, report.unresolved, report.ambiguous, report.conflicts)):
1277
+ print("No non-absolute project_dir rows found in sessions.db.")
1278
+ return 0
1279
+
1280
+ verb = "fixed" if args.execute else "would fix"
1281
+ for basename, new_dir in report.repaired:
1282
+ print(f" {verb}: {basename} -> {new_dir}")
1283
+ for basename in report.unresolved:
1284
+ print(
1285
+ f" UNRESOLVED (no on-disk cc-sessions/{basename} under any root): {basename}",
1286
+ file=sys.stderr,
1287
+ )
1288
+ for basename, candidates in report.ambiguous.items():
1289
+ print(f" AMBIGUOUS ({len(candidates)} candidates, skipped): {basename}", file=sys.stderr)
1290
+ for basename in report.conflicts:
1291
+ print(
1292
+ f" CONFLICT (a correct row for this basename already exists, skipped): {basename}",
1293
+ file=sys.stderr,
1294
+ )
1295
+
1296
+ if not args.execute and report.repaired:
1297
+ print(f"\n{len(report.repaired)} row(s) would be repaired. Re-run with --execute to apply.")
1298
+ return 1 if (report.unresolved or report.ambiguous or report.conflicts) else 0
1299
+
1300
+
1248
1301
  def _fmt_ts(epoch: float) -> str:
1249
1302
  if not epoch:
1250
1303
  return "(never)"
@@ -2034,6 +2087,32 @@ def _build_parser() -> argparse.ArgumentParser:
2034
2087
  "Claude Code session.")
2035
2088
  m_all.add_argument("--dry-run", action="store_true")
2036
2089
 
2090
+ # ---- repair ----
2091
+ repair_parser = sub.add_parser("repair", help="Repair known sessions.db/store corruption")
2092
+ repair_sub = repair_parser.add_subparsers(dest="verb", metavar="<verb>")
2093
+ repair_sub.required = True
2094
+ r_sessions = repair_sub.add_parser(
2095
+ "sessions",
2096
+ help=(
2097
+ "Fix sessions.db rows whose project_dir is not an absolute path (invisible "
2098
+ "to `ccl`/`ccs --global`) by re-resolving them from on-disk cc-sessions/ "
2099
+ "directories. Dry-run by default."
2100
+ ),
2101
+ )
2102
+ r_sessions_mode = r_sessions.add_mutually_exclusive_group()
2103
+ r_sessions_mode.add_argument(
2104
+ "--execute", action="store_true",
2105
+ help="Apply the repair (default: dry-run, print what would change).",
2106
+ )
2107
+ r_sessions_mode.add_argument(
2108
+ "--dry-run", action="store_true",
2109
+ help="Print what would change without writing (this is the default).",
2110
+ )
2111
+ r_sessions.add_argument(
2112
+ "--sessions-db", default=None, metavar="PATH",
2113
+ help="sessions.db path override (default: from CCST_SESSIONS_DIR)",
2114
+ )
2115
+
2037
2116
  # ---- ccsched-jobs ----
2038
2117
  ccsched_jobs_parser = sub.add_parser(
2039
2118
  "ccsched-jobs", help="Provision CCST-bundled ccsched jobs",
@@ -2188,6 +2267,10 @@ def main() -> None:
2188
2267
  if args.verb == "all":
2189
2268
  sys.exit(_cmd_migrate_all(args))
2190
2269
 
2270
+ if args.noun == "repair":
2271
+ if args.verb == "sessions":
2272
+ sys.exit(_cmd_repair_sessions(args))
2273
+
2191
2274
  if args.noun == "ccsched-jobs":
2192
2275
  if args.verb == "install":
2193
2276
  sys.exit(_cmd_ccsched_jobs_install(args))
@@ -677,6 +677,31 @@ def check_pdata_verify(projects: list[str]) -> list[CheckResult]:
677
677
  return results
678
678
 
679
679
 
680
+ def check_sessions_project_dir_absolute(sessions_db_path: Path) -> list[CheckResult]:
681
+ """WARN if any sessions.db row has a non-absolute project_dir. Every reader that
682
+ scopes --global by root (ccs.py, sessions.find_matching_sessions) compares
683
+ project_dir.parent against a resolved, absolute root — a relative project_dir can
684
+ never match, so the row becomes permanently invisible to --global listings without
685
+ ever raising an error. Not FAIL: it degrades --global listings but does not block
686
+ core functionality, and `ccst repair sessions` fixes it non-destructively."""
687
+ from cc_session_tools.lib import sessions_repair
688
+
689
+ bad = sessions_repair.find_non_absolute_rows(path=sessions_db_path)
690
+ if not bad:
691
+ return [CheckResult(
692
+ name="sessions:project-dir-absolute", status=Status.OK,
693
+ reason="all sessions.db rows have an absolute project_dir",
694
+ )]
695
+ return [CheckResult(
696
+ name="sessions:project-dir-absolute", status=Status.WARN,
697
+ reason=(
698
+ f"{len(bad)} sessions.db row(s) have a non-absolute project_dir and are "
699
+ "invisible to `ccl`/`ccs --global` — run 'ccst repair sessions --dry-run' "
700
+ "to see them, then --execute to fix"
701
+ ),
702
+ )]
703
+
704
+
680
705
  # ---------- high-level runner ----------
681
706
 
682
707
 
@@ -693,6 +718,7 @@ def run_all_checks(
693
718
  legacy_migration_paths: LegacyMigrationPaths | None = None,
694
719
  projects_root: Path | None = None,
695
720
  pdata_verify_projects: list[str] | None = None,
721
+ sessions_db_path: Path | None = None,
696
722
  ) -> list[CheckResult]:
697
723
  """Run the full doctor suite and return results.
698
724
 
@@ -726,6 +752,9 @@ def run_all_checks(
726
752
  Project names whose last persisted ``ccst pdata verify`` result should
727
753
  be reported; when None, the pdata-verify checks are skipped. Never
728
754
  triggers a verify run — only reads what the recurring job left behind.
755
+ sessions_db_path:
756
+ Path to sessions.db; when None, the project_dir-absolute check is
757
+ skipped.
729
758
  """
730
759
  results: list[CheckResult] = []
731
760
 
@@ -794,6 +823,10 @@ def run_all_checks(
794
823
  if pdata_verify_projects is not None:
795
824
  results.extend(check_pdata_verify(pdata_verify_projects))
796
825
 
826
+ # Non-absolute project_dir rows in sessions.db
827
+ if sessions_db_path is not None:
828
+ results.extend(check_sessions_project_dir_absolute(sessions_db_path))
829
+
797
830
  # PyPI version check
798
831
  if not skip_pypi:
799
832
  results.append(check_pypi_version(installed_version))
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import os
23
23
  import sqlite3
24
+ import sys
24
25
  import time
25
26
  from dataclasses import dataclass
26
27
  from datetime import datetime, timezone
@@ -156,6 +157,27 @@ def _row_to_session(row: sqlite3.Row) -> SessionRow:
156
157
  )
157
158
 
158
159
 
160
+ def _reject_non_absolute_project_dir(project_dir: Path, basename: str, *, source: str) -> None:
161
+ """Print a clear, unmissable diagnostic to stderr — visible to a human reading the
162
+ terminal AND to Claude Code, which surfaces hook stderr as tool/hook output. A silent
163
+ rejection here means this row is permanently invisible to `ccl`/`ccs --global` until
164
+ repaired, so every rejection must say so out loud."""
165
+ print(
166
+ f"[sessions-db] REJECTED write from {source}: project_dir={str(project_dir)!r} for "
167
+ f"session {basename!r} is not absolute. This session will be MISSING from `ccl`/"
168
+ "`ccs --global` listings until fixed — run 'ccst repair sessions --dry-run' to see "
169
+ "it, then --execute to fix.",
170
+ file=sys.stderr,
171
+ )
172
+
173
+
174
+ def _is_valid_project_dir(project_dir: Path, basename: str, *, source: str) -> bool:
175
+ if project_dir.is_absolute():
176
+ return True
177
+ _reject_non_absolute_project_dir(project_dir, basename, source=source)
178
+ return False
179
+
180
+
159
181
  def ensure_session_row(
160
182
  project_dir: Path,
161
183
  basename: str,
@@ -169,12 +191,17 @@ def ensure_session_row(
169
191
  fires (hooks disabled/broken); the hook's own touch_last_opened() upsert
170
192
  is the normal path and would create the same row moments later regardless.
171
193
 
194
+ Also a no-op — with a loud stderr diagnostic — when `project_dir` is not
195
+ absolute; see `_is_valid_project_dir`.
196
+
172
197
  conn, if given, is reused as-is (caller owns commit/close) — see write_tag."""
173
198
  from cc_session_tools.lib.sessions import session_start_date
174
199
 
175
200
  start_date = session_start_date(basename)
176
201
  if start_date is None:
177
202
  return
203
+ if not _is_valid_project_dir(project_dir, basename, source="ensure_session_row"):
204
+ return
178
205
  owns_conn = conn is None
179
206
  c = conn if conn is not None else connect(path=path)
180
207
  try:
@@ -201,12 +228,17 @@ def touch_last_opened(
201
228
  ) -> None:
202
229
  """Upsert the last_opened timestamp (epoch seconds) for (project_dir, basename).
203
230
 
231
+ Also a no-op — with a loud stderr diagnostic — when `project_dir` is not
232
+ absolute; see `_is_valid_project_dir`.
233
+
204
234
  conn, if given, is reused as-is (caller owns commit/close) — see write_tag."""
205
235
  from cc_session_tools.lib.sessions import session_start_date
206
236
 
207
237
  start_date = session_start_date(basename)
208
238
  if start_date is None:
209
239
  return
240
+ if not _is_valid_project_dir(project_dir, basename, source="touch_last_opened"):
241
+ return
210
242
  ts = when if when is not None else time.time()
211
243
  owns_conn = conn is None
212
244
  c = conn if conn is not None else connect(path=path)
@@ -234,12 +266,17 @@ def touch_last_active(
234
266
  ) -> None:
235
267
  """Upsert the last_active timestamp (epoch seconds) for (project_dir, basename).
236
268
 
269
+ Also a no-op — with a loud stderr diagnostic — when `project_dir` is not
270
+ absolute; see `_is_valid_project_dir`.
271
+
237
272
  conn, if given, is reused as-is (caller owns commit/close) — see write_tag."""
238
273
  from cc_session_tools.lib.sessions import session_start_date
239
274
 
240
275
  start_date = session_start_date(basename)
241
276
  if start_date is None:
242
277
  return
278
+ if not _is_valid_project_dir(project_dir, basename, source="touch_last_active"):
279
+ return
243
280
  ts = when if when is not None else time.time()
244
281
  owns_conn = conn is None
245
282
  c = conn if conn is not None else connect(path=path)
@@ -0,0 +1,128 @@
1
+ """Repair sessions.db rows whose project_dir was written as a non-absolute Path.
2
+ Resolves each affected row's correct project_dir by locating its on-disk
3
+ cc-sessions/<basename>/ directory under the configured roots, then updates the row
4
+ in place, preserving its timestamps. Never guesses when a basename's on-disk
5
+ location is ambiguous (found under >1 root/project) or missing (found under none)
6
+ — those are reported, not silently resolved, because a wrong guess would
7
+ silently point `ccl`/`ccs` at the wrong project's session data, which is worse
8
+ than the original missing-from-listings bug."""
9
+ from __future__ import annotations
10
+
11
+ from collections import Counter
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from cc_session_tools.lib import sessions_db
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class RepairReport:
20
+ """Mutable accumulator, built up in place by repair() — not a frozen value object.
21
+
22
+ Note the duplicate-basename semantics differ across fields: `ambiguous` is a
23
+ dict keyed by basename (so a duplicate basename overwrites its own entry),
24
+ while `repaired`/`unresolved`/`conflicts` are lists (so a duplicate basename
25
+ can appear more than once). A caller counting rows across fields must account
26
+ for this asymmetry."""
27
+
28
+ repaired: list[tuple[str, Path]] = field(default_factory=list)
29
+ unresolved: list[str] = field(default_factory=list)
30
+ ambiguous: dict[str, list[Path]] = field(default_factory=dict)
31
+ conflicts: list[str] = field(default_factory=list)
32
+
33
+
34
+ def find_non_absolute_rows(*, path: Path | None = None) -> list[sessions_db.SessionRow]:
35
+ """Every sessions.db row whose project_dir is not an absolute path."""
36
+ return [r for r in sessions_db.list_sessions(path=path) if not r.project_dir.is_absolute()]
37
+
38
+
39
+ def _resolve_on_disk(basename: str, roots: list[Path]) -> list[Path]:
40
+ """Every project directly under a configured root whose cc-sessions/<basename>/
41
+ exists on disk — the set of plausible correct project_dir values for this row."""
42
+ matches: list[Path] = []
43
+ for root in roots:
44
+ if not root.is_dir():
45
+ continue
46
+ for proj in root.iterdir():
47
+ if proj.is_dir() and (proj / "cc-sessions" / basename).is_dir():
48
+ matches.append(proj)
49
+ return matches
50
+
51
+
52
+ def repair(
53
+ roots: list[Path], *, path: Path | None = None, dry_run: bool = True
54
+ ) -> RepairReport:
55
+ """Resolve and (unless dry_run) fix every non-absolute project_dir row.
56
+
57
+ dry_run=True (the default) only computes and returns the report. dry_run=False
58
+ updates sessions.db in place — one UPDATE per uniquely-resolved row, leaving
59
+ start_date/last_opened/last_active/discovered_at untouched: a project_dir
60
+ correction is not a new session-activity event, so it must not move
61
+ last_opened/last_active. Callers that want a safety copy first should back up
62
+ the DB file (e.g. via db.backup_to()) before calling with dry_run=False; this
63
+ function does not.
64
+
65
+ A resolved row is never blindly UPDATEd: (project_dir, basename) is the table's
66
+ PRIMARY KEY, so if a correct row for (resolved_dir, basename) already exists —
67
+ e.g. ccd.py's ensure_session_row() wrote it independently at session-creation
68
+ time — writing the corrupted row on top would violate that key. Such rows are
69
+ reported as `conflicts` and left untouched rather than raising mid-batch. The
70
+ same applies when two bad rows in the SAME batch resolve to the same target
71
+ (e.g. one row has project_dir='.', another has '..', and both name the same
72
+ basename): applying one would make the other's UPDATE collide with it, so
73
+ both are reported as conflicts rather than letting row-processing order
74
+ arbitrarily pick a "winner".
75
+ """
76
+ bad_rows = find_non_absolute_rows(path=path)
77
+ report = RepairReport()
78
+ if not bad_rows:
79
+ return report
80
+
81
+ resolutions: list[tuple[sessions_db.SessionRow, Path]] = []
82
+ for row in bad_rows:
83
+ candidates = _resolve_on_disk(row.basename, roots)
84
+ if len(candidates) == 1:
85
+ resolutions.append((row, candidates[0]))
86
+ elif len(candidates) == 0:
87
+ report.unresolved.append(row.basename)
88
+ else:
89
+ report.ambiguous[row.basename] = candidates
90
+
91
+ if not resolutions:
92
+ return report
93
+
94
+ # Split out any resolution whose target (new_dir, basename) already has a row —
95
+ # applying it would violate the PRIMARY KEY. Check before writing anything, not
96
+ # via try/except around the UPDATE, so one conflict can't abort sibling rows'
97
+ # otherwise-valid updates. `batch_target_counts` catches the same collision
98
+ # when it happens WITHIN this batch (two bad rows resolving to the same
99
+ # target): every row sharing such a target is a conflict, not just whichever
100
+ # one is processed second — picking a "winner" by iteration order would be
101
+ # arbitrary and non-deterministic.
102
+ existing_keys = {
103
+ (r.project_dir, r.basename) for r in sessions_db.list_sessions(path=path)
104
+ }
105
+ batch_target_counts = Counter((new_dir, row.basename) for row, new_dir in resolutions)
106
+ applyable: list[tuple[sessions_db.SessionRow, Path]] = []
107
+ for row, new_dir in resolutions:
108
+ target = (new_dir, row.basename)
109
+ if target in existing_keys or batch_target_counts[target] > 1:
110
+ report.conflicts.append(row.basename)
111
+ else:
112
+ applyable.append((row, new_dir))
113
+ report.repaired.append((row.basename, new_dir))
114
+
115
+ if dry_run or not applyable:
116
+ return report
117
+
118
+ conn = sessions_db.connect(path=path)
119
+ try:
120
+ for row, new_dir in applyable:
121
+ conn.execute(
122
+ "UPDATE sessions SET project_dir = ? WHERE project_dir = ? AND basename = ?",
123
+ (str(new_dir), str(row.project_dir), row.basename),
124
+ )
125
+ conn.commit()
126
+ finally:
127
+ conn.close()
128
+ return report
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-tools
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Claude Code session management CLI tools (ccd, ccr, ccs, claude-code-usage, ccst) and hook library (cccs_hooks)
5
5
  Author: raffishquartan
6
6
  License: MIT License
@@ -37,6 +37,7 @@ src/cc_session_tools/lib/rules.py
37
37
  src/cc_session_tools/lib/session_gc.py
38
38
  src/cc_session_tools/lib/sessions.py
39
39
  src/cc_session_tools/lib/sessions_db.py
40
+ src/cc_session_tools/lib/sessions_repair.py
40
41
  src/cc_session_tools/lib/shell_install.py
41
42
  src/cc_session_tools/lib/tasklist.py
42
43
  src/cc_session_tools/lib/telemetry_store.py
@@ -143,6 +144,7 @@ tests/test_ccst_pdata_cli.py
143
144
  tests/test_ccst_pdata_init_cli.py
144
145
  tests/test_ccst_pdata_reconcile_cli.py
145
146
  tests/test_ccst_pdata_verify_cli.py
147
+ tests/test_ccst_repair_sessions_cli.py
146
148
  tests/test_ccst_sessions_cli.py
147
149
  tests/test_ccst_shell_install.py
148
150
  tests/test_ccst_skills_install.py
@@ -190,6 +192,7 @@ tests/test_session_names.py
190
192
  tests/test_session_tag.py
191
193
  tests/test_sessions.py
192
194
  tests/test_sessions_db.py
195
+ tests/test_sessions_repair.py
193
196
  tests/test_tasklist.py
194
197
  tests/test_telemetry.py
195
198
  tests/test_telemetry_store.py
@@ -0,0 +1,46 @@
1
+ """Stop hook: records a session-activity sentinel into sessions.db.
2
+
3
+ Fires via Stop event (after each Claude response, not once per session).
4
+ Upserts the sessions table's last_active timestamp so `ccs --order-by active`
5
+ can sort sessions by recency of Claude activity without a filesystem walk.
6
+ Never blocks. Silent on the normal successful-write path; emits a
7
+ systemMessage only when CLD_SESSION_DIR is shaped like cc-sessions/<basename>
8
+ but not absolute, since writing would otherwise collapse project_dir to '.'.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import sqlite3
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from cc_session_tools.lib import sessions_db
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ session_dir_str = os.environ.get("CLD_SESSION_DIR", "")
23
+ if session_dir_str:
24
+ session_dir_path = Path(session_dir_str)
25
+ if session_dir_path.parent.name == "cc-sessions":
26
+ if not session_dir_path.is_absolute():
27
+ msg = (
28
+ f"[after-response] CLD_SESSION_DIR is not absolute "
29
+ f"({session_dir_str!r}); skipping sessions.db update to avoid writing "
30
+ "a corrupt project_dir. Run 'ccst repair sessions --dry-run' if "
31
+ "ccl/ccs --global later looks incomplete."
32
+ )
33
+ print(json.dumps({"systemMessage": msg}))
34
+ else:
35
+ try:
36
+ sessions_db.touch_last_active(
37
+ session_dir_path.parent.parent, session_dir_path.name
38
+ )
39
+ except (OSError, sqlite3.Error) as exc:
40
+ print(f"[after-response] Failed to record .last-active: {exc}", file=sys.stderr)
41
+
42
+ return 0
43
+
44
+
45
+ if __name__ == "__main__":
46
+ sys.exit(main())