cc-session-tools 2.8.0__tar.gz → 2.8.2__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 (257) hide show
  1. {cc_session_tools-2.8.0/src/cc_session_tools.egg-info → cc_session_tools-2.8.2}/PKG-INFO +1 -1
  2. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/pyproject.toml +1 -1
  3. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/ccst.py +111 -8
  4. cc_session_tools-2.8.2/src/cc_session_tools/lib/pdata/reorganize.py +367 -0
  5. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/service.py +69 -4
  6. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/write_log.py +16 -11
  7. cc_session_tools-2.8.2/src/cc_session_tools/prompts/pdata-migration-claude-md-update.md +125 -0
  8. cc_session_tools-2.8.2/src/cc_session_tools/prompts/pdata-migration-skills-update.md +128 -0
  9. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/pm-pdata-schema-design/SKILL.md +20 -0
  10. cc_session_tools-2.8.2/src/cc_session_tools/skills/pm-project-layout-reference/SKILL.md +83 -0
  11. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2/src/cc_session_tools.egg-info}/PKG-INFO +1 -1
  12. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools.egg-info/SOURCES.txt +3 -0
  13. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_bundle_discovery.py +24 -1
  14. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_pdata_cli.py +22 -0
  15. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_pdata_init_cli.py +7 -0
  16. cc_session_tools-2.8.2/tests/test_ccst_pdata_reorganize_cli.py +175 -0
  17. cc_session_tools-2.8.0/src/cc_session_tools/prompts/pdata-migration-claude-md-update.md +0 -10
  18. cc_session_tools-2.8.0/src/cc_session_tools/prompts/pdata-migration-skills-update.md +0 -10
  19. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/LICENSE +0 -0
  20. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/README.md +0 -0
  21. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/setup.cfg +0 -0
  22. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/__init__.py +0 -0
  23. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/__init__.py +0 -0
  24. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/ccd.py +0 -0
  25. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/ccmsg.py +0 -0
  26. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/ccr.py +0 -0
  27. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/ccs.py +0 -0
  28. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/ccsched.py +0 -0
  29. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/migrate_ccmsg.py +0 -0
  30. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/migrate_ccsched.py +0 -0
  31. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/migrate_sessions_db.py +0 -0
  32. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/cli/migrate_telemetry.py +0 -0
  33. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/config/hooks-bundle.json +0 -0
  34. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/config/session-tag-hook.json +0 -0
  35. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/hooks_install.py +0 -0
  36. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/__init__.py +0 -0
  37. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/claude_flags.py +0 -0
  38. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/claude_md_install.py +0 -0
  39. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/context_overrides.py +0 -0
  40. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/db.py +0 -0
  41. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/debug.py +0 -0
  42. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/doctor.py +0 -0
  43. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/doctor_mutes.py +0 -0
  44. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/hook_registry.py +0 -0
  45. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/install_sync.py +0 -0
  46. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/levenshtein.py +0 -0
  47. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/__init__.py +0 -0
  48. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/addressing.py +0 -0
  49. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/cursor.py +0 -0
  50. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/lock.py +0 -0
  51. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/message.py +0 -0
  52. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/move_safety.py +0 -0
  53. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/repository.py +0 -0
  54. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/retention.py +0 -0
  55. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/service.py +0 -0
  56. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/messaging/store.py +0 -0
  57. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/paths.py +0 -0
  58. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/__init__.py +0 -0
  59. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/backup.py +0 -0
  60. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/classify.py +0 -0
  61. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/cutover.py +0 -0
  62. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/formatting.py +0 -0
  63. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/importers.py +0 -0
  64. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/init_paths.py +0 -0
  65. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/init_service.py +0 -0
  66. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/manifest.py +0 -0
  67. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/naming.py +0 -0
  68. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/repository.py +0 -0
  69. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/session_output.py +0 -0
  70. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/store.py +0 -0
  71. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/pdata/verify.py +0 -0
  72. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/picker.py +0 -0
  73. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/proc_lock.py +0 -0
  74. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/prompts.py +0 -0
  75. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/roots.py +0 -0
  76. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/rules.py +0 -0
  77. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/__init__.py +0 -0
  78. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/bundled_jobs.py +0 -0
  79. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/cadence.py +0 -0
  80. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/cursor.py +0 -0
  81. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/digest.py +0 -0
  82. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/due.py +0 -0
  83. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/duration.py +0 -0
  84. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/jobspec.py +0 -0
  85. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/ledger.py +0 -0
  86. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/lock.py +0 -0
  87. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/notify.py +0 -0
  88. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/reconcile.py +0 -0
  89. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/registry.py +0 -0
  90. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/runner.py +0 -0
  91. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/state.py +0 -0
  92. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/store.py +0 -0
  93. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/surface.py +0 -0
  94. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/throttle.py +0 -0
  95. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/scheduler/worker.py +0 -0
  96. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/session_gc.py +0 -0
  97. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/sessions.py +0 -0
  98. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/sessions_db.py +0 -0
  99. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/sessions_repair.py +0 -0
  100. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/shell_install.py +0 -0
  101. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/tasklist.py +0 -0
  102. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/lib/telemetry_store.py +0 -0
  103. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/analyse-cc-usage/SKILL.md +0 -0
  104. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/context-override/SKILL.md +0 -0
  105. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/delete-sessions/SKILL.md +0 -0
  106. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/delete-sessions/scripts/delete_sessions.py +0 -0
  107. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/do-executor-critic-assessor-loop/SKILL.md +0 -0
  108. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/find-claude-code-session/SKILL.md +0 -0
  109. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/generate-8digit-code/SKILL.md +0 -0
  110. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/generate-8digit-code/scripts/generate_8digit_code.py +0 -0
  111. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/list-empty-sessions/SKILL.md +0 -0
  112. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/list-empty-sessions/scripts/list_empty_sessions.py +0 -0
  113. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/manage-recurring-cc-jobs-using-ccsched/SKILL.md +0 -0
  114. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/move-session/SKILL.md +0 -0
  115. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/move-session/hooks/sessionstart-pending-rename.sh +0 -0
  116. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/move-session/scripts/cc_session_rules.py +0 -0
  117. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/move-session/scripts/move_session.py +0 -0
  118. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/pm-pdata-conflict-resolution/SKILL.md +0 -0
  119. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/pm-project-init/SKILL.md +0 -0
  120. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/pm-update-central-files/SKILL.md +0 -0
  121. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/reduce-persistent-context/CALIBRATION.md +0 -0
  122. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/reduce-persistent-context/SKILL.md +0 -0
  123. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/analyze_context.py +0 -0
  124. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/duplicates.py +0 -0
  125. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/measure.py +0 -0
  126. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/report.py +0 -0
  127. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/tokens.py +0 -0
  128. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/usage.py +0 -0
  129. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/select-agent-model/SKILL.md +0 -0
  130. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/send-session-message/SKILL.md +0 -0
  131. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/update-command-cache/SKILL.md +0 -0
  132. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools/skills/update-command-cache/scripts/update_command_cache.py +0 -0
  133. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools.egg-info/dependency_links.txt +0 -0
  134. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools.egg-info/entry_points.txt +0 -0
  135. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools.egg-info/requires.txt +0 -0
  136. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cc_session_tools.egg-info/top_level.txt +0 -0
  137. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/__init__.py +0 -0
  138. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/after_response.py +0 -0
  139. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/bash_hard_deny.py +0 -0
  140. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/bash_security_review.py +0 -0
  141. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/cache.py +0 -0
  142. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/catchup.py +0 -0
  143. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/confirm_8digit.py +0 -0
  144. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/context_window_warning.py +0 -0
  145. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/last_screenshot.py +0 -0
  146. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/marker_allow.py +0 -0
  147. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/markers.py +0 -0
  148. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/messaging_deliver.py +0 -0
  149. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/model_info.py +0 -0
  150. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/normalise.py +0 -0
  151. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/pending_migration.py +0 -0
  152. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/pending_rename.py +0 -0
  153. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/session_tag.py +0 -0
  154. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/stats.py +0 -0
  155. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/telemetry.py +0 -0
  156. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/telemetry_query.py +0 -0
  157. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/telemetry_trim.py +0 -0
  158. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/transcript.py +0 -0
  159. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/cccs_hooks/worklog_guard.py +0 -0
  160. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/__init__.py +0 -0
  161. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/attribution.py +0 -0
  162. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/cache.py +0 -0
  163. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/ccusage_wrapper.py +0 -0
  164. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/cli.py +0 -0
  165. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/data/pricing.json +0 -0
  166. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/parent_inference.py +0 -0
  167. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/parser.py +0 -0
  168. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/pricing.py +0 -0
  169. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/query.py +0 -0
  170. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/report.py +0 -0
  171. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/schema.py +0 -0
  172. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/src/claude_code_usage/session_names.py +0 -0
  173. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_after_response.py +0 -0
  174. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_attribution.py +0 -0
  175. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_bash_hard_deny.py +0 -0
  176. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_bash_security_review.py +0 -0
  177. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_cache.py +0 -0
  178. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_cache_sqlite.py +0 -0
  179. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccr_orphans.py +0 -0
  180. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccs_emptiness.py +0 -0
  181. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccs_eta.py +0 -0
  182. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccs_list_mode.py +0 -0
  183. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccs_scope_flags.py +0 -0
  184. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccs_sentinel_sort.py +0 -0
  185. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccs_session_counts.py +0 -0
  186. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_ccsched_jobs_cli.py +0 -0
  187. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_cli.py +0 -0
  188. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_context_override_cli.py +0 -0
  189. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_doctor.py +0 -0
  190. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_gc_report.py +0 -0
  191. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_hook_dispatcher.py +0 -0
  192. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_hooks_install_prune.py +0 -0
  193. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_hooks_install_zero_arg.py +0 -0
  194. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_hooks_uninstall.py +0 -0
  195. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_install_everything.py +0 -0
  196. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_main_gate.py +0 -0
  197. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_pdata_reconcile_cli.py +0 -0
  198. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_pdata_verify_cli.py +0 -0
  199. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_repair_sessions_cli.py +0 -0
  200. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_sessions_cli.py +0 -0
  201. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_shell_install.py +0 -0
  202. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_skills_install.py +0 -0
  203. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_skills_uninstall.py +0 -0
  204. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_telemetry_query.py +0 -0
  205. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccst_telemetry_trim.py +0 -0
  206. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_ccusage_wrapper.py +0 -0
  207. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_claude_flags.py +0 -0
  208. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_claude_md_install.py +0 -0
  209. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_cli.py +0 -0
  210. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_cli_ccd.py +0 -0
  211. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_cli_ccd_dry_run.py +0 -0
  212. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_cli_ccr.py +0 -0
  213. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_cli_ccs.py +0 -0
  214. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_cli_version.py +0 -0
  215. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_confirm_8digit.py +0 -0
  216. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_context_overrides.py +0 -0
  217. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_context_window_warning.py +0 -0
  218. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_db.py +0 -0
  219. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_debug.py +0 -0
  220. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_doctor_mutes.py +0 -0
  221. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_empty_session.py +0 -0
  222. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_hook_cache.py +0 -0
  223. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_hooks_install.py +0 -0
  224. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_install_sync.py +0 -0
  225. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_last_screenshot.py +0 -0
  226. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_levenshtein.py +0 -0
  227. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_marker_allow.py +0 -0
  228. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_migrate_ccmsg.py +0 -0
  229. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_migrate_ccsched.py +0 -0
  230. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_migrate_sessions_db.py +0 -0
  231. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_migrate_telemetry.py +0 -0
  232. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_model_info.py +0 -0
  233. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_normalise.py +0 -0
  234. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_parent_inference.py +0 -0
  235. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_parser.py +0 -0
  236. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_paths.py +0 -0
  237. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_pending_migration_hook.py +0 -0
  238. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_picker.py +0 -0
  239. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_pricing.py +0 -0
  240. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_proc_lock.py +0 -0
  241. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_prompts.py +0 -0
  242. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_query.py +0 -0
  243. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_report.py +0 -0
  244. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_roots.py +0 -0
  245. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_rules.py +0 -0
  246. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_scheduler_bundled_jobs.py +0 -0
  247. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_schema.py +0 -0
  248. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_session_names.py +0 -0
  249. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_session_tag.py +0 -0
  250. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_sessions.py +0 -0
  251. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_sessions_db.py +0 -0
  252. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_sessions_repair.py +0 -0
  253. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_tasklist.py +0 -0
  254. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_telemetry.py +0 -0
  255. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_telemetry_store.py +0 -0
  256. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/tests/test_transcript.py +0 -0
  257. {cc_session_tools-2.8.0 → cc_session_tools-2.8.2}/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.8.0
3
+ Version: 2.8.2
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.8.0"
7
+ version = "2.8.2"
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" }
@@ -137,9 +137,18 @@ def _discover_prompts_dir() -> Path:
137
137
  )
138
138
 
139
139
 
140
- def _print_migration_prompt_reminders(*labeled_filenames: tuple[str, str]) -> None:
140
+ def _print_migration_prompt_reminders(
141
+ project_root: Path, *labeled_filenames: tuple[str, str]
142
+ ) -> None:
141
143
  """Print one "<label>: <path>" reminder line per (label, filename) pair, for the bundled
142
- prompts/ directory.
144
+ prompts/ directory, followed by a line telling the user to run it in a fresh Claude Code
145
+ session started in project_root.
146
+
147
+ Each prompt's own "Run it via" section already spells out the `cd <project> && claude -p
148
+ ...` invocation (see e.g. pdata-migration-skills-update.md) - that only works if the
149
+ session's cwd is the project root, since every prompt's Step 1 checks for it and aborts
150
+ otherwise. Printing the reminder without saying so reads as "open this file", which invites
151
+ running it inline in whatever session called `ccst pdata init` - the wrong cwd entirely.
143
152
 
144
153
  Best-effort, deliberately swallowing a missing prompts/ directory: these reminders are a
145
154
  nicety layered on top of an otherwise-already-completed classification or migration, not
@@ -154,6 +163,10 @@ def _print_migration_prompt_reminders(*labeled_filenames: tuple[str, str]) -> No
154
163
  return
155
164
  for label, filename in labeled_filenames:
156
165
  print(f"{label}: {prompts_dir / filename}")
166
+ print(
167
+ f" Run in a new Claude Code session started in {project_root} (its own "
168
+ f"\"Run it via\" section has the exact command) - not in this session."
169
+ )
157
170
 
158
171
 
159
172
  # ---------- skills install ----------
@@ -875,14 +888,19 @@ def _cmd_gc_report(args: argparse.Namespace) -> int:
875
888
  # ---------- pdata ----------
876
889
 
877
890
 
878
- def _parse_field_assignment(raw: str) -> tuple[str, str]:
879
- """Parse "k=v" into (k, v). Raises ValueError on malformed input."""
891
+ def _parse_field_assignment(raw: str) -> tuple[str, str | None]:
892
+ """Parse "k=v" into (k, v). The literal token `null` (unquoted, lowercase) is interpreted
893
+ as SQL NULL rather than the four-character string "null" - this is the documented way to
894
+ clear/unset a field via --field (see check-tesco-shop-due's SKILL.md for a real caller that
895
+ relies on it). A field genuinely needing to store the text "null" cannot be written via
896
+ --field; there's no escape hatch for that case since --field has no quoting syntax at all.
897
+ Raises ValueError on malformed input."""
880
898
  if "=" not in raw:
881
899
  raise ValueError(f"malformed --field assignment (want name=value): {raw!r}")
882
900
  name, value = raw.split("=", 1)
883
901
  if not name:
884
902
  raise ValueError(f"malformed --field assignment (want name=value): {raw!r}")
885
- return name, value
903
+ return name, (None if value == "null" else value)
886
904
 
887
905
 
888
906
  def _cmd_pdata_add(args: argparse.Namespace) -> int:
@@ -1091,6 +1109,7 @@ def _cmd_pdata_init(args: argparse.Namespace) -> int:
1091
1109
  print(result.report)
1092
1110
  print(f"\nProposal: {result.proposal_path}")
1093
1111
  _print_migration_prompt_reminders(
1112
+ result.proposal_path.parent,
1094
1113
  ("After a successful --write, update project docs", "pdata-migration-claude-md-update.md"),
1095
1114
  )
1096
1115
  return 0
@@ -1129,6 +1148,7 @@ def _cmd_pdata_init(args: argparse.Namespace) -> int:
1129
1148
  print(write_result.report) # spec §7.1 step 4's diff report, for review
1130
1149
  print(f"\nVerify: ccst pdata verify --project {args.project} --full")
1131
1150
  _print_migration_prompt_reminders(
1151
+ project_root,
1132
1152
  ("Update project docs", "pdata-migration-claude-md-update.md"),
1133
1153
  ("Update consuming skills", "pdata-migration-skills-update.md"),
1134
1154
  )
@@ -1136,6 +1156,67 @@ def _cmd_pdata_init(args: argparse.Namespace) -> int:
1136
1156
  return 0
1137
1157
 
1138
1158
 
1159
+ def _cmd_pdata_reorganize(args: argparse.Namespace) -> int:
1160
+ from cc_session_tools.lib.pdata import init_paths, reorganize, write_log
1161
+
1162
+ try:
1163
+ project_root = init_paths.resolve_project_root(args.project, rehearse=None)
1164
+ except ValueError as exc:
1165
+ # Matches _cmd_pdata_init's own handling of the identical resolve_project_root() call -
1166
+ # a bad --project name (e.g. containing '/') must give the same clean exit-2 message
1167
+ # every other pdata command gives, not an uncaught traceback.
1168
+ print(f"ccst pdata: {exc}", file=sys.stderr)
1169
+ return 2
1170
+
1171
+ if not args.write:
1172
+ try:
1173
+ plan = reorganize.dry_run(
1174
+ project=args.project, project_root=project_root,
1175
+ folder=args.folder, strategy=args.strategy,
1176
+ )
1177
+ except (FileNotFoundError, ValueError) as exc:
1178
+ print(f"ccst pdata: {exc}", file=sys.stderr)
1179
+ return 2
1180
+ for move in plan.moves:
1181
+ print(f"{move.old_relative} -> {move.new_relative}")
1182
+ for matched in plan.matched_records:
1183
+ print(f" pdata record {matched.record.id} (group={matched.record.record_group}): "
1184
+ f"file_path -> {matched.new_file_path}")
1185
+ for ref in plan.external_references:
1186
+ print(f" external reference: {ref.file}:{ref.line_number}: {ref.line_text}")
1187
+ if not plan.moves:
1188
+ print(f"no files found directly under {args.folder}/")
1189
+ return 0
1190
+
1191
+ # --write can move many files over a long-running, potentially flaky I/O path - durably
1192
+ # logged the same way `ccst pdata init --write` is, under this operation's own filename so
1193
+ # the two never truncate each other's log (see reorganize.REORGANIZE_WRITE_LOG_FILENAME).
1194
+ with write_log.WriteLog(project_root, log_filename=reorganize.REORGANIZE_WRITE_LOG_FILENAME):
1195
+ try:
1196
+ result = reorganize.write(
1197
+ project=args.project, project_root=project_root,
1198
+ folder=args.folder, strategy=args.strategy,
1199
+ )
1200
+ except (FileNotFoundError, ValueError) as exc:
1201
+ print(f"ccst pdata: {exc}", file=sys.stderr)
1202
+ print(f"ERROR: {exc}")
1203
+ return 2
1204
+
1205
+ if result.failure is not None:
1206
+ print("ccst pdata reorganize: failed, rolled back:", file=sys.stderr)
1207
+ for reason in result.failure.reasons:
1208
+ print(f" - {reason}", file=sys.stderr)
1209
+ print(f"ERROR: failed, rolled back ({len(result.failure.reasons)} reason(s))")
1210
+ return 1
1211
+
1212
+ print(f"Moved {len(result.plan.moves)} file(s) under {args.folder}/")
1213
+ print(f"Backup: {result.backup_path}")
1214
+ for ref in result.plan.external_references:
1215
+ print(f" still needs manual review: {ref.file}:{ref.line_number}: {ref.line_text}")
1216
+ print("SUCCESS")
1217
+ return 0
1218
+
1219
+
1139
1220
  def _cmd_pdata_reconcile_session_output(args: argparse.Namespace) -> int:
1140
1221
  from cc_session_tools.lib import roots
1141
1222
  from cc_session_tools.lib.pdata import session_output
@@ -2038,7 +2119,8 @@ def _build_parser() -> argparse.ArgumentParser:
2038
2119
  pdata_add_parser.add_argument(
2039
2120
  "--field", action="append", default=[], metavar="NAME=VALUE",
2040
2121
  help="Extension field assignment; may repeat. Field must already be registered via "
2041
- "'ccst pdata schema add-field'.",
2122
+ "'ccst pdata schema add-field'. VALUE of the literal token 'null' sets SQL NULL "
2123
+ "rather than the string \"null\".",
2042
2124
  )
2043
2125
 
2044
2126
  pdata_schema_parser = pdata_sub.add_parser("schema", help="Schema discovery and evolution")
@@ -2107,13 +2189,19 @@ def _build_parser() -> argparse.ArgumentParser:
2107
2189
  pdata_update_parser.add_argument(
2108
2190
  "--content", default=None,
2109
2191
  help="New content. Omit to leave content unchanged (at least one of --content, "
2110
- "--file, --field is required)",
2192
+ "--file, --field is required). Never re-derived from --field - if a record's "
2193
+ "content is meant to mirror its fields, pass a matching --content on every "
2194
+ "--field update yourself.",
2111
2195
  )
2112
2196
  pdata_update_parser.add_argument(
2113
2197
  "--file", default=None, metavar="PATH",
2114
2198
  help="New relative file path. Omit to leave the existing file_path unchanged.",
2115
2199
  )
2116
- pdata_update_parser.add_argument("--field", action="append", default=[], metavar="NAME=VALUE")
2200
+ pdata_update_parser.add_argument(
2201
+ "--field", action="append", default=[], metavar="NAME=VALUE",
2202
+ help="Extension field assignment; may repeat. Never updates content (see --content). "
2203
+ "VALUE of the literal token 'null' sets SQL NULL rather than the string \"null\".",
2204
+ )
2117
2205
  pdata_update_parser.add_argument(
2118
2206
  "--format", choices=("table", "json"), default="table",
2119
2207
  help="Format used only for the conflict diff on a version mismatch",
@@ -2146,6 +2234,19 @@ def _build_parser() -> argparse.ArgumentParser:
2146
2234
  ),
2147
2235
  )
2148
2236
 
2237
+ pdata_reorganize_parser = pdata_sub.add_parser(
2238
+ "reorganize", help="Split a flat folder into a nested (by-year or by-year-month) structure"
2239
+ )
2240
+ pdata_reorganize_parser.add_argument("--project", required=True, metavar="NAME")
2241
+ pdata_reorganize_parser.add_argument("--folder", required=True, metavar="RELATIVE_PATH")
2242
+ pdata_reorganize_parser.add_argument(
2243
+ "--strategy", required=True, choices=("by-year", "by-year-month"),
2244
+ )
2245
+ pdata_reorganize_parser.add_argument(
2246
+ "--write", action="store_true",
2247
+ help="Perform the move and update matching pdata records (default: dry-run only)",
2248
+ )
2249
+
2149
2250
  pdata_reconcile_parser = pdata_sub.add_parser(
2150
2251
  "reconcile-session-output",
2151
2252
  help="Backfill the session-output index from cc-sessions/*/out/ on disk (idempotent)",
@@ -2450,6 +2551,8 @@ def main() -> None:
2450
2551
  sys.exit(_cmd_pdata_restore(args))
2451
2552
  if args.verb == "init":
2452
2553
  sys.exit(_cmd_pdata_init(args))
2554
+ if args.verb == "reorganize":
2555
+ sys.exit(_cmd_pdata_reorganize(args))
2453
2556
  if args.verb == "reconcile-session-output":
2454
2557
  sys.exit(_cmd_pdata_reconcile_session_output(args))
2455
2558
  if args.verb == "verify":
@@ -0,0 +1,367 @@
1
+ """Flat-to-nested folder restructuring for `ccst pdata reorganize` (design spec
2
+ 2026-08-21-pm-project-layout-reference-design.md, §3). Scoped to exactly one operation:
3
+ splitting a flat folder into year or year/month subfolders once it's grown past the
4
+ `pm-project-layout-reference` skill's 500-file guidance - not general-purpose reorganisation.
5
+
6
+ dry_run() never touches the filesystem or the database; write() (a later task) performs the
7
+ moves and DB updates dry_run() planned, reusing backup.create_backup() as its safety net
8
+ exactly the way `ccst pdata init --write` does.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import subprocess
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from cc_session_tools.lib.pdata import backup, init_paths, service
19
+ from cc_session_tools.lib.pdata.init_paths import PROPOSAL_FILENAME
20
+ from cc_session_tools.lib.pdata.service import Record
21
+ from cc_session_tools.lib.pdata.write_log import LOG_FILENAME as WRITE_LOG_FILENAME
22
+
23
+ _LEADING_DATE_RE = re.compile(r"^(?P<year>\d{4})\.(?P<month>\d{2})\.(?P<day>\d{2})-")
24
+
25
+ _STRATEGIES = frozenset({"by-year", "by-year-month"})
26
+
27
+ # This operation's own durable per-run log (mirrors ccst-pdata-init-write.log, but under its
28
+ # own name so a reorganize run never truncates - or gets excluded/included alongside - an
29
+ # unrelated pdata init run's log). Named here, not in write_log.py, since it's this module's
30
+ # own concern; the CLI layer imports it to pass to write_log.WriteLog(log_filename=...).
31
+ REORGANIZE_WRITE_LOG_FILENAME = "ccst-pdata-reorganize-write.log"
32
+
33
+ # Filenames excluded from the external-reference scan by name, not just by directory - all
34
+ # three persist at the project root indefinitely (classify.py excludes the first two from
35
+ # classification for the same reason) and can literally contain the file_path of every
36
+ # classified/moved entry, including files under whatever folder is being reorganized. Without
37
+ # this, a project that has ever run `ccst pdata init` or a previous `ccst pdata reorganize`
38
+ # would get a spurious "external reference" hit inside its own bookkeeping every time
39
+ # dry_run() runs.
40
+ _EXCLUDED_FILENAMES = frozenset({
41
+ PROPOSAL_FILENAME, WRITE_LOG_FILENAME, REORGANIZE_WRITE_LOG_FILENAME,
42
+ })
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Move:
47
+ old_relative: str
48
+ new_relative: str
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class ExternalReference:
53
+ file: Path
54
+ line_number: int
55
+ line_text: str
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class MatchedRecord:
60
+ record: Record
61
+ new_file_path: str
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class ReorganizePlan:
66
+ project: str
67
+ project_root: Path
68
+ folder: str
69
+ strategy: str
70
+ moves: list[Move]
71
+ matched_records: list[MatchedRecord]
72
+ external_references: list[ExternalReference]
73
+
74
+
75
+ def _year_and_month_for(path: Path) -> tuple[str, str]:
76
+ """(year, month) for path - from a leading YYYY.MM.DD- date in the filename if present
77
+ (this codebase's own naming convention, e.g. archive-correspondence's output), else the
78
+ file's mtime. Shared by _year_for/_year_month_for so the date-vs-mtime decision and the
79
+ regex match happen exactly once, not twice with the same logic duplicated between them.
80
+
81
+ No validation that a regex-matched month is actually 01-12 (or day 01-31) - a malformed
82
+ but shape-matching date in a filename (e.g. "2025.13.99-foo.md") is accepted at face value.
83
+ Low-risk for a dry-run/planning tool whose output is reviewed before any --write, but worth
84
+ knowing if this is ever reused somewhere the plan isn't reviewed first."""
85
+ match = _LEADING_DATE_RE.match(path.name)
86
+ if match:
87
+ return match.group("year"), match.group("month")
88
+ localtime = time.localtime(path.stat().st_mtime)
89
+ return time.strftime("%Y", localtime), time.strftime("%m", localtime)
90
+
91
+
92
+ def _year_for(path: Path) -> str:
93
+ year, _month = _year_and_month_for(path)
94
+ return year
95
+
96
+
97
+ def _year_month_for(path: Path) -> str:
98
+ year, month = _year_and_month_for(path)
99
+ return f"{year}/{month}"
100
+
101
+
102
+ def _new_relative(folder: str, entry: Path, strategy: str) -> str:
103
+ subdir = _year_for(entry) if strategy == "by-year" else _year_month_for(entry)
104
+ return f"{folder}/{subdir}/{entry.name}"
105
+
106
+
107
+ def _scan_external_references(
108
+ *, project_root: Path, folder: str, old_relatives: list[str],
109
+ ) -> list[ExternalReference]:
110
+ """Grep every text file in project_root (excluding folder itself and the usual
111
+ ccst/git bookkeeping dirs) for a literal occurrence of any old_relatives entry - reported
112
+ only, never edited (design spec §3: rewriting prose isn't safe to automate).
113
+
114
+ folder is excluded by path-prefix comparison, not single-component name matching - folder
115
+ can itself be multi-segment (e.g. "workstreams/ws-01"), which a plain
116
+ `part in excluded_names` check would never match against any single path component.
117
+
118
+ Reuses init_paths.EXCLUDED_DIR_NAMES - the same set classify.py's own directory walk
119
+ already excludes - rather than hardcoding a second, incomplete copy: a hardcoded
120
+ ".pdata-migrated" literal here would also miss REHEARSAL_DB_DIRNAME/
121
+ REHEARSAL_BACKUP_DIRNAME, walking into a rehearsal sandbox's own contents on any project
122
+ that has ever used --rehearse and reporting spurious matches from inside it."""
123
+ excluded_dir_names = init_paths.EXCLUDED_DIR_NAMES
124
+ folder_parts = Path(folder).parts
125
+ refs: list[ExternalReference] = []
126
+ for candidate in sorted(project_root.rglob("*")):
127
+ if not candidate.is_file():
128
+ continue
129
+ rel_parts = candidate.relative_to(project_root).parts
130
+ if rel_parts[:len(folder_parts)] == folder_parts:
131
+ continue # inside the folder being reorganized itself
132
+ if any(part in excluded_dir_names for part in rel_parts[:-1]):
133
+ continue
134
+ if candidate.name in _EXCLUDED_FILENAMES:
135
+ continue # ccst's own bookkeeping - see _EXCLUDED_FILENAMES's comment
136
+ try:
137
+ text = candidate.read_text(encoding="utf-8")
138
+ except (UnicodeDecodeError, OSError):
139
+ continue
140
+ for line_number, line in enumerate(text.splitlines(), start=1):
141
+ for old_relative in old_relatives:
142
+ if old_relative in line:
143
+ refs.append(ExternalReference(
144
+ file=candidate, line_number=line_number, line_text=line.strip(),
145
+ ))
146
+ return refs
147
+
148
+
149
+ def _validate_relative_folder(folder: str) -> None:
150
+ """A separate check from (not a call to) service._validate_relative_file_path - similar in
151
+ spirit, but folder has two failure modes file_path doesn't need to guard against, since
152
+ file_path is never joined with mkdir() to build a new directory tree the way folder is here:
153
+
154
+ - An absolute path silently discards project_root entirely when joined with `/` (pathlib's
155
+ own behaviour, not a bug in this code - `Path("/a") / "/etc"` is `Path("/etc")`), and a
156
+ '..' segment can escape project_root the same way a crafted --file could.
157
+ - An EMPTY folder ("") makes old_relative/new_relative leading-slash strings
158
+ (f"{folder}/{name}" -> "/name"), which then trigger the exact same absolute-path escape
159
+ above once project_root / new_relative is evaluated - so empty must be rejected as its
160
+ own case, before the absolute-path check ever sees it (an empty string doesn't start with
161
+ "/", so that check alone wouldn't catch it). A trailing or doubled slash
162
+ ("correspondence/", "a//b") produces a doubled-slash old_relative/new_relative that no
163
+ longer matches the single-slash file_path values pdata records and project docs actually
164
+ use - write() would then silently update zero matching records and report success while
165
+ every affected record's file_path goes stale, instead of erroring loudly."""
166
+ if folder.startswith("/"):
167
+ raise ValueError(f"--folder must be relative to the project root, got absolute path: {folder!r}")
168
+ segments = folder.split("/")
169
+ if not folder or any(not segment for segment in segments):
170
+ raise ValueError(
171
+ f"--folder must not be empty or contain empty/trailing path segments: {folder!r}"
172
+ )
173
+ if any(segment == ".." for segment in segments):
174
+ raise ValueError(f"--folder must not contain '..' path-traversal segments: {folder!r}")
175
+
176
+
177
+ def dry_run(*, project: str, project_root: Path, folder: str, strategy: str) -> ReorganizePlan:
178
+ if strategy not in _STRATEGIES:
179
+ raise ValueError(f"unknown strategy {strategy!r} - must be one of {sorted(_STRATEGIES)}")
180
+ _validate_relative_folder(folder)
181
+ folder_path = project_root / folder
182
+ if not folder_path.is_dir():
183
+ raise FileNotFoundError(f"no such folder to reorganize: {folder_path}")
184
+
185
+ entries = sorted(p for p in folder_path.iterdir() if p.is_file())
186
+ moves = [
187
+ Move(
188
+ old_relative=f"{folder}/{entry.name}",
189
+ new_relative=_new_relative(folder, entry, strategy),
190
+ )
191
+ for entry in entries
192
+ ]
193
+
194
+ matched = service.find_records_by_file_path_prefix(project=project, prefix=f"{folder}/")
195
+ move_by_old = {m.old_relative: m.new_relative for m in moves}
196
+ matched_records = [
197
+ MatchedRecord(record=record, new_file_path=move_by_old[record.file_path])
198
+ for record in matched
199
+ if record.file_path is not None and record.file_path in move_by_old
200
+ ]
201
+
202
+ external_references = _scan_external_references(
203
+ project_root=project_root, folder=folder,
204
+ old_relatives=[m.old_relative for m in moves],
205
+ )
206
+
207
+ return ReorganizePlan(
208
+ project=project, project_root=project_root, folder=folder, strategy=strategy,
209
+ moves=moves, matched_records=matched_records, external_references=external_references,
210
+ )
211
+
212
+
213
+ @dataclass(frozen=True, slots=True)
214
+ class ReorganizeFailure:
215
+ reasons: list[str]
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class ReorganizeResult:
220
+ plan: ReorganizePlan
221
+ backup_path: Path | None
222
+ failure: ReorganizeFailure | None
223
+
224
+
225
+ def _is_git_repo(project_root: Path) -> bool:
226
+ return (project_root / ".git").exists()
227
+
228
+
229
+ def _remove_empty_dirs_up_to(*, start: Path, boundary: Path) -> None:
230
+ """Remove start and each successive empty parent directory, stopping at (never removing)
231
+ boundary itself. Shared by _move_file's own failure cleanup and _move_file_back's rollback
232
+ cleanup - both need to walk back up through however many nesting levels by-year-month left
233
+ (two - correspondence/2025/06/) without ever deleting the folder being reorganized."""
234
+ current = start
235
+ while current != boundary and current.is_dir() and not any(current.iterdir()):
236
+ current.rmdir()
237
+ current = current.parent
238
+
239
+
240
+ def _first_missing_ancestor(*, start: Path, boundary: Path) -> Path | None:
241
+ """Walking from start up towards (not including) boundary, the shallowest directory that
242
+ does not yet exist - exactly the topmost directory `start.mkdir(parents=True)` would
243
+ actually create. None if start already exists (mkdir would be a complete no-op).
244
+
245
+ Used so _move_file's failure-cleanup only ever removes directories its own mkdir call
246
+ genuinely created, never one that already existed before it - e.g. left over from an
247
+ earlier partial/crashed run, or present for an unrelated reason. Without this, cleanup
248
+ would delete any empty directory sitting at the destination path regardless of whether
249
+ this call was the one that created it."""
250
+ if start.exists():
251
+ return None
252
+ missing = start
253
+ while missing.parent != boundary and not missing.parent.exists():
254
+ missing = missing.parent
255
+ return missing
256
+
257
+
258
+ def _move_file(*, project_root: Path, move: Move, folder: str, use_git: bool) -> None:
259
+ src = project_root / move.old_relative
260
+ dest = project_root / move.new_relative
261
+ folder_path = project_root / folder
262
+ newly_created_root = _first_missing_ancestor(start=dest.parent, boundary=folder_path)
263
+ dest.parent.mkdir(parents=True, exist_ok=True)
264
+ try:
265
+ if use_git:
266
+ subprocess.run(
267
+ ["git", "mv", move.old_relative, move.new_relative],
268
+ cwd=project_root, check=True, capture_output=True, text=True,
269
+ )
270
+ else:
271
+ src.rename(dest)
272
+ except (OSError, subprocess.CalledProcessError):
273
+ # The mkdir above may have just created dest.parent (and its own new parents, for
274
+ # by-year-month) - if the move itself then failed, that directory was never populated
275
+ # and would otherwise be left behind as debris: write()'s own rollback only knows
276
+ # about *completed* moves (this one was never added to `moved`), so nothing else
277
+ # would ever clean it up. newly_created_root is None when dest.parent already existed
278
+ # before this call (nothing to clean up - it wasn't ours to remove).
279
+ if newly_created_root is not None:
280
+ _remove_empty_dirs_up_to(start=dest.parent, boundary=newly_created_root.parent)
281
+ raise
282
+
283
+
284
+ def _move_file_back(*, project_root: Path, move: Move, folder: str, use_git: bool) -> None:
285
+ """Undo _move_file - used by write()'s rollback path only."""
286
+ dest_parent = (project_root / move.new_relative).parent
287
+ if use_git:
288
+ subprocess.run(
289
+ ["git", "mv", move.new_relative, move.old_relative],
290
+ cwd=project_root, check=True, capture_output=True, text=True,
291
+ )
292
+ else:
293
+ (project_root / move.new_relative).rename(project_root / move.old_relative)
294
+ _remove_empty_dirs_up_to(start=dest_parent, boundary=project_root / folder)
295
+
296
+
297
+ def write(*, project: str, project_root: Path, folder: str, strategy: str) -> ReorganizeResult:
298
+ plan = dry_run(project=project, project_root=project_root, folder=folder, strategy=strategy)
299
+ use_git = _is_git_repo(project_root)
300
+
301
+ # Matches init_service.write()'s own handling of this exact call (init_service.py
302
+ # ~lines 220-232): create_backup() can exhaust its retries and raise BackupError, which
303
+ # must become a structured failure here too, not an uncaught crash before anything has
304
+ # been moved.
305
+ try:
306
+ backup_path = backup.create_backup(project=project, project_root=project_root)
307
+ except backup.BackupError as exc:
308
+ return ReorganizeResult(plan=plan, backup_path=None, failure=ReorganizeFailure(reasons=[str(exc)]))
309
+
310
+ moved: list[Move] = []
311
+ # (matched, post-update Record incl. its bumped version) for every update_record() call
312
+ # that succeeded so far - needed to reverse them in the right order on failure, each with
313
+ # the expected_version its OWN update actually left the row at, not its pre-update version.
314
+ updated: list[tuple[MatchedRecord, Record]] = []
315
+ try:
316
+ for move in plan.moves:
317
+ _move_file(project_root=project_root, move=move, folder=folder, use_git=use_git)
318
+ moved.append(move)
319
+
320
+ for matched in plan.matched_records:
321
+ updated_record = service.update_record(
322
+ project=project, record_id=matched.record.id,
323
+ expected_version=matched.record.version,
324
+ content=None, file_path=matched.new_file_path, fields={},
325
+ )
326
+ updated.append((matched, updated_record))
327
+ except (OSError, subprocess.CalledProcessError, service.VersionConflictError,
328
+ service.RecordNotFoundError) as exc:
329
+ reasons = [str(exc)]
330
+ # Roll back each moved file and its matching DB update (if any) as one paired unit,
331
+ # in reverse order - move the file back FIRST, then revert its record, so the two
332
+ # can never end up disagreeing about where the file actually is:
333
+ # - if the move-back fails, the record is left un-reverted too (still pointing at
334
+ # the new path) - consistent, since the file is still physically there.
335
+ # - if the move-back succeeds but the reversal update_record() call then fails,
336
+ # that's flagged explicitly rather than silently diverging - matched.record is
337
+ # filtered upstream (dry_run) to always have a non-None file_path, so the
338
+ # reversal always restores a real prior value, never a no-op None.
339
+ # Each step is wrapped individually, matching init_service._rollback()'s own contract:
340
+ # an unwrapped raise here would abort the rest of the rollback (and this whole
341
+ # function) instead of returning a ReorganizeResult at all.
342
+ updated_by_new_path = {m.new_file_path: (m, r) for m, r in updated}
343
+ for move in reversed(moved):
344
+ pair = updated_by_new_path.get(move.new_relative)
345
+ try:
346
+ _move_file_back(project_root=project_root, move=move, folder=folder, use_git=use_git)
347
+ except (OSError, subprocess.CalledProcessError) as rollback_exc:
348
+ reasons.append(f"{move.new_relative}: rollback failed: {rollback_exc}")
349
+ continue # file never moved back - its record (if any) must keep the new path
350
+ if pair is None:
351
+ continue
352
+ matched, updated_record = pair
353
+ try:
354
+ service.update_record(
355
+ project=project, record_id=matched.record.id,
356
+ expected_version=updated_record.version,
357
+ content=None, file_path=matched.record.file_path, fields={},
358
+ )
359
+ except (service.VersionConflictError, service.RecordNotFoundError) as rollback_exc:
360
+ reasons.append(f"record {matched.record.id}: rollback failed: {rollback_exc}")
361
+
362
+ return ReorganizeResult(
363
+ plan=plan, backup_path=backup_path,
364
+ failure=ReorganizeFailure(reasons=reasons),
365
+ )
366
+
367
+ return ReorganizeResult(plan=plan, backup_path=backup_path, failure=None)