cc-session-tools 2.7.2__tar.gz → 2.8.1__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 (256) hide show
  1. {cc_session_tools-2.7.2/src/cc_session_tools.egg-info → cc_session_tools-2.8.1}/PKG-INFO +1 -1
  2. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/pyproject.toml +3 -3
  3. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccst.py +117 -0
  4. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/init_paths.py +1 -1
  5. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/init_service.py +1 -1
  6. cc_session_tools-2.8.1/src/cc_session_tools/lib/pdata/reorganize.py +367 -0
  7. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/service.py +60 -1
  8. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/write_log.py +16 -11
  9. cc_session_tools-2.8.1/src/cc_session_tools/prompts/pdata-migration-claude-md-update.md +125 -0
  10. cc_session_tools-2.8.1/src/cc_session_tools/prompts/pdata-migration-skills-update.md +128 -0
  11. cc_session_tools-2.8.1/src/cc_session_tools/skills/pm-project-layout-reference/SKILL.md +83 -0
  12. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1/src/cc_session_tools.egg-info}/PKG-INFO +1 -1
  13. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/SOURCES.txt +5 -0
  14. cc_session_tools-2.8.1/tests/test_ccst_bundle_discovery.py +98 -0
  15. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_pdata_init_cli.py +12 -0
  16. cc_session_tools-2.8.1/tests/test_ccst_pdata_reorganize_cli.py +175 -0
  17. cc_session_tools-2.7.2/tests/test_ccst_bundle_discovery.py +0 -52
  18. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/LICENSE +0 -0
  19. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/README.md +0 -0
  20. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/setup.cfg +0 -0
  21. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/__init__.py +0 -0
  22. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/__init__.py +0 -0
  23. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccd.py +0 -0
  24. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccmsg.py +0 -0
  25. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccr.py +0 -0
  26. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccs.py +0 -0
  27. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccsched.py +0 -0
  28. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/migrate_ccmsg.py +0 -0
  29. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/migrate_ccsched.py +0 -0
  30. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/migrate_sessions_db.py +0 -0
  31. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/migrate_telemetry.py +0 -0
  32. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/config/hooks-bundle.json +0 -0
  33. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/config/session-tag-hook.json +0 -0
  34. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/hooks_install.py +0 -0
  35. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/__init__.py +0 -0
  36. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/claude_flags.py +0 -0
  37. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/claude_md_install.py +0 -0
  38. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/context_overrides.py +0 -0
  39. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/db.py +0 -0
  40. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/debug.py +0 -0
  41. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/doctor.py +0 -0
  42. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/doctor_mutes.py +0 -0
  43. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/hook_registry.py +0 -0
  44. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/install_sync.py +0 -0
  45. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/levenshtein.py +0 -0
  46. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/__init__.py +0 -0
  47. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/addressing.py +0 -0
  48. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/cursor.py +0 -0
  49. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/lock.py +0 -0
  50. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/message.py +0 -0
  51. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/move_safety.py +0 -0
  52. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/repository.py +0 -0
  53. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/retention.py +0 -0
  54. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/service.py +0 -0
  55. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/store.py +0 -0
  56. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/paths.py +0 -0
  57. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/__init__.py +0 -0
  58. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/backup.py +0 -0
  59. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/classify.py +0 -0
  60. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/cutover.py +0 -0
  61. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/formatting.py +0 -0
  62. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/importers.py +0 -0
  63. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/manifest.py +0 -0
  64. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/naming.py +0 -0
  65. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/repository.py +0 -0
  66. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/session_output.py +0 -0
  67. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/store.py +0 -0
  68. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/verify.py +0 -0
  69. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/picker.py +0 -0
  70. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/proc_lock.py +0 -0
  71. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/prompts.py +0 -0
  72. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/roots.py +0 -0
  73. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/rules.py +0 -0
  74. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/__init__.py +0 -0
  75. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/bundled_jobs.py +0 -0
  76. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/cadence.py +0 -0
  77. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/cursor.py +0 -0
  78. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/digest.py +0 -0
  79. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/due.py +0 -0
  80. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/duration.py +0 -0
  81. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/jobspec.py +0 -0
  82. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/ledger.py +0 -0
  83. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/lock.py +0 -0
  84. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/notify.py +0 -0
  85. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/reconcile.py +0 -0
  86. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/registry.py +0 -0
  87. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/runner.py +0 -0
  88. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/state.py +0 -0
  89. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/store.py +0 -0
  90. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/surface.py +0 -0
  91. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/throttle.py +0 -0
  92. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/worker.py +0 -0
  93. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/session_gc.py +0 -0
  94. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/sessions.py +0 -0
  95. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/sessions_db.py +0 -0
  96. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/sessions_repair.py +0 -0
  97. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/shell_install.py +0 -0
  98. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/tasklist.py +0 -0
  99. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/telemetry_store.py +0 -0
  100. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/analyse-cc-usage/SKILL.md +0 -0
  101. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/context-override/SKILL.md +0 -0
  102. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/delete-sessions/SKILL.md +0 -0
  103. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/delete-sessions/scripts/delete_sessions.py +0 -0
  104. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/do-executor-critic-assessor-loop/SKILL.md +0 -0
  105. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/find-claude-code-session/SKILL.md +0 -0
  106. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/generate-8digit-code/SKILL.md +0 -0
  107. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/generate-8digit-code/scripts/generate_8digit_code.py +0 -0
  108. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/list-empty-sessions/SKILL.md +0 -0
  109. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/list-empty-sessions/scripts/list_empty_sessions.py +0 -0
  110. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/manage-recurring-cc-jobs-using-ccsched/SKILL.md +0 -0
  111. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/move-session/SKILL.md +0 -0
  112. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/move-session/hooks/sessionstart-pending-rename.sh +0 -0
  113. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/move-session/scripts/cc_session_rules.py +0 -0
  114. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/move-session/scripts/move_session.py +0 -0
  115. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/pm-pdata-conflict-resolution/SKILL.md +0 -0
  116. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/pm-pdata-schema-design/SKILL.md +0 -0
  117. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/pm-project-init/SKILL.md +0 -0
  118. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/pm-update-central-files/SKILL.md +0 -0
  119. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/CALIBRATION.md +0 -0
  120. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/SKILL.md +0 -0
  121. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/analyze_context.py +0 -0
  122. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/duplicates.py +0 -0
  123. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/measure.py +0 -0
  124. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/report.py +0 -0
  125. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/tokens.py +0 -0
  126. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/usage.py +0 -0
  127. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/select-agent-model/SKILL.md +0 -0
  128. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/send-session-message/SKILL.md +0 -0
  129. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/update-command-cache/SKILL.md +0 -0
  130. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/update-command-cache/scripts/update_command_cache.py +0 -0
  131. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/dependency_links.txt +0 -0
  132. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/entry_points.txt +0 -0
  133. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/requires.txt +0 -0
  134. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/top_level.txt +0 -0
  135. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/__init__.py +0 -0
  136. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/after_response.py +0 -0
  137. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/bash_hard_deny.py +0 -0
  138. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/bash_security_review.py +0 -0
  139. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/cache.py +0 -0
  140. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/catchup.py +0 -0
  141. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/confirm_8digit.py +0 -0
  142. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/context_window_warning.py +0 -0
  143. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/last_screenshot.py +0 -0
  144. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/marker_allow.py +0 -0
  145. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/markers.py +0 -0
  146. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/messaging_deliver.py +0 -0
  147. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/model_info.py +0 -0
  148. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/normalise.py +0 -0
  149. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/pending_migration.py +0 -0
  150. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/pending_rename.py +0 -0
  151. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/session_tag.py +0 -0
  152. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/stats.py +0 -0
  153. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/telemetry.py +0 -0
  154. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/telemetry_query.py +0 -0
  155. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/telemetry_trim.py +0 -0
  156. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/transcript.py +0 -0
  157. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/worklog_guard.py +0 -0
  158. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/__init__.py +0 -0
  159. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/attribution.py +0 -0
  160. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/cache.py +0 -0
  161. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/ccusage_wrapper.py +0 -0
  162. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/cli.py +0 -0
  163. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/data/pricing.json +0 -0
  164. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/parent_inference.py +0 -0
  165. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/parser.py +0 -0
  166. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/pricing.py +0 -0
  167. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/query.py +0 -0
  168. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/report.py +0 -0
  169. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/schema.py +0 -0
  170. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/session_names.py +0 -0
  171. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_after_response.py +0 -0
  172. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_attribution.py +0 -0
  173. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_bash_hard_deny.py +0 -0
  174. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_bash_security_review.py +0 -0
  175. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cache.py +0 -0
  176. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cache_sqlite.py +0 -0
  177. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccr_orphans.py +0 -0
  178. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_emptiness.py +0 -0
  179. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_eta.py +0 -0
  180. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_list_mode.py +0 -0
  181. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_scope_flags.py +0 -0
  182. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_sentinel_sort.py +0 -0
  183. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_session_counts.py +0 -0
  184. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_ccsched_jobs_cli.py +0 -0
  185. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_cli.py +0 -0
  186. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_context_override_cli.py +0 -0
  187. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_doctor.py +0 -0
  188. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_gc_report.py +0 -0
  189. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_hook_dispatcher.py +0 -0
  190. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_hooks_install_prune.py +0 -0
  191. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_hooks_install_zero_arg.py +0 -0
  192. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_hooks_uninstall.py +0 -0
  193. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_install_everything.py +0 -0
  194. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_main_gate.py +0 -0
  195. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_pdata_cli.py +0 -0
  196. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_pdata_reconcile_cli.py +0 -0
  197. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_pdata_verify_cli.py +0 -0
  198. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_repair_sessions_cli.py +0 -0
  199. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_sessions_cli.py +0 -0
  200. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_shell_install.py +0 -0
  201. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_skills_install.py +0 -0
  202. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_skills_uninstall.py +0 -0
  203. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_telemetry_query.py +0 -0
  204. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_telemetry_trim.py +0 -0
  205. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccusage_wrapper.py +0 -0
  206. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_claude_flags.py +0 -0
  207. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_claude_md_install.py +0 -0
  208. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli.py +0 -0
  209. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_ccd.py +0 -0
  210. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_ccd_dry_run.py +0 -0
  211. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_ccr.py +0 -0
  212. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_ccs.py +0 -0
  213. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_version.py +0 -0
  214. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_confirm_8digit.py +0 -0
  215. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_context_overrides.py +0 -0
  216. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_context_window_warning.py +0 -0
  217. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_db.py +0 -0
  218. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_debug.py +0 -0
  219. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_doctor_mutes.py +0 -0
  220. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_empty_session.py +0 -0
  221. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_hook_cache.py +0 -0
  222. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_hooks_install.py +0 -0
  223. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_install_sync.py +0 -0
  224. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_last_screenshot.py +0 -0
  225. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_levenshtein.py +0 -0
  226. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_marker_allow.py +0 -0
  227. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_migrate_ccmsg.py +0 -0
  228. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_migrate_ccsched.py +0 -0
  229. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_migrate_sessions_db.py +0 -0
  230. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_migrate_telemetry.py +0 -0
  231. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_model_info.py +0 -0
  232. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_normalise.py +0 -0
  233. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_parent_inference.py +0 -0
  234. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_parser.py +0 -0
  235. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_paths.py +0 -0
  236. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_pending_migration_hook.py +0 -0
  237. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_picker.py +0 -0
  238. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_pricing.py +0 -0
  239. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_proc_lock.py +0 -0
  240. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_prompts.py +0 -0
  241. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_query.py +0 -0
  242. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_report.py +0 -0
  243. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_roots.py +0 -0
  244. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_rules.py +0 -0
  245. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_scheduler_bundled_jobs.py +0 -0
  246. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_schema.py +0 -0
  247. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_session_names.py +0 -0
  248. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_session_tag.py +0 -0
  249. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_sessions.py +0 -0
  250. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_sessions_db.py +0 -0
  251. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_sessions_repair.py +0 -0
  252. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_tasklist.py +0 -0
  253. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_telemetry.py +0 -0
  254. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_telemetry_store.py +0 -0
  255. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_transcript.py +0 -0
  256. {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/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.7.2
3
+ Version: 2.8.1
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.7.2"
7
+ version = "2.8.1"
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" }
@@ -62,11 +62,11 @@ where = ["src"]
62
62
  # only filters within the file's *own* discovered package, and a wrongly
63
63
  # auto-discovered "cc_session_tools.skills.<name>.tests" package was never
64
64
  # covered by an exclude-package-data entry keyed just "cc_session_tools").
65
- exclude = ["cc_session_tools.skills", "cc_session_tools.skills.*", "cc_session_tools.config", "cc_session_tools.config.*"]
65
+ exclude = ["cc_session_tools.skills", "cc_session_tools.skills.*", "cc_session_tools.config", "cc_session_tools.config.*", "cc_session_tools.prompts", "cc_session_tools.prompts.*"]
66
66
 
67
67
  [tool.setuptools.package-data]
68
68
  claude_code_usage = ["data/*.json"]
69
- cc_session_tools = ["skills/**/*", "config/*"]
69
+ cc_session_tools = ["skills/**/*", "config/*", "prompts/**/*"]
70
70
 
71
71
  [tool.setuptools.exclude-package-data]
72
72
  cc_session_tools = ["**/tests/*", "**/__pycache__/*"]
@@ -122,6 +122,40 @@ def _discover_bundle() -> Path:
122
122
  )
123
123
 
124
124
 
125
+ def _discover_prompts_dir() -> Path:
126
+ """Return the bundled prompts/ directory (pdata-migration follow-up prompts).
127
+
128
+ prompts/ is packaged inside cc_session_tools the same way skills/ and config/ are (see
129
+ _discover_source_dir) - src/cc_session_tools/cli/ccst.py -> ../prompts. No install-location
130
+ fallback, same reasoning as the other two: a fallback here would just mask a broken install.
131
+ """
132
+ candidate = Path(__file__).resolve().parent.parent / "prompts"
133
+ if candidate.is_dir():
134
+ return candidate
135
+ raise FileNotFoundError(
136
+ "Cannot locate bundled prompts/ directory. Run from the source tree or reinstall ccst."
137
+ )
138
+
139
+
140
+ def _print_migration_prompt_reminders(*labeled_filenames: tuple[str, str]) -> None:
141
+ """Print one "<label>: <path>" reminder line per (label, filename) pair, for the bundled
142
+ prompts/ directory.
143
+
144
+ Best-effort, deliberately swallowing a missing prompts/ directory: these reminders are a
145
+ nicety layered on top of an otherwise-already-completed classification or migration, not
146
+ the operation itself — a broken/partial install must never turn a successful --write (rows
147
+ already written, files already cut over) or dry-run (proposal already written) into a
148
+ reported failure just because this follow-up reminder couldn't be printed.
149
+ """
150
+ try:
151
+ prompts_dir = _discover_prompts_dir()
152
+ except FileNotFoundError as exc:
153
+ print(f"({exc})", file=sys.stderr)
154
+ return
155
+ for label, filename in labeled_filenames:
156
+ print(f"{label}: {prompts_dir / filename}")
157
+
158
+
125
159
  # ---------- skills install ----------
126
160
 
127
161
 
@@ -1056,6 +1090,9 @@ def _cmd_pdata_init(args: argparse.Namespace) -> int:
1056
1090
  return 2
1057
1091
  print(result.report)
1058
1092
  print(f"\nProposal: {result.proposal_path}")
1093
+ _print_migration_prompt_reminders(
1094
+ ("After a successful --write, update project docs", "pdata-migration-claude-md-update.md"),
1095
+ )
1059
1096
  return 0
1060
1097
 
1061
1098
  try:
@@ -1091,6 +1128,71 @@ def _cmd_pdata_init(args: argparse.Namespace) -> int:
1091
1128
  print()
1092
1129
  print(write_result.report) # spec §7.1 step 4's diff report, for review
1093
1130
  print(f"\nVerify: ccst pdata verify --project {args.project} --full")
1131
+ _print_migration_prompt_reminders(
1132
+ ("Update project docs", "pdata-migration-claude-md-update.md"),
1133
+ ("Update consuming skills", "pdata-migration-skills-update.md"),
1134
+ )
1135
+ print("SUCCESS")
1136
+ return 0
1137
+
1138
+
1139
+ def _cmd_pdata_reorganize(args: argparse.Namespace) -> int:
1140
+ from cc_session_tools.lib.pdata import init_paths, reorganize, write_log
1141
+
1142
+ try:
1143
+ project_root = init_paths.resolve_project_root(args.project, rehearse=None)
1144
+ except ValueError as exc:
1145
+ # Matches _cmd_pdata_init's own handling of the identical resolve_project_root() call -
1146
+ # a bad --project name (e.g. containing '/') must give the same clean exit-2 message
1147
+ # every other pdata command gives, not an uncaught traceback.
1148
+ print(f"ccst pdata: {exc}", file=sys.stderr)
1149
+ return 2
1150
+
1151
+ if not args.write:
1152
+ try:
1153
+ plan = reorganize.dry_run(
1154
+ project=args.project, project_root=project_root,
1155
+ folder=args.folder, strategy=args.strategy,
1156
+ )
1157
+ except (FileNotFoundError, ValueError) as exc:
1158
+ print(f"ccst pdata: {exc}", file=sys.stderr)
1159
+ return 2
1160
+ for move in plan.moves:
1161
+ print(f"{move.old_relative} -> {move.new_relative}")
1162
+ for matched in plan.matched_records:
1163
+ print(f" pdata record {matched.record.id} (group={matched.record.record_group}): "
1164
+ f"file_path -> {matched.new_file_path}")
1165
+ for ref in plan.external_references:
1166
+ print(f" external reference: {ref.file}:{ref.line_number}: {ref.line_text}")
1167
+ if not plan.moves:
1168
+ print(f"no files found directly under {args.folder}/")
1169
+ return 0
1170
+
1171
+ # --write can move many files over a long-running, potentially flaky I/O path - durably
1172
+ # logged the same way `ccst pdata init --write` is, under this operation's own filename so
1173
+ # the two never truncate each other's log (see reorganize.REORGANIZE_WRITE_LOG_FILENAME).
1174
+ with write_log.WriteLog(project_root, log_filename=reorganize.REORGANIZE_WRITE_LOG_FILENAME):
1175
+ try:
1176
+ result = reorganize.write(
1177
+ project=args.project, project_root=project_root,
1178
+ folder=args.folder, strategy=args.strategy,
1179
+ )
1180
+ except (FileNotFoundError, ValueError) as exc:
1181
+ print(f"ccst pdata: {exc}", file=sys.stderr)
1182
+ print(f"ERROR: {exc}")
1183
+ return 2
1184
+
1185
+ if result.failure is not None:
1186
+ print("ccst pdata reorganize: failed, rolled back:", file=sys.stderr)
1187
+ for reason in result.failure.reasons:
1188
+ print(f" - {reason}", file=sys.stderr)
1189
+ print(f"ERROR: failed, rolled back ({len(result.failure.reasons)} reason(s))")
1190
+ return 1
1191
+
1192
+ print(f"Moved {len(result.plan.moves)} file(s) under {args.folder}/")
1193
+ print(f"Backup: {result.backup_path}")
1194
+ for ref in result.plan.external_references:
1195
+ print(f" still needs manual review: {ref.file}:{ref.line_number}: {ref.line_text}")
1094
1196
  print("SUCCESS")
1095
1197
  return 0
1096
1198
 
@@ -2105,6 +2207,19 @@ def _build_parser() -> argparse.ArgumentParser:
2105
2207
  ),
2106
2208
  )
2107
2209
 
2210
+ pdata_reorganize_parser = pdata_sub.add_parser(
2211
+ "reorganize", help="Split a flat folder into a nested (by-year or by-year-month) structure"
2212
+ )
2213
+ pdata_reorganize_parser.add_argument("--project", required=True, metavar="NAME")
2214
+ pdata_reorganize_parser.add_argument("--folder", required=True, metavar="RELATIVE_PATH")
2215
+ pdata_reorganize_parser.add_argument(
2216
+ "--strategy", required=True, choices=("by-year", "by-year-month"),
2217
+ )
2218
+ pdata_reorganize_parser.add_argument(
2219
+ "--write", action="store_true",
2220
+ help="Perform the move and update matching pdata records (default: dry-run only)",
2221
+ )
2222
+
2108
2223
  pdata_reconcile_parser = pdata_sub.add_parser(
2109
2224
  "reconcile-session-output",
2110
2225
  help="Backfill the session-output index from cc-sessions/*/out/ on disk (idempotent)",
@@ -2409,6 +2524,8 @@ def main() -> None:
2409
2524
  sys.exit(_cmd_pdata_restore(args))
2410
2525
  if args.verb == "init":
2411
2526
  sys.exit(_cmd_pdata_init(args))
2527
+ if args.verb == "reorganize":
2528
+ sys.exit(_cmd_pdata_reorganize(args))
2412
2529
  if args.verb == "reconcile-session-output":
2413
2530
  sys.exit(_cmd_pdata_reconcile_session_output(args))
2414
2531
  if args.verb == "verify":
@@ -76,7 +76,7 @@ def backup_dir_override(rehearse: Path | None) -> Iterator[None]:
76
76
  whatever CCST_PDATA_BACKUP_DIR is already set to), by reusing this plan's own
77
77
  backup.BACKUP_DIR_ENV seam — mirrors project_db_dir_override's shape exactly.
78
78
  Without this, a rehearsed --write would still deposit a
79
- <project>-<epoch>.tar.gz into the same directory a genuine production
79
+ <project>-<YYYYMMDD-HHMMSS>.tar.gz into the same directory a genuine production
80
80
  migration's backup would use, indistinguishable by filename. No-op when
81
81
  rehearse is None."""
82
82
  if rehearse is None:
@@ -164,7 +164,7 @@ def write(
164
164
  # A's CCST_PROJECT_DB_DIR seam), backup_dir_override redirects where
165
165
  # backup.create_backup() below writes its tar.gz (this module's own
166
166
  # CCST_PDATA_BACKUP_DIR seam). Without the second seam, a rehearsed --write
167
- # would still deposit a real <project>-<epoch>.tar.gz into the production
167
+ # would still deposit a real <project>-<YYYYMMDD-HHMMSS>.tar.gz into the production
168
168
  # backup directory — indistinguishable by filename from a genuine migration's
169
169
  # backup. Both are no-ops when rehearse is None.
170
170
  with (
@@ -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)
@@ -9,7 +9,7 @@ import re as _re
9
9
  from collections.abc import Mapping
10
10
  from dataclasses import dataclass, field
11
11
 
12
- from cc_session_tools.lib.pdata import naming, repository
12
+ from cc_session_tools.lib.pdata import naming, repository, store
13
13
 
14
14
  # op is deliberately \S+ here (not the literal alternation of valid ops) — matching only a
15
15
  # literal alternation would make an invalid op (e.g. "~=") fail to match the whole regex at all,
@@ -213,6 +213,65 @@ def query_records(
213
213
  conn.close()
214
214
 
215
215
 
216
+ def find_records_by_file_path_prefix(*, project: str, prefix: str) -> list[Record]:
217
+ """Every active record across every record_group in this project whose file_path starts
218
+ with prefix - used by lib/pdata/reorganize.py to find which rows need their file_path
219
+ updated when a folder is split into a nested structure. Returns [] both when the project
220
+ has no .db yet and when it has one but nothing matches - callers that need to distinguish
221
+ "no store" from "store, no match" should check store.db_path(project).exists() themselves.
222
+
223
+ Goes straight to repository.query_records() with an already-parsed (field, op, value)
224
+ condition, one connection reused across every record_group, rather than routing through
225
+ this module's own query_records()/_parse_where_clause() string DSL: that DSL's
226
+ `\\s+`-based regex would silently strip any leading whitespace off prefix on the round
227
+ trip through it (a real, if narrow, correctness bug for a prefix that legitimately starts
228
+ with a space), and re-resolving a fresh connection per record_group here would be needless
229
+ overhead for what's logically one read. list_extension_columns/get_extension_row's
230
+ row-materialisation is duplicated inline here rather than shared with list_records/
231
+ query_records above - matching how those two already duplicate the same block between
232
+ each other rather than a factored-out helper.
233
+
234
+ Known limitation: prefix is still used as a raw SQL LIKE pattern, so a literal '%' or '_'
235
+ in it is interpreted as a wildcard rather than a literal character - SQLite's LIKE has no
236
+ ESCAPE-clause support wired up anywhere in this codebase. Not a practical issue for this
237
+ callsite specifically - prefix is always a project-relative folder path (this codebase's
238
+ own naming convention uses hyphens, not underscores, e.g. ws-01-slug), but worth knowing if
239
+ this function is ever reused somewhere prefix isn't a controlled folder name."""
240
+ if not prefix:
241
+ raise ValueError("find_records_by_file_path_prefix requires a non-empty prefix")
242
+ if not store.db_path(project).exists():
243
+ return []
244
+ conn = repository.connect(project)
245
+ try:
246
+ matches: list[Record] = []
247
+ for group in repository.list_record_groups(conn):
248
+ record_group = group["record_group"]
249
+ # list_record_groups() types its return as dict[str, object] since row_count and
250
+ # max_updated_at are int-shaped columns in the same row - record_group itself is
251
+ # always the TEXT column's value, never anything else, so this narrows it for
252
+ # mypy the same way an impossible-state assert would (coding-standards: make the
253
+ # impossible state visible rather than silently re-checking it).
254
+ assert isinstance(record_group, str)
255
+ rows = repository.query_records(
256
+ conn, record_group=record_group,
257
+ conditions=[("file_path", "LIKE", f"{prefix}%")],
258
+ limit=None, include_deleted=False,
259
+ )
260
+ has_ext = repository.extension_table_exists(conn, record_group)
261
+ for row in rows:
262
+ record = _row_to_record(row)
263
+ if has_ext:
264
+ ext_row = repository.get_extension_row(conn, record_group, row["id"])
265
+ if ext_row is not None:
266
+ record.fields = {
267
+ k: ext_row[k] for k in ext_row.keys() if k != "record_id"
268
+ }
269
+ matches.append(record)
270
+ return matches
271
+ finally:
272
+ conn.close()
273
+
274
+
216
275
  class RecordNotFoundError(Exception):
217
276
  """Raised when a record id resolves to no row (or no active row)."""
218
277