cc-session-tools 2.12.0__tar.gz → 2.12.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 (276) hide show
  1. {cc_session_tools-2.12.0/src/cc_session_tools.egg-info → cc_session_tools-2.12.2}/PKG-INFO +9 -2
  2. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/README.md +8 -1
  3. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/pyproject.toml +1 -1
  4. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/ccd.py +4 -0
  5. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/ccr.py +4 -0
  6. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/ccsched.py +38 -0
  7. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/ccst.py +34 -0
  8. cc_session_tools-2.12.2/src/cc_session_tools/lib/occupancy.py +168 -0
  9. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/init_service.py +83 -20
  10. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/repository.py +17 -0
  11. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/jobspec.py +5 -2
  12. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/ledger.py +17 -0
  13. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/registry.py +25 -0
  14. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/worker.py +8 -2
  15. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/manage-recurring-cc-jobs-using-ccsched/SKILL.md +3 -0
  16. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2/src/cc_session_tools.egg-info}/PKG-INFO +9 -2
  17. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/pdata_sync.py +50 -22
  18. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_pdata_init_cli.py +66 -0
  19. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_pdata_verify_cli.py +37 -0
  20. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_cli_ccd.py +6 -0
  21. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_cli_ccr.py +3 -0
  22. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_occupancy.py +79 -0
  23. cc_session_tools-2.12.0/src/cc_session_tools/lib/occupancy.py +0 -83
  24. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/LICENSE +0 -0
  25. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/setup.cfg +0 -0
  26. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/__init__.py +0 -0
  27. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/__init__.py +0 -0
  28. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/ccmsg.py +0 -0
  29. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/ccs.py +0 -0
  30. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/migrate_ccmsg.py +0 -0
  31. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/migrate_ccsched.py +0 -0
  32. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/migrate_sessions_db.py +0 -0
  33. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/cli/migrate_telemetry.py +0 -0
  34. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/config/hooks-bundle.json +0 -0
  35. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/config/session-tag-hook.json +0 -0
  36. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/hooks_install.py +0 -0
  37. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/__init__.py +0 -0
  38. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/claude_flags.py +0 -0
  39. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/claude_md_install.py +0 -0
  40. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/context_overrides.py +0 -0
  41. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/db.py +0 -0
  42. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/debug.py +0 -0
  43. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/doctor.py +0 -0
  44. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/doctor_mutes.py +0 -0
  45. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/hook_registry.py +0 -0
  46. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/install_sync.py +0 -0
  47. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/levenshtein.py +0 -0
  48. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/machine_identity.py +0 -0
  49. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/__init__.py +0 -0
  50. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/addressing.py +0 -0
  51. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/cursor.py +0 -0
  52. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/lock.py +0 -0
  53. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/message.py +0 -0
  54. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/move_safety.py +0 -0
  55. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/repository.py +0 -0
  56. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/retention.py +0 -0
  57. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/service.py +0 -0
  58. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/messaging/store.py +0 -0
  59. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/paths.py +0 -0
  60. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/__init__.py +0 -0
  61. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/backup.py +0 -0
  62. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/classify.py +0 -0
  63. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/cutover.py +0 -0
  64. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/dump.py +0 -0
  65. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/formatting.py +0 -0
  66. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/importers.py +0 -0
  67. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/init_paths.py +0 -0
  68. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/manifest.py +0 -0
  69. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/naming.py +0 -0
  70. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/rehydrate.py +0 -0
  71. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/rename_group.py +0 -0
  72. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/reorganize.py +0 -0
  73. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/resolve.py +0 -0
  74. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/service.py +0 -0
  75. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/session_output.py +0 -0
  76. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/store.py +0 -0
  77. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/sync_check.py +0 -0
  78. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/sync_lock.py +0 -0
  79. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/sync_notify.py +0 -0
  80. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/vector_clock.py +0 -0
  81. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/vector_clock_store.py +0 -0
  82. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/verify.py +0 -0
  83. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/pdata/write_log.py +0 -0
  84. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/picker.py +0 -0
  85. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/proc_lock.py +0 -0
  86. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/prompts.py +0 -0
  87. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/roots.py +0 -0
  88. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/rules.py +0 -0
  89. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/__init__.py +0 -0
  90. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/bundled_jobs.py +0 -0
  91. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/cadence.py +0 -0
  92. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/cursor.py +0 -0
  93. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/digest.py +0 -0
  94. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/due.py +0 -0
  95. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/duration.py +0 -0
  96. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/lock.py +0 -0
  97. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/notify.py +0 -0
  98. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/reconcile.py +0 -0
  99. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/runner.py +0 -0
  100. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/state.py +0 -0
  101. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/store.py +0 -0
  102. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/surface.py +0 -0
  103. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/scheduler/throttle.py +0 -0
  104. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/session_gc.py +0 -0
  105. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/sessions.py +0 -0
  106. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/sessions_db.py +0 -0
  107. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/sessions_repair.py +0 -0
  108. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/shell_install.py +0 -0
  109. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/tasklist.py +0 -0
  110. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/lib/telemetry_store.py +0 -0
  111. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/prompts/pdata-migration-claude-md-update.md +0 -0
  112. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/prompts/pdata-migration-skills-update.md +0 -0
  113. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/analyse-cc-usage/SKILL.md +0 -0
  114. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/clean-hook-sessions/SKILL.md +0 -0
  115. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/clean-hook-sessions/scripts/clean-hook-sessions.py +0 -0
  116. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/context-override/SKILL.md +0 -0
  117. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/delete-sessions/SKILL.md +0 -0
  118. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/delete-sessions/scripts/delete_sessions.py +0 -0
  119. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/do-executor-critic-assessor-loop/SKILL.md +0 -0
  120. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/find-claude-code-session/SKILL.md +0 -0
  121. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/generate-8digit-code/SKILL.md +0 -0
  122. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/generate-8digit-code/scripts/generate_8digit_code.py +0 -0
  123. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/list-empty-sessions/SKILL.md +0 -0
  124. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/list-empty-sessions/scripts/list_empty_sessions.py +0 -0
  125. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/move-session/SKILL.md +0 -0
  126. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/move-session/hooks/sessionstart-pending-rename.sh +0 -0
  127. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/move-session/scripts/cc_session_rules.py +0 -0
  128. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/move-session/scripts/move_session.py +0 -0
  129. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/pm-pdata-conflict-resolution/SKILL.md +0 -0
  130. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/pm-pdata-schema-design/SKILL.md +0 -0
  131. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/pm-project-init/SKILL.md +0 -0
  132. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/pm-project-layout-reference/SKILL.md +0 -0
  133. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/pm-update-central-files/SKILL.md +0 -0
  134. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/reduce-persistent-context/CALIBRATION.md +0 -0
  135. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/reduce-persistent-context/SKILL.md +0 -0
  136. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/analyze_context.py +0 -0
  137. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/duplicates.py +0 -0
  138. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/measure.py +0 -0
  139. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/report.py +0 -0
  140. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/tokens.py +0 -0
  141. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/usage.py +0 -0
  142. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/select-agent-model/SKILL.md +0 -0
  143. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/send-session-message/SKILL.md +0 -0
  144. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/update-command-cache/SKILL.md +0 -0
  145. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools/skills/update-command-cache/scripts/update_command_cache.py +0 -0
  146. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools.egg-info/SOURCES.txt +0 -0
  147. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools.egg-info/dependency_links.txt +0 -0
  148. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools.egg-info/entry_points.txt +0 -0
  149. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools.egg-info/requires.txt +0 -0
  150. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cc_session_tools.egg-info/top_level.txt +0 -0
  151. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/__init__.py +0 -0
  152. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/after_response.py +0 -0
  153. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/bash_hard_deny.py +0 -0
  154. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/bash_security_review.py +0 -0
  155. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/cache.py +0 -0
  156. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/catchup.py +0 -0
  157. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/confirm_8digit.py +0 -0
  158. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/context_window_warning.py +0 -0
  159. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/last_screenshot.py +0 -0
  160. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/marker_allow.py +0 -0
  161. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/markers.py +0 -0
  162. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/messaging_deliver.py +0 -0
  163. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/model_info.py +0 -0
  164. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/normalise.py +0 -0
  165. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/pending_migration.py +0 -0
  166. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/pending_rename.py +0 -0
  167. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/session_tag.py +0 -0
  168. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/stats.py +0 -0
  169. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/telemetry.py +0 -0
  170. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/telemetry_query.py +0 -0
  171. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/telemetry_trim.py +0 -0
  172. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/transcript.py +0 -0
  173. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/cccs_hooks/worklog_guard.py +0 -0
  174. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/__init__.py +0 -0
  175. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/attribution.py +0 -0
  176. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/cache.py +0 -0
  177. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/ccusage_wrapper.py +0 -0
  178. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/cli.py +0 -0
  179. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/data/pricing.json +0 -0
  180. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/parent_inference.py +0 -0
  181. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/parser.py +0 -0
  182. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/pricing.py +0 -0
  183. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/query.py +0 -0
  184. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/report.py +0 -0
  185. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/schema.py +0 -0
  186. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/src/claude_code_usage/session_names.py +0 -0
  187. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_after_response.py +0 -0
  188. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_attribution.py +0 -0
  189. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_bash_hard_deny.py +0 -0
  190. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_bash_security_review.py +0 -0
  191. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_cache.py +0 -0
  192. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_cache_sqlite.py +0 -0
  193. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccr_orphans.py +0 -0
  194. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccs_emptiness.py +0 -0
  195. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccs_eta.py +0 -0
  196. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccs_list_mode.py +0 -0
  197. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccs_scope_flags.py +0 -0
  198. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccs_sentinel_sort.py +0 -0
  199. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccs_session_counts.py +0 -0
  200. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_bundle_discovery.py +0 -0
  201. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_ccsched_jobs_cli.py +0 -0
  202. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_cli.py +0 -0
  203. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_context_override_cli.py +0 -0
  204. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_doctor.py +0 -0
  205. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_gc_prune.py +0 -0
  206. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_gc_report.py +0 -0
  207. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_hook_dispatcher.py +0 -0
  208. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_hooks_install_prune.py +0 -0
  209. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_hooks_install_zero_arg.py +0 -0
  210. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_hooks_uninstall.py +0 -0
  211. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_install_everything.py +0 -0
  212. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_machine_identity_cli.py +0 -0
  213. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_main_gate.py +0 -0
  214. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_pdata_cli.py +0 -0
  215. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_pdata_reconcile_cli.py +0 -0
  216. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_pdata_rename_group_cli.py +0 -0
  217. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_pdata_reorganize_cli.py +0 -0
  218. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_pdata_sync_cli.py +0 -0
  219. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_repair_sessions_cli.py +0 -0
  220. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_sessions_cli.py +0 -0
  221. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_shell_install.py +0 -0
  222. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_skills_install.py +0 -0
  223. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_skills_uninstall.py +0 -0
  224. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_telemetry_query.py +0 -0
  225. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccst_telemetry_trim.py +0 -0
  226. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_ccusage_wrapper.py +0 -0
  227. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_claude_flags.py +0 -0
  228. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_claude_md_install.py +0 -0
  229. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_cli.py +0 -0
  230. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_cli_ccd_dry_run.py +0 -0
  231. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_cli_ccs.py +0 -0
  232. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_cli_version.py +0 -0
  233. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_confirm_8digit.py +0 -0
  234. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_context_overrides.py +0 -0
  235. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_context_window_warning.py +0 -0
  236. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_db.py +0 -0
  237. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_debug.py +0 -0
  238. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_doctor_mutes.py +0 -0
  239. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_empty_session.py +0 -0
  240. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_hook_cache.py +0 -0
  241. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_hooks_install.py +0 -0
  242. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_install_sync.py +0 -0
  243. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_last_screenshot.py +0 -0
  244. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_levenshtein.py +0 -0
  245. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_machine_identity.py +0 -0
  246. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_marker_allow.py +0 -0
  247. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_migrate_ccmsg.py +0 -0
  248. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_migrate_ccsched.py +0 -0
  249. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_migrate_sessions_db.py +0 -0
  250. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_migrate_telemetry.py +0 -0
  251. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_model_info.py +0 -0
  252. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_normalise.py +0 -0
  253. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_parent_inference.py +0 -0
  254. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_parser.py +0 -0
  255. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_paths.py +0 -0
  256. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_pending_migration_hook.py +0 -0
  257. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_picker.py +0 -0
  258. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_pricing.py +0 -0
  259. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_proc_lock.py +0 -0
  260. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_prompts.py +0 -0
  261. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_query.py +0 -0
  262. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_report.py +0 -0
  263. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_roots.py +0 -0
  264. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_rules.py +0 -0
  265. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_scheduler_bundled_jobs.py +0 -0
  266. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_schema.py +0 -0
  267. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_session_names.py +0 -0
  268. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_session_tag.py +0 -0
  269. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_sessions.py +0 -0
  270. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_sessions_db.py +0 -0
  271. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_sessions_repair.py +0 -0
  272. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_tasklist.py +0 -0
  273. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_telemetry.py +0 -0
  274. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_telemetry_store.py +0 -0
  275. {cc_session_tools-2.12.0 → cc_session_tools-2.12.2}/tests/test_transcript.py +0 -0
  276. {cc_session_tools-2.12.0 → cc_session_tools-2.12.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.12.0
3
+ Version: 2.12.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
@@ -80,7 +80,7 @@ bundled scheduled jobs:
80
80
  | **`claude-code-usage`** | Multi-dimensional usage analytics CLI: query/group/filter by project, session, model, MCP server, plugin, tool, day/week/month/year. Reconciles dollar totals against `ccusage`. |
81
81
  | **`ccst <noun> <verb>`** | Umbrella CLI for hook and skill management: install, uninstall, health-check, shell helpers, telemetry trim, global CLAUDE.md messaging block. |
82
82
  | **`ccmsg <command>`** | Inter-session messaging CLI: send, deliver, read, list, claim, and archive durable messages between Claude Code sessions. |
83
- | **`ccsched <command>`** | Scheduled-task CLI: register, list, edit, enable/disable, and remove recurring jobs; inspect status; one-shot sweep. |
83
+ | **`ccsched <command>`** | Scheduled-task CLI: register, list, edit, rename, enable/disable, and remove recurring jobs; inspect status; one-shot sweep. |
84
84
  | **`ccl` (shell fn)** | Shell function wrapping `ccs` for list-mode usage. Installed by `ccst shell install`. |
85
85
 
86
86
  **Bundled skills** (installed via `ccst skills install`)
@@ -630,6 +630,7 @@ loser exits).
630
630
  | `edit` | Edit a job field (cadence, command, coalesce, etc.) in-place. |
631
631
  | `enable` / `disable` | Toggle a job on or off without removing it. |
632
632
  | `remove` | Delete a job from the registry. |
633
+ | `rename` | Rename a job's id; its run state and ledger history carry over to the new id. Refuses while the job is running. |
633
634
  | `run` | Run a job immediately (foreground; bypasses cadence). |
634
635
  | `status` | Show recent ledger history for a job (or all jobs). |
635
636
  | `sweep` | One-shot reconcile + launch from the shell. |
@@ -1073,6 +1074,12 @@ Claude Code lets multiple sessions share a single task list if they all set the
1073
1074
 
1074
1075
  This means you can pick up a task created in yesterday's session from today's session in the same project, without any extra setup.
1075
1076
 
1077
+ Both `ccd` and `ccr` also unconditionally set `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` in the launched
1078
+ session's environment. Claude Code >=2.1.233 hides the TodoWrite/TaskCreate/TaskGet/TaskUpdate/
1079
+ TaskList tools by default on Opus 4.8, Sonnet 5, Fable 5, Mythos 5 and later unless this is set -
1080
+ without it, the task list machinery above would be wired up but the tools that use it would be
1081
+ invisible to the model.
1082
+
1076
1083
  ## Typo protection (strict root only)
1077
1084
 
1078
1085
  When you start a session under the **strict** (`PROJ_ROOT`) root, `ccd` checks whether your tag's first dash-separated term looks like a typo of the project directory name (Levenshtein distance ≤ 2):
@@ -23,7 +23,7 @@ bundled scheduled jobs:
23
23
  | **`claude-code-usage`** | Multi-dimensional usage analytics CLI: query/group/filter by project, session, model, MCP server, plugin, tool, day/week/month/year. Reconciles dollar totals against `ccusage`. |
24
24
  | **`ccst <noun> <verb>`** | Umbrella CLI for hook and skill management: install, uninstall, health-check, shell helpers, telemetry trim, global CLAUDE.md messaging block. |
25
25
  | **`ccmsg <command>`** | Inter-session messaging CLI: send, deliver, read, list, claim, and archive durable messages between Claude Code sessions. |
26
- | **`ccsched <command>`** | Scheduled-task CLI: register, list, edit, enable/disable, and remove recurring jobs; inspect status; one-shot sweep. |
26
+ | **`ccsched <command>`** | Scheduled-task CLI: register, list, edit, rename, enable/disable, and remove recurring jobs; inspect status; one-shot sweep. |
27
27
  | **`ccl` (shell fn)** | Shell function wrapping `ccs` for list-mode usage. Installed by `ccst shell install`. |
28
28
 
29
29
  **Bundled skills** (installed via `ccst skills install`)
@@ -573,6 +573,7 @@ loser exits).
573
573
  | `edit` | Edit a job field (cadence, command, coalesce, etc.) in-place. |
574
574
  | `enable` / `disable` | Toggle a job on or off without removing it. |
575
575
  | `remove` | Delete a job from the registry. |
576
+ | `rename` | Rename a job's id; its run state and ledger history carry over to the new id. Refuses while the job is running. |
576
577
  | `run` | Run a job immediately (foreground; bypasses cadence). |
577
578
  | `status` | Show recent ledger history for a job (or all jobs). |
578
579
  | `sweep` | One-shot reconcile + launch from the shell. |
@@ -1016,6 +1017,12 @@ Claude Code lets multiple sessions share a single task list if they all set the
1016
1017
 
1017
1018
  This means you can pick up a task created in yesterday's session from today's session in the same project, without any extra setup.
1018
1019
 
1020
+ Both `ccd` and `ccr` also unconditionally set `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` in the launched
1021
+ session's environment. Claude Code >=2.1.233 hides the TodoWrite/TaskCreate/TaskGet/TaskUpdate/
1022
+ TaskList tools by default on Opus 4.8, Sonnet 5, Fable 5, Mythos 5 and later unless this is set -
1023
+ without it, the task list machinery above would be wired up but the tools that use it would be
1024
+ invisible to the model.
1025
+
1019
1026
  ## Typo protection (strict root only)
1020
1027
 
1021
1028
  When you start a session under the **strict** (`PROJ_ROOT`) root, `ccd` checks whether your tag's first dash-separated term looks like a typo of the project directory name (Levenshtein distance ≤ 2):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cc-session-tools"
7
- version = "2.12.0"
7
+ version = "2.12.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" }
@@ -200,6 +200,10 @@ def main(argv: list[str] | None = None) -> int:
200
200
  env["CLD_SESSION_TAG"] = tag
201
201
  env["CLD_SESSION_DIR"] = str(session_dir)
202
202
  env["CLD_SESSION_MODE"] = "new"
203
+ # Claude Code >=2.1.233 hides TodoWrite/TaskCreate/TaskGet/TaskUpdate/TaskList
204
+ # on Opus 4.8, Sonnet 5, Fable 5, Mythos 5+ unless this is set, regardless of
205
+ # whether a task list ID resolves below.
206
+ env["CLAUDE_CODE_ENABLE_TODO_TOOLS"] = "1"
203
207
  task_list_id = id_for_project(real_pwd)
204
208
  if task_list_id is not None:
205
209
  env["CLAUDE_CODE_TASK_LIST_ID"] = task_list_id
@@ -221,6 +221,10 @@ def main(argv: list[str] | None = None) -> int:
221
221
  env["CLD_SESSION_TAG"] = tag
222
222
  env["CLD_SESSION_DIR"] = str(m.session_dir)
223
223
  env["CLD_SESSION_MODE"] = "resume"
224
+ # Claude Code >=2.1.233 hides TodoWrite/TaskCreate/TaskGet/TaskUpdate/TaskList
225
+ # on Opus 4.8, Sonnet 5, Fable 5, Mythos 5+ unless this is set, regardless of
226
+ # whether a task list ID resolves below.
227
+ env["CLAUDE_CODE_ENABLE_TODO_TOOLS"] = "1"
224
228
  task_list_id = id_for_project(m.project_dir)
225
229
  if task_list_id is not None:
226
230
  env["CLAUDE_CODE_TASK_LIST_ID"] = task_list_id
@@ -25,9 +25,11 @@ from cc_session_tools.lib.scheduler.duration import parse_duration
25
25
  from cc_session_tools.lib.scheduler.jobspec import (
26
26
  JobSpec,
27
27
  JobValidationError,
28
+ check_job_id,
28
29
  parse_success_exit_codes,
29
30
  validate_job_fields,
30
31
  )
32
+ from cc_session_tools.lib.scheduler.lock import InFlightLockHeld, in_flight_lock
31
33
  from cc_session_tools.lib.scheduler.runner import run_command
32
34
 
33
35
 
@@ -137,6 +139,18 @@ def _build_parser() -> argparse.ArgumentParser:
137
139
  sp = sub.add_parser(verb, help=f"{verb.capitalize()} a job.")
138
140
  sp.add_argument("id")
139
141
 
142
+ rename_p = sub.add_parser(
143
+ "rename",
144
+ help="Rename a job, keeping its run history under the new id.",
145
+ )
146
+ rename_p.add_argument("old_id", metavar="old-id", help="The job's current id.")
147
+ rename_p.add_argument(
148
+ "new_id", metavar="new-id",
149
+ help="The new id, lowercase kebab-case [a-z0-9-]. Run state (last_success, "
150
+ "consecutive_failures, suspended, ...) and ledger history (`ccsched status`) "
151
+ "carry over. Refuses while the job is currently running.",
152
+ )
153
+
140
154
  run_p = sub.add_parser("run", help="Run one job now.")
141
155
  run_p.add_argument("id")
142
156
 
@@ -303,6 +317,25 @@ def _cmd_remove(job_id: str) -> int:
303
317
  return 0
304
318
 
305
319
 
320
+ def _cmd_rename(args: argparse.Namespace) -> int:
321
+ if args.new_id == args.old_id:
322
+ return _err("new id is the same as the current id")
323
+ try:
324
+ check_job_id(args.new_id)
325
+ except JobValidationError as exc:
326
+ return _err(str(exc))
327
+ try:
328
+ with in_flight_lock(args.old_id):
329
+ registry.rename_job(args.old_id, args.new_id)
330
+ ledger.rename_job(args.old_id, args.new_id)
331
+ except InFlightLockHeld:
332
+ return _err(f"job {args.old_id!r} is currently running; try again once it finishes")
333
+ except registry.RegistryError as exc:
334
+ return _err(str(exc))
335
+ print(f"renamed {args.old_id} -> {args.new_id}")
336
+ return 0
337
+
338
+
306
339
  def _cmd_run(
307
340
  args: argparse.Namespace, *, notify_push: worker.NotifyPush = notify.push_outcome,
308
341
  ) -> int:
@@ -340,6 +373,9 @@ def _cmd_status(args: argparse.Namespace) -> int:
340
373
  for r in rows:
341
374
  print(f"{r.get('ts','')} {r.get('job_id',''):<24} {r.get('event',''):<12} "
342
375
  f"ran={r.get('ran')} exit={r.get('exit_code')}")
376
+ error = r.get("error")
377
+ if error:
378
+ print(f" {error}")
343
379
  return 0
344
380
 
345
381
 
@@ -380,6 +416,8 @@ def main(argv: list[str] | None = None) -> int:
380
416
  return _cmd_set_enabled(args.id, False)
381
417
  if args.command == "remove":
382
418
  return _cmd_remove(args.id)
419
+ if args.command == "rename":
420
+ return _cmd_rename(args)
383
421
  if args.command == "run":
384
422
  return _cmd_run(args)
385
423
  if args.command == "status":
@@ -1156,6 +1156,11 @@ def _cmd_pdata_init(args: argparse.Namespace) -> int:
1156
1156
  print(f"ccst pdata: {exc}", file=sys.stderr)
1157
1157
  return 2
1158
1158
  print(result.report)
1159
+ if result.would_adopt_from_dump:
1160
+ # No proposal was written and there's nothing to classify - the reminders below are
1161
+ # about updating project docs after a real classify/import cutover, which doesn't
1162
+ # apply here.
1163
+ return 0
1159
1164
  print(f"\nProposal: {result.proposal_path}")
1160
1165
  _print_migration_prompt_reminders(
1161
1166
  result.proposal_path.parent,
@@ -1407,6 +1412,35 @@ def _cmd_pdata_verify(args: argparse.Namespace) -> int:
1407
1412
  print(f"ccst pdata verify: {project}: {exc}", file=sys.stderr)
1408
1413
  worst = max(worst, 2)
1409
1414
  continue
1415
+ except Exception as exc:
1416
+ # run_verify crashed before persisting anything for this project - a
1417
+ # per-project issue (its own except ValueError above) is the only
1418
+ # *expected* failure shape; anything else is a bug or a transient
1419
+ # race (e.g. a concurrent writer) worth surfacing in full rather than
1420
+ # letting it silently take the whole --all-projects sweep down with
1421
+ # no diagnostic trail. Rerun once, in --full mode, as a one-shot
1422
+ # diagnostic: exactly one retry, never chained further even if the
1423
+ # retry crashes too, so a persistently broken project reports once
1424
+ # and moves on instead of turning into a retry loop.
1425
+ print(f"ccst pdata verify: {project}: crashed: {exc}", file=sys.stderr)
1426
+ worst = max(worst, 1)
1427
+ flagged.append(project)
1428
+ try:
1429
+ diag = verify.run_verify(project=project, full=True)
1430
+ except Exception as diag_exc:
1431
+ print(
1432
+ f"ccst pdata verify: {project}: diagnostic rerun also crashed: {diag_exc}",
1433
+ file=sys.stderr,
1434
+ )
1435
+ else:
1436
+ print(
1437
+ f"ccst pdata verify: {project}: diagnostic rerun: {diag.status} "
1438
+ f"({len(diag.issues)} issue(s))",
1439
+ file=sys.stderr,
1440
+ )
1441
+ for issue in diag.issues:
1442
+ print(f" [{issue.severity}] {issue.check}: {issue.message}", file=sys.stderr)
1443
+ continue
1410
1444
  if summary.status != "OK":
1411
1445
  worst = max(worst, 1)
1412
1446
  flagged.append(project)
@@ -0,0 +1,168 @@
1
+ """Is a live `claude` process currently working in a given project (spec: "Process safety").
2
+
3
+ Fails safe: any error enumerating `claude` processes, or resolving a PID's cwd, counts as
4
+ occupied, never as clear - see is_occupied()'s single try/except around both steps. Letting
5
+ either failure fall through to "not occupied" would be indistinguishable from the legitimate
6
+ "no claude process right now" case to every caller, which is exactly the false "clear to
7
+ rehydrate" signal this module exists to prevent.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def _claude_pids() -> list[int]:
18
+ """Every running `claude` process's PID. Raises (OSError, subprocess.SubprocessError) on any
19
+ genuine failure to enumerate them - does not swallow these into an empty list, since an
20
+ empty list here is indistinguishable from "pgrep ran fine and found nothing" to is_occupied().
21
+ A *clean* "no matches" from pgrep itself (exit code 1, empty stdout, no exception) is not a
22
+ failure and correctly produces []."""
23
+ out = subprocess.run(
24
+ ["pgrep", "-x", "claude"],
25
+ capture_output=True,
26
+ text=True,
27
+ timeout=5,
28
+ ).stdout
29
+ # str.split() with no args already splits on whitespace and drops empty
30
+ # tokens, so each token is already stripped — no further .strip() needed.
31
+ return [int(p) for p in out.split() if p.isdigit()]
32
+
33
+
34
+ def _cwd_of_pid(pid: int) -> Path:
35
+ """pid's current working directory. Raises (OSError, subprocess.SubprocessError) on any
36
+ failure - callers must catch both, not just OSError (a nonzero lsof exit, e.g. the ordinary
37
+ race where the process has already exited, raises CalledProcessError, not OSError)."""
38
+ if sys.platform == "darwin":
39
+ # lsof's field-output mode ("man lsof", OUTPUT FOR OTHER PROGRAMS)
40
+ # always emits the PID ('p') field, then whichever other fields were
41
+ # requested — here just the name ('n') field — each on its own line,
42
+ # prefixed by its field character. So scanning for the line that
43
+ # starts with 'n' and stripping that one character is documented
44
+ # lsof behaviour, not a guess.
45
+ out = subprocess.run(
46
+ ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=5,
50
+ check=True,
51
+ ).stdout
52
+ for line in out.splitlines():
53
+ if line.startswith("n"):
54
+ return Path(line[1:])
55
+ raise OSError(f"lsof returned no cwd line for pid {pid}")
56
+ if sys.platform.startswith("linux"):
57
+ return Path(os.readlink(f"/proc/{pid}/cwd"))
58
+ # A third platform (e.g. Windows-native Python, not WSL) has no /proc and isn't darwin -
59
+ # raise explicitly rather than silently falling into the Linux branch and failing with a
60
+ # confusing FileNotFoundError. Still caught by is_occupied()'s fail-safe handler below.
61
+ raise OSError(f"occupancy check not implemented for platform {sys.platform!r}")
62
+
63
+
64
+ def _ppid_of(pid: int) -> int | None:
65
+ """`pid`'s parent PID, or None if it can't be determined (already exited, permission error,
66
+ or an unsupported platform) - every failure degrades to None rather than raising, since the
67
+ only caller, `launching_claude_pid`, already treats "can't climb any further" as a normal
68
+ stopping condition, not an error."""
69
+ if sys.platform.startswith("linux"):
70
+ try:
71
+ status = Path(f"/proc/{pid}/status").read_text()
72
+ except OSError:
73
+ return None
74
+ for line in status.splitlines():
75
+ if line.startswith("PPid:"):
76
+ return int(line.split()[1])
77
+ return None
78
+ if sys.platform == "darwin":
79
+ try:
80
+ out = subprocess.run(
81
+ ["ps", "-o", "ppid=", "-p", str(pid)],
82
+ capture_output=True, text=True, timeout=5, check=True,
83
+ ).stdout.strip()
84
+ except (OSError, subprocess.SubprocessError):
85
+ return None
86
+ return int(out) if out.isdigit() else None
87
+ return None
88
+
89
+
90
+ def _comm_of(pid: int) -> str | None:
91
+ """`pid`'s process name (`/proc/<pid>/comm`'s exact contents on Linux - the same field
92
+ `pgrep -x` matches against), or None if it can't be read."""
93
+ if sys.platform.startswith("linux"):
94
+ try:
95
+ return Path(f"/proc/{pid}/comm").read_text().strip()
96
+ except OSError:
97
+ return None
98
+ if sys.platform == "darwin":
99
+ try:
100
+ out = subprocess.run(
101
+ ["ps", "-o", "comm=", "-p", str(pid)],
102
+ capture_output=True, text=True, timeout=5, check=True,
103
+ ).stdout.strip()
104
+ except (OSError, subprocess.SubprocessError):
105
+ return None
106
+ return out or None
107
+ return None
108
+
109
+
110
+ def launching_claude_pid(start_pid: int, *, max_hops: int = 5) -> int | None:
111
+ """The nearest ancestor of `start_pid` (inclusive) whose process name is `claude`, or None if
112
+ none is found within `max_hops` steps.
113
+
114
+ Exists because a hook's `command` is not run as a direct child of the `claude` process: Claude
115
+ Code spawns every command hook via `/bin/sh -c "<command>"`, and on any system where `/bin/sh`
116
+ is dash rather than bash - the Debian/Ubuntu/WSL2 default - dash does not replace itself with
117
+ the command it's running the way bash does for a single simple `-c` script. That leaves a real
118
+ `sh` process between `claude` and the hook's own process, confirmed empirically (not assumed)
119
+ by installing a diagnostic `SessionStart` hook in a scratch project and inspecting
120
+ `/proc/<pid>/{comm,status}` for the whole ancestor chain: `ccst` (the hook) -> `sh -c ...` ->
121
+ `claude` -> the shell that launched it, exactly two hops from `os.getppid()`'s immediate
122
+ parent to the real `claude` process, not one. A caller that naively used `os.getppid()` as
123
+ "the launching claude process's PID" (as this hook's `main()` used to) would therefore be
124
+ excluding the wrong PID from `is_occupied()`'s check every time - one that will never actually
125
+ appear in `_claude_pids()`'s `pgrep -x claude` results - so the exclusion silently never
126
+ matches anything, and a SessionStart hook checking its own just-started project reports it as
127
+ occupied by itself on every single invocation.
128
+
129
+ Bounded to `max_hops` (comfortably above the 2 hops seen in the confirmed case, to tolerate
130
+ extra wrapping such as a `timeout` command) so an unrecognised process-tree shape degrades to
131
+ None rather than looping forever; a caller receiving None should exclude nothing, matching
132
+ this module's existing bias toward reporting occupied over silently under-excluding.
133
+
134
+ Unconfirmed on macOS's own `/bin/sh` (Apple's bash 3.2 build) - it may already perform the
135
+ single-command replacement bash does elsewhere, making the extra hop unnecessary there; this
136
+ function is safe either way since it stops as soon as it finds a `claude` ancestor, however
137
+ many hops that takes."""
138
+ pid = start_pid
139
+ for _ in range(max_hops):
140
+ if _comm_of(pid) == "claude":
141
+ return pid
142
+ parent = _ppid_of(pid)
143
+ if parent is None or parent == pid:
144
+ return None
145
+ pid = parent
146
+ return None
147
+
148
+
149
+ def is_occupied(project_root: Path, *, exclude_pid: int | None = None) -> bool:
150
+ """True if any live `claude` process (other than `exclude_pid`) has its cwd resolved to
151
+ exactly `project_root` - matching Claude Code's own behaviour of keeping a session's cwd
152
+ fixed at the project root for its whole lifetime (see e.g. ccd.py's os.chdir(real_pwd)),
153
+ never a cc-sessions/<tag>/ subdirectory."""
154
+ resolved_root = project_root.resolve()
155
+ try:
156
+ pids = _claude_pids()
157
+ except (OSError, subprocess.SubprocessError):
158
+ return True # can't even enumerate claude processes - fail safe, never fail open
159
+ for pid in pids:
160
+ if pid == exclude_pid:
161
+ continue
162
+ try:
163
+ cwd = _cwd_of_pid(pid).resolve()
164
+ except (OSError, subprocess.SubprocessError):
165
+ return True # fail safe, never fail open
166
+ if cwd == resolved_root:
167
+ return True
168
+ return False
@@ -32,11 +32,43 @@ class DryRunResult:
32
32
  manifest: Manifest
33
33
  report: str
34
34
  proposal_path: Path
35
+ # True iff --write would adopt from an existing sync dump rather than classify/import this
36
+ # machine's flat files - see _pending_adoption. When True, `report` already describes the
37
+ # adoption and no proposal file was written; `proposal_path` is still populated (where one
38
+ # *would* go) for callers that print it unconditionally, but shouldn't be treated as pointing
39
+ # at a real file.
40
+ would_adopt_from_dump: bool = False
35
41
 
36
42
 
37
43
  def dry_run(*, project: str, rehearse: Path | None = None) -> DryRunResult:
38
44
  project_root = init_paths.resolve_project_root(project, rehearse=rehearse)
45
+ proposal_path = project_root / init_paths.PROPOSAL_FILENAME
39
46
  with init_paths.project_db_dir_override(rehearse):
47
+ # Checked before anything below opens/creates this project's .db (repository.connect()'s
48
+ # own DDL does that as a side effect - see _already_locally_migrated): a dry run must
49
+ # never create persistent state that could itself go on to poison a later adopt-from-dump
50
+ # decision, which is exactly what happened before this check existed - see that
51
+ # function's docstring for the real incident.
52
+ pending = _pending_adoption(project=project, project_root=project_root)
53
+ if pending is not None:
54
+ latest_path = project_root / ".pdata-db-dump" / "latest.sql"
55
+ published_at = _format_published_at(pending, latest_path)
56
+ report = (
57
+ f"ccst pdata init --project {project}: a published sync dump already exists "
58
+ f"(from {pending.machine_id}, at {published_at}) and this machine has no local "
59
+ f"pdata content for this project yet.\n\n`--write` will adopt that dump directly "
60
+ f"(same mechanism as the ongoing sync trigger) instead of classifying/importing "
61
+ f"this machine's current flat files, which would create a second, disconnected "
62
+ f"history for this project.\n\nRun `ccst pdata init --project {project} --write` "
63
+ f"to adopt it."
64
+ )
65
+ return DryRunResult(
66
+ manifest=Manifest(project=project, entries=[]),
67
+ report=report,
68
+ proposal_path=proposal_path,
69
+ would_adopt_from_dump=True,
70
+ )
71
+
40
72
  # repository.connect() runs the base-schema DDL (CREATE TABLE IF NOT
41
73
  # EXISTS) on every call — this is what "safe to run against an empty
42
74
  # folder... also how a genuinely new project gets its .db" (spec §5) means.
@@ -50,7 +82,6 @@ def dry_run(*, project: str, rehearse: Path | None = None) -> DryRunResult:
50
82
  existing_record_groups = frozenset(
51
83
  str(group["record_group"]) for group in service.schema_list(project=project)
52
84
  )
53
- proposal_path = project_root / init_paths.PROPOSAL_FILENAME
54
85
  m = manifest.load_or_create(
55
86
  project_root, project, proposal_path,
56
87
  existing_record_groups=existing_record_groups,
@@ -209,26 +240,40 @@ def _format_published_at(info: dump.DumpInfo, latest_path: Path) -> str:
209
240
  return dump.format_dumped_at(int(latest_path.stat().st_mtime))
210
241
 
211
242
 
212
- def _adopt_from_dump(
213
- *, project: str, project_root: Path, on_progress: Callable[[str], None] | None,
214
- ) -> WriteResult | None:
215
- """Spec "ccst pdata init on a second machine (adopt-from-dump)": a project already migrated
216
- to pdata on another machine publishes a dump into <project_root>/.pdata-db-dump/latest.sql —
217
- a machine with no local .db for this project yet must adopt that dump directly (same
218
- mechanism as the ongoing rehydrate trigger) rather than classifying/importing this machine's
219
- current flat files as if this were a first-ever migration, which would produce a DB
220
- disconnected from the dump's vector-clock history and likely duplicate content already
221
- captured elsewhere.
243
+ def _already_locally_migrated(project: str) -> bool:
244
+ """True iff this machine already has real pdata content for `project` - not merely an empty
245
+ schema-only `.db` file. `repository.connect()`'s own DDL (`CREATE TABLE IF NOT EXISTS`)
246
+ creates that file as a side effect of any read-only operation that opens a connection -
247
+ `dry_run()`'s own schema lookup, `ccst pdata verify`, and others - well before any real
248
+ migration ever happens on this machine. A bare `store.db_path(project).exists()` check
249
+ can't tell that empty file apart from a genuine prior migration, and would misread it as
250
+ "already migrated here" - silently and permanently skipping adopt-from-dump. Confirmed live,
251
+ not hypothetical: a second machine's `ccst pdata init` (no `--write`) created exactly this
252
+ empty file, and the very next `--write` on the same machine then skipped adoption because of
253
+ it, falling through to an ordinary classify/import that failed for unrelated reasons."""
254
+ if not store.db_path(project).exists():
255
+ return False
256
+ conn = repository.connect(project)
257
+ try:
258
+ return repository.has_any_records(conn)
259
+ finally:
260
+ conn.close()
261
+
222
262
 
223
- Returns the early WriteResult to hand straight back from write() when adoption ran; None when
224
- there is nothing to adopt (no dump has ever been published for this project — the ordinary
225
- classify/import flow proceeds exactly as before this feature existed) or when this machine
226
- already has its own local .db for the project (already adopted/migrated here previously —
227
- ccst pdata rehydrate, not ccst pdata init, is the ongoing sync path for that case).
263
+ def _pending_adoption(*, project: str, project_root: Path) -> dump.DumpInfo | None:
264
+ """Is there a published dump this machine should adopt instead of classifying/importing its
265
+ own flat files? Returns the validated `DumpInfo` when adoption applies; `None` when there is
266
+ nothing to adopt (no dump has ever been published for this project - the ordinary
267
+ classify/import flow proceeds exactly as before this feature existed) or this machine is
268
+ already genuinely migrated (see `_already_locally_migrated` - ccst pdata rehydrate, not ccst
269
+ pdata init, is the ongoing sync path for that case).
228
270
 
229
- Checked via the dump file's own existence, not DumpInfo.checksum_valid alone: read_latest()
230
- returns checksum_valid=False both when no dump was ever published and when one exists but is
231
- corrupt, and those two cases must not be treated the same — the first is the ordinary
271
+ Shared by `dry_run()` (reports what `--write` would do) and `_adopt_from_dump()` (does it) -
272
+ one check, so the two can never disagree about whether adoption applies.
273
+
274
+ Checked via the dump file's own existence, not `DumpInfo.checksum_valid` alone: `read_latest`
275
+ returns `checksum_valid=False` both when no dump was ever published and when one exists but is
276
+ corrupt, and those two cases must not be treated the same - the first is the ordinary
232
277
  first-ever migration, the second is a real problem that must be surfaced, never silently
233
278
  papered over by falling through to classification."""
234
279
  latest_path = project_root / ".pdata-db-dump" / "latest.sql"
@@ -243,8 +288,26 @@ def _adopt_from_dump(
243
288
  "project. Publish a fresh dump from a known-good machine (ccst pdata dump --force) "
244
289
  "or reconcile manually (ccst pdata resolve) once those commands exist."
245
290
  )
246
- if store.db_path(project).exists():
291
+ if _already_locally_migrated(project):
247
292
  return None
293
+ return info
294
+
295
+
296
+ def _adopt_from_dump(
297
+ *, project: str, project_root: Path, on_progress: Callable[[str], None] | None,
298
+ ) -> WriteResult | None:
299
+ """Spec "ccst pdata init on a second machine (adopt-from-dump)": a project already migrated
300
+ to pdata on another machine publishes a dump into <project_root>/.pdata-db-dump/latest.sql —
301
+ a machine with no local pdata content for this project yet must adopt that dump directly
302
+ (same mechanism as the ongoing rehydrate trigger) rather than classifying/importing this
303
+ machine's current flat files as if this were a first-ever migration, which would produce a DB
304
+ disconnected from the dump's vector-clock history and likely duplicate content already
305
+ captured elsewhere. Returns the early WriteResult to hand straight back from write() when
306
+ adoption ran; None when `_pending_adoption` finds nothing to adopt."""
307
+ info = _pending_adoption(project=project, project_root=project_root)
308
+ if info is None:
309
+ return None
310
+ latest_path = project_root / ".pdata-db-dump" / "latest.sql"
248
311
  published_at = _format_published_at(info, latest_path)
249
312
  message = (
250
313
  f"Adopting existing pdata from sync dump (published by {info.machine_id}, "
@@ -108,6 +108,23 @@ def get_base_record(conn: sqlite3.Connection, record_id: int) -> sqlite3.Row | N
108
108
  return row
109
109
 
110
110
 
111
+ def has_any_records(conn: sqlite3.Connection) -> bool:
112
+ """True iff `records` has at least one row NOT soft-deleted. Existence check, not a count -
113
+ `LIMIT 1` so this stays cheap regardless of table size. Distinguishes a genuinely-populated
114
+ .db from an empty schema-only one that `connect()`'s own DDL can create as an incidental side
115
+ effect of a read-only operation, well before any real content is ever written.
116
+
117
+ Excludes soft-deleted rows deliberately, not an oversight: a rolled-back `ccst pdata init
118
+ --write` (`init_service._rollback`) soft-deletes every row it inserted rather than hard
119
+ deleting them - "no hard delete, full auditability" - so a failed write attempt leaves the
120
+ `records` table non-empty with nothing but tombstones. Counting those as "real content" would
121
+ make a failed, fully-rolled-back --write permanently block adopt-from-dump on this exact
122
+ machine, which is the opposite of what a rollback is supposed to mean."""
123
+ return conn.execute(
124
+ "SELECT 1 FROM records WHERE deleted_at IS NULL LIMIT 1"
125
+ ).fetchone() is not None
126
+
127
+
111
128
  def ensure_extension_table(conn: sqlite3.Connection, record_group: str) -> None:
112
129
  """CREATE ext_<group> (record_id INTEGER PRIMARY KEY REFERENCES records(id)) if it doesn't
113
130
  exist yet — record_group is validated by naming.extension_table_name(). Caller owns the
@@ -38,7 +38,10 @@ class JobSpec:
38
38
  success_exit_codes: tuple[int, ...] = (0,)
39
39
 
40
40
 
41
- def _check_id(job_id: str) -> None:
41
+ def check_job_id(job_id: str) -> None:
42
+ """Boundary validator for a bare job id, used both by validate_job_fields
43
+ (a full spec) and by callers that only need to validate an id on its own
44
+ - e.g. ccsched's `rename` command, which never builds a JobSpec."""
42
45
  if not _KEBAB_RE.match(job_id):
43
46
  raise JobValidationError(
44
47
  f"invalid job id {job_id!r}: must be lowercase kebab-case [a-z0-9-], "
@@ -107,7 +110,7 @@ def validate_job_fields(
107
110
  timeout: str,
108
111
  success_exit_codes: tuple[int, ...] = (0,),
109
112
  ) -> JobSpec:
110
- _check_id(job_id)
113
+ check_job_id(job_id)
111
114
  try:
112
115
  parse_cadence(cadence)
113
116
  except CadenceError as exc:
@@ -78,6 +78,23 @@ def record(entry: LedgerEntry) -> None:
78
78
  print(f"[telemetry-warn] ledger write failed: {e}", file=sys.stderr)
79
79
 
80
80
 
81
+ def rename_job(old_id: str, new_id: str) -> None:
82
+ """Repoint every catchup_events row from old_id to new_id, so `ccsched
83
+ status` and the surfacing pass keep showing continuous history under a
84
+ job's new name. Unlike record(), this raises on failure rather than
85
+ swallowing it - it's a foreground CLI operation (ccsched rename), not
86
+ best-effort logging, so a caller that already renamed the job in
87
+ ccsched.db needs to know if history did not follow."""
88
+ conn = telemetry_store.connect()
89
+ try:
90
+ conn.execute(
91
+ "UPDATE catchup_events SET job_id=? WHERE job_id=?", (new_id, old_id)
92
+ )
93
+ conn.commit()
94
+ finally:
95
+ conn.close()
96
+
97
+
81
98
  def read_recent(job_id: str | None = None, *, limit: int = 50) -> list[dict[str, object]]:
82
99
  """Return up to ``limit`` recent catch-up rows, oldest-first within that
83
100
  slice, optionally filtered by job_id."""
@@ -112,6 +112,31 @@ def set_enabled(job_id: str, enabled: bool) -> None:
112
112
  conn.close()
113
113
 
114
114
 
115
+ def rename_job(old_id: str, new_id: str) -> None:
116
+ """Repoint a job's id everywhere it's a row key in ccsched.db - the `jobs`
117
+ spec row, its `job_state` row (so registered_at/last_success/etc. carry
118
+ over), and its `bundled_job_installs` row if it has one - in a single
119
+ transaction. Callers that also want run history to follow (telemetry.db's
120
+ catchup_events) call ledger.rename_job separately; the two stores are
121
+ never truly atomic together, matching how the rest of this package treats
122
+ them as independent."""
123
+ conn = store.connect()
124
+ try:
125
+ cur = conn.execute("UPDATE jobs SET job_id=? WHERE job_id=?", (new_id, old_id))
126
+ if cur.rowcount == 0:
127
+ conn.commit()
128
+ raise RegistryError(f"unknown job id: {old_id!r}")
129
+ conn.execute("UPDATE job_state SET job_id=? WHERE job_id=?", (new_id, old_id))
130
+ conn.execute(
131
+ "UPDATE bundled_job_installs SET job_id=? WHERE job_id=?", (new_id, old_id)
132
+ )
133
+ conn.commit()
134
+ except sqlite3.IntegrityError as exc:
135
+ raise RegistryError(f"job id already exists: {new_id!r}") from exc
136
+ finally:
137
+ conn.close()
138
+
139
+
115
140
  def mark_bundled_installed(job_id: str, installed_at: str) -> None:
116
141
  """Record that a CCST-bundled job (lib/scheduler/bundled_jobs.py) has been installed on this
117
142
  machine, so a later `ccsched remove` can be told apart from "never installed" by