cc-session-tools 2.12.2__tar.gz → 2.12.4__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 (275) hide show
  1. {cc_session_tools-2.12.2/src/cc_session_tools.egg-info → cc_session_tools-2.12.4}/PKG-INFO +1 -1
  2. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/pyproject.toml +1 -1
  3. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/ccst.py +59 -12
  4. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/doctor.py +10 -6
  5. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/resolve.py +9 -1
  6. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/verify.py +1 -1
  7. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/bundled_jobs.py +56 -13
  8. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/digest.py +16 -1
  9. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/surface.py +84 -14
  10. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4/src/cc_session_tools.egg-info}/PKG-INFO +1 -1
  11. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_ccsched_jobs_cli.py +8 -1
  12. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_doctor.py +28 -0
  13. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_pdata_sync_cli.py +54 -0
  14. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_scheduler_bundled_jobs.py +60 -1
  15. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/LICENSE +0 -0
  16. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/README.md +0 -0
  17. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/setup.cfg +0 -0
  18. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/__init__.py +0 -0
  19. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/__init__.py +0 -0
  20. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/ccd.py +0 -0
  21. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/ccmsg.py +0 -0
  22. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/ccr.py +0 -0
  23. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/ccs.py +0 -0
  24. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/ccsched.py +0 -0
  25. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/migrate_ccmsg.py +0 -0
  26. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/migrate_ccsched.py +0 -0
  27. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/migrate_sessions_db.py +0 -0
  28. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/cli/migrate_telemetry.py +0 -0
  29. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/config/hooks-bundle.json +0 -0
  30. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/config/session-tag-hook.json +0 -0
  31. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/hooks_install.py +0 -0
  32. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/__init__.py +0 -0
  33. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/claude_flags.py +0 -0
  34. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/claude_md_install.py +0 -0
  35. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/context_overrides.py +0 -0
  36. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/db.py +0 -0
  37. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/debug.py +0 -0
  38. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/doctor_mutes.py +0 -0
  39. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/hook_registry.py +0 -0
  40. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/install_sync.py +0 -0
  41. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/levenshtein.py +0 -0
  42. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/machine_identity.py +0 -0
  43. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/__init__.py +0 -0
  44. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/addressing.py +0 -0
  45. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/cursor.py +0 -0
  46. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/lock.py +0 -0
  47. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/message.py +0 -0
  48. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/move_safety.py +0 -0
  49. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/repository.py +0 -0
  50. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/retention.py +0 -0
  51. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/service.py +0 -0
  52. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/messaging/store.py +0 -0
  53. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/occupancy.py +0 -0
  54. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/paths.py +0 -0
  55. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/__init__.py +0 -0
  56. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/backup.py +0 -0
  57. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/classify.py +0 -0
  58. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/cutover.py +0 -0
  59. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/dump.py +0 -0
  60. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/formatting.py +0 -0
  61. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/importers.py +0 -0
  62. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/init_paths.py +0 -0
  63. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/init_service.py +0 -0
  64. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/manifest.py +0 -0
  65. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/naming.py +0 -0
  66. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/rehydrate.py +0 -0
  67. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/rename_group.py +0 -0
  68. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/reorganize.py +0 -0
  69. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/repository.py +0 -0
  70. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/service.py +0 -0
  71. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/session_output.py +0 -0
  72. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/store.py +0 -0
  73. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/sync_check.py +0 -0
  74. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/sync_lock.py +0 -0
  75. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/sync_notify.py +0 -0
  76. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/vector_clock.py +0 -0
  77. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/vector_clock_store.py +0 -0
  78. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/pdata/write_log.py +0 -0
  79. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/picker.py +0 -0
  80. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/proc_lock.py +0 -0
  81. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/prompts.py +0 -0
  82. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/roots.py +0 -0
  83. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/rules.py +0 -0
  84. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/__init__.py +0 -0
  85. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/cadence.py +0 -0
  86. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/cursor.py +0 -0
  87. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/due.py +0 -0
  88. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/duration.py +0 -0
  89. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/jobspec.py +0 -0
  90. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/ledger.py +0 -0
  91. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/lock.py +0 -0
  92. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/notify.py +0 -0
  93. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/reconcile.py +0 -0
  94. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/registry.py +0 -0
  95. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/runner.py +0 -0
  96. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/state.py +0 -0
  97. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/store.py +0 -0
  98. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/throttle.py +0 -0
  99. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/scheduler/worker.py +0 -0
  100. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/session_gc.py +0 -0
  101. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/sessions.py +0 -0
  102. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/sessions_db.py +0 -0
  103. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/sessions_repair.py +0 -0
  104. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/shell_install.py +0 -0
  105. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/tasklist.py +0 -0
  106. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/lib/telemetry_store.py +0 -0
  107. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/prompts/pdata-migration-claude-md-update.md +0 -0
  108. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/prompts/pdata-migration-skills-update.md +0 -0
  109. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/analyse-cc-usage/SKILL.md +0 -0
  110. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/clean-hook-sessions/SKILL.md +0 -0
  111. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/clean-hook-sessions/scripts/clean-hook-sessions.py +0 -0
  112. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/context-override/SKILL.md +0 -0
  113. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/delete-sessions/SKILL.md +0 -0
  114. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/delete-sessions/scripts/delete_sessions.py +0 -0
  115. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/do-executor-critic-assessor-loop/SKILL.md +0 -0
  116. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/find-claude-code-session/SKILL.md +0 -0
  117. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/generate-8digit-code/SKILL.md +0 -0
  118. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/generate-8digit-code/scripts/generate_8digit_code.py +0 -0
  119. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/list-empty-sessions/SKILL.md +0 -0
  120. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/list-empty-sessions/scripts/list_empty_sessions.py +0 -0
  121. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/manage-recurring-cc-jobs-using-ccsched/SKILL.md +0 -0
  122. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/move-session/SKILL.md +0 -0
  123. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/move-session/hooks/sessionstart-pending-rename.sh +0 -0
  124. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/move-session/scripts/cc_session_rules.py +0 -0
  125. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/move-session/scripts/move_session.py +0 -0
  126. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/pm-pdata-conflict-resolution/SKILL.md +0 -0
  127. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/pm-pdata-schema-design/SKILL.md +0 -0
  128. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/pm-project-init/SKILL.md +0 -0
  129. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/pm-project-layout-reference/SKILL.md +0 -0
  130. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/pm-update-central-files/SKILL.md +0 -0
  131. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/reduce-persistent-context/CALIBRATION.md +0 -0
  132. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/reduce-persistent-context/SKILL.md +0 -0
  133. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/reduce-persistent-context/scripts/analyze_context.py +0 -0
  134. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/reduce-persistent-context/scripts/duplicates.py +0 -0
  135. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/reduce-persistent-context/scripts/measure.py +0 -0
  136. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/reduce-persistent-context/scripts/report.py +0 -0
  137. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/reduce-persistent-context/scripts/tokens.py +0 -0
  138. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/reduce-persistent-context/scripts/usage.py +0 -0
  139. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/select-agent-model/SKILL.md +0 -0
  140. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/send-session-message/SKILL.md +0 -0
  141. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/update-command-cache/SKILL.md +0 -0
  142. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools/skills/update-command-cache/scripts/update_command_cache.py +0 -0
  143. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools.egg-info/SOURCES.txt +0 -0
  144. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools.egg-info/dependency_links.txt +0 -0
  145. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools.egg-info/entry_points.txt +0 -0
  146. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools.egg-info/requires.txt +0 -0
  147. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cc_session_tools.egg-info/top_level.txt +0 -0
  148. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/__init__.py +0 -0
  149. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/after_response.py +0 -0
  150. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/bash_hard_deny.py +0 -0
  151. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/bash_security_review.py +0 -0
  152. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/cache.py +0 -0
  153. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/catchup.py +0 -0
  154. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/confirm_8digit.py +0 -0
  155. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/context_window_warning.py +0 -0
  156. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/last_screenshot.py +0 -0
  157. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/marker_allow.py +0 -0
  158. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/markers.py +0 -0
  159. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/messaging_deliver.py +0 -0
  160. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/model_info.py +0 -0
  161. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/normalise.py +0 -0
  162. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/pdata_sync.py +0 -0
  163. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/pending_migration.py +0 -0
  164. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/pending_rename.py +0 -0
  165. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/session_tag.py +0 -0
  166. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/stats.py +0 -0
  167. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/telemetry.py +0 -0
  168. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/telemetry_query.py +0 -0
  169. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/telemetry_trim.py +0 -0
  170. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/transcript.py +0 -0
  171. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/cccs_hooks/worklog_guard.py +0 -0
  172. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/__init__.py +0 -0
  173. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/attribution.py +0 -0
  174. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/cache.py +0 -0
  175. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/ccusage_wrapper.py +0 -0
  176. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/cli.py +0 -0
  177. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/data/pricing.json +0 -0
  178. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/parent_inference.py +0 -0
  179. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/parser.py +0 -0
  180. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/pricing.py +0 -0
  181. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/query.py +0 -0
  182. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/report.py +0 -0
  183. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/schema.py +0 -0
  184. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/src/claude_code_usage/session_names.py +0 -0
  185. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_after_response.py +0 -0
  186. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_attribution.py +0 -0
  187. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_bash_hard_deny.py +0 -0
  188. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_bash_security_review.py +0 -0
  189. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_cache.py +0 -0
  190. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_cache_sqlite.py +0 -0
  191. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccr_orphans.py +0 -0
  192. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccs_emptiness.py +0 -0
  193. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccs_eta.py +0 -0
  194. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccs_list_mode.py +0 -0
  195. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccs_scope_flags.py +0 -0
  196. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccs_sentinel_sort.py +0 -0
  197. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccs_session_counts.py +0 -0
  198. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_bundle_discovery.py +0 -0
  199. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_cli.py +0 -0
  200. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_context_override_cli.py +0 -0
  201. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_gc_prune.py +0 -0
  202. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_gc_report.py +0 -0
  203. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_hook_dispatcher.py +0 -0
  204. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_hooks_install_prune.py +0 -0
  205. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_hooks_install_zero_arg.py +0 -0
  206. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_hooks_uninstall.py +0 -0
  207. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_install_everything.py +0 -0
  208. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_machine_identity_cli.py +0 -0
  209. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_main_gate.py +0 -0
  210. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_pdata_cli.py +0 -0
  211. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_pdata_init_cli.py +0 -0
  212. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_pdata_reconcile_cli.py +0 -0
  213. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_pdata_rename_group_cli.py +0 -0
  214. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_pdata_reorganize_cli.py +0 -0
  215. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_pdata_verify_cli.py +0 -0
  216. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_repair_sessions_cli.py +0 -0
  217. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_sessions_cli.py +0 -0
  218. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_shell_install.py +0 -0
  219. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_skills_install.py +0 -0
  220. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_skills_uninstall.py +0 -0
  221. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_telemetry_query.py +0 -0
  222. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccst_telemetry_trim.py +0 -0
  223. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_ccusage_wrapper.py +0 -0
  224. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_claude_flags.py +0 -0
  225. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_claude_md_install.py +0 -0
  226. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_cli.py +0 -0
  227. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_cli_ccd.py +0 -0
  228. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_cli_ccd_dry_run.py +0 -0
  229. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_cli_ccr.py +0 -0
  230. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_cli_ccs.py +0 -0
  231. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_cli_version.py +0 -0
  232. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_confirm_8digit.py +0 -0
  233. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_context_overrides.py +0 -0
  234. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_context_window_warning.py +0 -0
  235. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_db.py +0 -0
  236. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_debug.py +0 -0
  237. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_doctor_mutes.py +0 -0
  238. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_empty_session.py +0 -0
  239. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_hook_cache.py +0 -0
  240. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_hooks_install.py +0 -0
  241. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_install_sync.py +0 -0
  242. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_last_screenshot.py +0 -0
  243. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_levenshtein.py +0 -0
  244. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_machine_identity.py +0 -0
  245. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_marker_allow.py +0 -0
  246. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_migrate_ccmsg.py +0 -0
  247. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_migrate_ccsched.py +0 -0
  248. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_migrate_sessions_db.py +0 -0
  249. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_migrate_telemetry.py +0 -0
  250. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_model_info.py +0 -0
  251. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_normalise.py +0 -0
  252. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_occupancy.py +0 -0
  253. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_parent_inference.py +0 -0
  254. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_parser.py +0 -0
  255. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_paths.py +0 -0
  256. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_pending_migration_hook.py +0 -0
  257. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_picker.py +0 -0
  258. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_pricing.py +0 -0
  259. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_proc_lock.py +0 -0
  260. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_prompts.py +0 -0
  261. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_query.py +0 -0
  262. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_report.py +0 -0
  263. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_roots.py +0 -0
  264. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_rules.py +0 -0
  265. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_schema.py +0 -0
  266. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_session_names.py +0 -0
  267. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_session_tag.py +0 -0
  268. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_sessions.py +0 -0
  269. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_sessions_db.py +0 -0
  270. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_sessions_repair.py +0 -0
  271. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_tasklist.py +0 -0
  272. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_telemetry.py +0 -0
  273. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_telemetry_store.py +0 -0
  274. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/tests/test_transcript.py +0 -0
  275. {cc_session_tools-2.12.2 → cc_session_tools-2.12.4}/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.2
3
+ Version: 2.12.4
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.12.2"
7
+ version = "2.12.4"
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" }
@@ -1611,6 +1611,42 @@ def _resolve_deleted_at(payload: RecordPayload) -> int | None:
1611
1611
  return payload["base"]["deleted_at"]
1612
1612
 
1613
1613
 
1614
+ def _close_empty_fork_if_any(project: str) -> bool:
1615
+ """`resolve.diff_against_dump` found zero record/schema differences for `project` - but that
1616
+ alone doesn't mean there's nothing to do: the vector clock can still be a genuine fork (each
1617
+ machine made a local write the other hasn't seen) whose *content* happens to match on both
1618
+ sides, e.g. two machines independently re-deriving the same auto-reconciled index from
1619
+ already-synced files. In that case `dump`/`rehydrate` refuse forever with nothing for a human
1620
+ to pick between - `resolve.apply_resolution(project, {})` is now able to close it (see that
1621
+ function's own comment), so do so.
1622
+
1623
+ Deliberately narrow: only touches anything when the comparison is an actual FORK. A plain
1624
+ "already up to date" project (DUMP_DOMINATES, or nothing published yet) is left completely
1625
+ alone - the diagnose path stays side-effect-free for the overwhelmingly common case, matching
1626
+ `diff_against_dump`'s own "Display/diagnostic use only" contract. Returns True iff a fork was
1627
+ found and closed; False for every other outcome (no fork, checksum-invalid dump, lock
1628
+ contention) - callers fall back to the ordinary "clean" message in all of those, since no
1629
+ caller here treats "not closed" as an error worth surfacing on its own (a checksum-invalid
1630
+ dump or lock contention will already have been reported by the caller that hit it first, or
1631
+ will be on the very next run)."""
1632
+ from cc_session_tools.lib.pdata import (
1633
+ dump, repository, resolve, store, vector_clock, vector_clock_store,
1634
+ )
1635
+
1636
+ project_root = store.project_root(project)
1637
+ existing = dump.read_latest(project_root)
1638
+ if not existing.checksum_valid:
1639
+ return False
1640
+ conn = repository.connect(project)
1641
+ try:
1642
+ local_vector = vector_clock_store.read_vector(conn)
1643
+ finally:
1644
+ conn.close()
1645
+ if vector_clock.compare(local=local_vector, dump=existing.vector) is not vector_clock.Comparison.FORK:
1646
+ return False
1647
+ return resolve.apply_resolution(project, {}) is resolve.ApplyOutcome.APPLIED
1648
+
1649
+
1614
1650
  def _cmd_pdata_resolve(args: argparse.Namespace) -> int:
1615
1651
  from cc_session_tools.lib.pdata import resolve, verify
1616
1652
 
@@ -1666,18 +1702,24 @@ def _cmd_pdata_resolve(args: argparse.Namespace) -> int:
1666
1702
  if has_diff:
1667
1703
  outstanding += 1
1668
1704
 
1669
- if compact:
1670
- if has_diff:
1705
+ if not has_diff:
1706
+ closed = _close_empty_fork_if_any(project)
1707
+ if compact:
1708
+ print(f"{project}: clean (fork auto-closed)" if closed else f"{project}: clean")
1709
+ elif closed:
1671
1710
  print(
1672
- f"{project}: {len(diff.records)} record(s) + "
1673
- f"{len(diff.schema_fields)} schema field(s) outstanding"
1711
+ f"{project}: clean - content already matched the dump; closed the "
1712
+ f"vector-clock fork automatically (nothing to choose between)"
1674
1713
  )
1675
1714
  else:
1676
- print(f"{project}: clean")
1715
+ print(f"{project}: clean - nothing to resolve")
1677
1716
  continue
1678
1717
 
1679
- if not has_diff:
1680
- print(f"{project}: clean - nothing to resolve")
1718
+ if compact:
1719
+ print(
1720
+ f"{project}: {len(diff.records)} record(s) + "
1721
+ f"{len(diff.schema_fields)} schema field(s) outstanding"
1722
+ )
1681
1723
  continue
1682
1724
 
1683
1725
  print(
@@ -2132,12 +2174,17 @@ def _cmd_ccsched_jobs_install(args: argparse.Namespace) -> int:
2132
2174
  for job in bundled_jobs.BUNDLED_CCSCHED_JOBS:
2133
2175
  spec = existing.get(job.job_id)
2134
2176
  if spec is not None:
2135
- changed = bundled_jobs.diff_from_bundled(spec, job)
2136
- if changed:
2177
+ changed_detail = bundled_jobs.diff_from_bundled_detail(spec, job)
2178
+ if changed_detail:
2179
+ field_word = "field differs" if len(changed_detail) == 1 else "fields differ"
2180
+ print(
2181
+ f" changed (not touched): {job.job_id} - {len(changed_detail)} "
2182
+ f"{field_word} from the bundled definition:"
2183
+ )
2184
+ print(bundled_jobs.render_field_diffs(changed_detail))
2137
2185
  print(
2138
- f" changed (not touched): {job.job_id} - {', '.join(changed)} "
2139
- f"differ from the bundled definition; run 'ccsched edit' to realign, or "
2140
- f"leave as your intentional customization"
2186
+ f" run 'ccsched edit {job.job_id}' to realign, or leave as your "
2187
+ f"intentional customization"
2141
2188
  )
2142
2189
  elif not spec.enabled:
2143
2190
  print(f" disabled (not touched): {job.job_id} - run 'ccsched enable {job.job_id}' to re-enable")
@@ -256,16 +256,20 @@ def check_ccsched_job_registered(
256
256
  for spec in specs:
257
257
  if spec.job_id == job_id:
258
258
  if expected is not None:
259
- from cc_session_tools.lib.scheduler.bundled_jobs import diff_from_bundled
259
+ from cc_session_tools.lib.scheduler.bundled_jobs import (
260
+ diff_from_bundled_detail, render_field_diffs,
261
+ )
260
262
 
261
- changed = diff_from_bundled(spec, expected)
262
- if changed:
263
+ changed_detail = diff_from_bundled_detail(spec, expected)
264
+ if changed_detail:
265
+ field_word = "field differs" if len(changed_detail) == 1 else "fields differ"
263
266
  return CheckResult(
264
267
  name=name, status=Status.WARN,
265
268
  reason=(
266
- f"registered but {', '.join(changed)} differ from the bundled "
267
- f"definition — run 'ccst ccsched-jobs install --apply' to see the "
268
- f"diff, or 'ccsched edit {job_id}' if this was intentional"
269
+ f"registered but {len(changed_detail)} {field_word} from "
270
+ f"the bundled definition:\n{render_field_diffs(changed_detail)}\n"
271
+ f"run 'ccsched edit {job_id}' to realign, or leave as your "
272
+ f"intentional customization"
269
273
  ),
270
274
  )
271
275
  if spec.enabled:
@@ -316,7 +316,15 @@ def _apply_resolution(
316
316
  f"machine is missing a field, matching the other's definition, then retry."
317
317
  )
318
318
 
319
- if not choices:
319
+ # Scoped to `diff.records` non-empty, not unconditional: a genuine vector-clock fork whose
320
+ # records happen to be byte-identical on both sides (diff.records == []) has nothing for a
321
+ # human to pick between, and `choices={}` correctly names "every record_id in the diff" for
322
+ # that case (there are zero). Rejecting it unconditionally left that case with no way through
323
+ # `apply_resolution` at all — `dump`/`rehydrate` would refuse forever with only `--force` (a
324
+ # blunt overwrite that risks discarding a real edit neither side actually has to lose here)
325
+ # to unblock it. Found 2026-08-31 working the `home` project's fork-testing walkthrough; see
326
+ # CHANGELOG.
327
+ if not choices and diff.records:
320
328
  raise ValueError("apply_resolution requires at least one record_id in choices")
321
329
 
322
330
  by_id = {record_diff.record_id: record_diff for record_diff in diff.records}
@@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS pdata_verify_runs (
41
41
  CREATE INDEX IF NOT EXISTS idx_pdata_verify_runs_run_at ON pdata_verify_runs(run_at);
42
42
  """
43
43
 
44
- _MAX_RETAINED_RUNS = 30
44
+ _MAX_RETAINED_RUNS = 100
45
45
  _DOUBLE_UPDATE_WINDOW_SECONDS = 6 * 60 * 60 # see plan Decision 4 — not a CLI flag
46
46
 
47
47
  _SEVERITY_ORDER = {"OK": 0, "WARN": 1, "FAIL": 2}
@@ -10,6 +10,7 @@ deliberately excludes `enabled`: that is per-machine operational state a human t
10
10
  `_cmd_ccsched_jobs_install` and `doctor.check_ccsched_job_registered`, the two callers."""
11
11
  from __future__ import annotations
12
12
 
13
+ import shlex
13
14
  from dataclasses import dataclass
14
15
  from pathlib import Path
15
16
  from typing import TYPE_CHECKING
@@ -183,23 +184,65 @@ BUNDLED_CCSCHED_JOBS: tuple[BundledJob, ...] = (
183
184
  )
184
185
 
185
186
 
186
- def diff_from_bundled(spec: "JobSpec", job: BundledJob) -> tuple[str, ...]:
187
- """Names of the fields where a registered JobSpec no longer matches its bundled
188
- definition, in declaration order; empty if it matches exactly. Deliberately ignores
189
- `enabled` — see module docstring."""
190
- differing: list[str] = []
187
+ @dataclass(frozen=True, slots=True)
188
+ class FieldDiff:
189
+ """One field where a registered JobSpec no longer matches its bundled definition,
190
+ carrying both the bundled and the current (live) value, rendered as display-ready
191
+ strings so a caller never has to know a field's underlying type - see
192
+ `diff_from_bundled_detail`."""
193
+ field: str
194
+ bundled: str
195
+ current: str
196
+
197
+
198
+ def diff_from_bundled_detail(spec: "JobSpec", job: BundledJob) -> tuple[FieldDiff, ...]:
199
+ """Same comparison as `diff_from_bundled`, but carries the before/after values for
200
+ display, in declaration order; empty if the spec matches its bundled definition
201
+ exactly. Deliberately ignores `enabled` — see module docstring. This is the one place
202
+ that decides both *whether* a field changed and *how* its value is rendered, so
203
+ `_cmd_ccsched_jobs_install` and `check_ccsched_job_registered` — the two callers —
204
+ can never disagree on either question."""
205
+ diffs: list[FieldDiff] = []
191
206
  if spec.cadence != job.cadence:
192
- differing.append("cadence")
207
+ diffs.append(FieldDiff("cadence", job.cadence, spec.cadence))
193
208
  if spec.coalesce.value != job.coalesce:
194
- differing.append("coalesce")
209
+ diffs.append(FieldDiff("coalesce", job.coalesce, spec.coalesce.value))
195
210
  if spec.catchup_window != job.catchup_window:
196
- differing.append("catchup_window")
211
+ diffs.append(FieldDiff("catchup_window", job.catchup_window, spec.catchup_window))
197
212
  if spec.timeout != job.timeout:
198
- differing.append("timeout")
213
+ diffs.append(FieldDiff("timeout", job.timeout, spec.timeout))
199
214
  if spec.surface != job.surface:
200
- differing.append("surface")
215
+ diffs.append(FieldDiff("surface", str(job.surface), str(spec.surface)))
201
216
  if spec.command != job.command:
202
- differing.append("command")
217
+ # shlex.join renders as an actual shell line, not a Python tuple repr - readable
218
+ # for a multi-arg command.
219
+ diffs.append(FieldDiff("command", shlex.join(job.command), shlex.join(spec.command)))
203
220
  if spec.success_exit_codes != job.success_exit_codes:
204
- differing.append("success_exit_codes")
205
- return tuple(differing)
221
+ diffs.append(FieldDiff(
222
+ "success_exit_codes",
223
+ ", ".join(str(c) for c in job.success_exit_codes),
224
+ ", ".join(str(c) for c in spec.success_exit_codes),
225
+ ))
226
+ return tuple(diffs)
227
+
228
+
229
+ def diff_from_bundled(spec: "JobSpec", job: BundledJob) -> tuple[str, ...]:
230
+ """Names of the fields where a registered JobSpec no longer matches its bundled
231
+ definition, in declaration order; empty if it matches exactly. A thin wrapper over
232
+ `diff_from_bundled_detail` so both callers stay guaranteed-identical in what counts
233
+ as "changed" — see module docstring."""
234
+ return tuple(fd.field for fd in diff_from_bundled_detail(spec, job))
235
+
236
+
237
+ def render_field_diffs(diffs: tuple[FieldDiff, ...], *, indent: str = " ") -> str:
238
+ """Shared before/after rendering for a non-empty `diff_from_bundled_detail` result -
239
+ one `field:` sub-block per entry, in the same order, `-`/`+` prefixed so it reads as a
240
+ diff without pulling in `difflib`. Both `_cmd_ccsched_jobs_install` and
241
+ `check_ccsched_job_registered` call this so they render byte-identical bodies and only
242
+ differ in the header/hint text wrapped around it."""
243
+ lines: list[str] = []
244
+ for d in diffs:
245
+ lines.append(f"{indent}{d.field}:")
246
+ lines.append(f"{indent} - bundled: {d.bundled}")
247
+ lines.append(f"{indent} + current: {d.current}")
248
+ return "\n".join(lines)
@@ -40,6 +40,11 @@ class JobReport:
40
40
  # exclusive with `findings`: a given run is either a clean 0-exit or a
41
41
  # findings-bearing nonzero exit, never both.
42
42
  output: str | None = None
43
+ # RAN with output and count > 1 only: the span from the oldest coalesced
44
+ # run to now (e.g. "11h", "2d"), formatted without an "ago" suffix so it
45
+ # reads as a window ("... in last {window}:") rather than a point in time
46
+ # - see surface.py's per-job clean-output coalescing.
47
+ window: str | None = None
43
48
 
44
49
 
45
50
  def _ordinal(n: int) -> str:
@@ -90,7 +95,17 @@ def _line(report: JobReport) -> str | None:
90
95
  # distinct from the findings/warning case above so a passing check
91
96
  # is never mistaken for one that found something. One line-group:
92
97
  # never falls through to the bare "✓ ran" case below.
93
- header = f"✓ ran {report.job_id}{overdue}{age_suffix}:"
98
+ if report.count > 1:
99
+ # Several clean-output runs of the same job coalesced within
100
+ # one sweep (§ output-coalesce fix, mirrors the bare-run
101
+ # coalesce below) - one line carrying a count and the window
102
+ # they span, body is the most recent run's output only.
103
+ header = (
104
+ f"✓ ran {report.job_id}{age_suffix} "
105
+ f"({report.count} times in last {report.window}):"
106
+ )
107
+ else:
108
+ header = f"✓ ran {report.job_id}{overdue}{age_suffix}:"
94
109
  body = "\n".join(f" {line}" for line in report.output.splitlines())
95
110
  return f"{header}\n{body}"
96
111
  if report.count > 1:
@@ -17,6 +17,13 @@ unconditionally rather than the per-job flag, since digest.py never consults
17
17
  `surface` for Outcome.RAN — the per-job flag is only meaningful for LAUNCH,
18
18
  where it is still looked up via `_surface_flag`.
19
19
 
20
+ A clean (0-exit) run's captured stdout coalesces per job_id the same way bare
21
+ routine runs do: several such runs for the same job within one digest render
22
+ as one line carrying a count, the span they cover, and only the MOST RECENT
23
+ run's output (§ output-coalesce fix) — see `_output_reports`. Findings
24
+ (nonzero-exit captured stdout) are exempt, same as FAILED/SUSPENDED: they are
25
+ signal, never folded, always replayed individually with their own age suffix.
26
+
20
27
  `surface(..., lookback=...)` optionally widens the read below the session's
21
28
  own cursor to also pick up ledger activity within `lookback` of `now`, for the
22
29
  SessionStart 24h rolling lookback (§ widen fix) — see catchup.py, the only
@@ -81,6 +88,19 @@ def _format_age(sent: datetime | None, now: datetime) -> str:
81
88
  return f"{hours // 24}d ago"
82
89
 
83
90
 
91
+ def _format_span(oldest: datetime, now: datetime) -> str:
92
+ """Same buckets as `_format_age`, but as a window ("11h") rather than a
93
+ point in time ("11h ago") - for the "N times in last {span}" wording on
94
+ coalesced clean-output runs."""
95
+ minutes = max(1, int((now - oldest).total_seconds() // 60))
96
+ if minutes < 60:
97
+ return f"{minutes}m"
98
+ hours = minutes // 60
99
+ if hours < 24:
100
+ return f"{hours}h"
101
+ return f"{hours // 24}d"
102
+
103
+
84
104
  # Sort key for an entry whose ts is missing/unparseable - sorts first, same
85
105
  # convention as `_format_age`'s "unknown time ago" treating it as maximally old.
86
106
  _UNKNOWN_TS = datetime.min.replace(tzinfo=timezone.utc)
@@ -161,6 +181,51 @@ def _routine_reports(
161
181
  return out
162
182
 
163
183
 
184
+ def _output_reports(
185
+ entries: list[dict[str, object]], *, now: datetime,
186
+ ) -> list[tuple[datetime, JobReport]]:
187
+ """Clean-output (0-exit, captured stdout) RUN/BACKFILL entries, grouped by
188
+ job_id. A job_id with only one entry renders exactly as before (no count
189
+ suffix). A job_id with several entries within one digest coalesces to one
190
+ line carrying a count, the span the group covers, and only the MOST
191
+ RECENT entry's output - not N near-identical output blocks for the same
192
+ job (§ output-coalesce fix). Findings-bearing (nonzero-exit) entries never
193
+ reach this function - they are signal, not routine noise, and always
194
+ replay individually (see the caller)."""
195
+ groups: dict[str, list[dict[str, object]]] = {}
196
+ for e in entries:
197
+ groups.setdefault(str(e.get("job_id", "")), []).append(e)
198
+
199
+ out: list[tuple[datetime, JobReport]] = []
200
+ for job_id, group in groups.items():
201
+ timestamps = [_parse_ts(g.get("ts")) for g in group]
202
+ latest = max((t for t in timestamps if t is not None), default=None)
203
+ oldest = min((t for t in timestamps if t is not None), default=None)
204
+ sort_ts = latest if latest is not None else _UNKNOWN_TS
205
+ if len(group) == 1:
206
+ e = group[0]
207
+ out.append((sort_ts, JobReport(
208
+ job_id=job_id, outcome=Outcome.RAN, surface=True, overdue="",
209
+ ran=int(cast(int, e.get("ran", 0)) or 0), deferred=0, expired=0,
210
+ consecutive_failures=0, age=_format_age(latest, now),
211
+ output=str(e.get("error")),
212
+ )))
213
+ continue
214
+ # Most recent entry by ts (fall back to list order for unparseable ts).
215
+ latest_entry = max(
216
+ group, key=lambda g: (_parse_ts(g.get("ts")) or _UNKNOWN_TS),
217
+ )
218
+ window = _format_span(oldest, now) if oldest is not None else "unknown time"
219
+ out.append((sort_ts, JobReport(
220
+ job_id=job_id, outcome=Outcome.RAN, surface=True, overdue="",
221
+ ran=sum(int(cast(int, g.get("ran", 0)) or 0) for g in group),
222
+ deferred=0, expired=0, consecutive_failures=0,
223
+ age=_format_age(latest, now), count=len(group),
224
+ output=str(latest_entry.get("error")), window=window,
225
+ )))
226
+ return out
227
+
228
+
164
229
  def surface(
165
230
  *, session_uuid: str, now: datetime, lookback: timedelta | None = None,
166
231
  ) -> SurfaceResult:
@@ -185,6 +250,7 @@ def surface(
185
250
  # them.
186
251
  pairs: list[tuple[datetime, JobReport]] = []
187
252
  routine: list[dict[str, object]] = []
253
+ clean_output: list[dict[str, object]] = []
188
254
  for e in entries:
189
255
  event = str(e.get("event", ""))
190
256
  job_id = str(e.get("job_id", ""))
@@ -218,20 +284,23 @@ def surface(
218
284
  captured = e.get("error")
219
285
  if captured:
220
286
  is_warning = raw_exit not in (0, None)
221
- # surface=True unconditionally: a completed run is always
222
- # visible, so the per-job flag is not load-bearing here (see
223
- # module docstring) - matches the pre-existing findings
224
- # convention rather than the FAILED/SUSPENDED "write the real
225
- # flag, bypass it in digest.py" one, since digest.py does not
226
- # even look at `surface` for Outcome.RAN.
227
- pairs.append((sort_ts, JobReport(
228
- job_id=job_id, outcome=Outcome.RAN, surface=True, overdue="",
229
- ran=int(cast(int, e.get("ran", 0)) or 0), deferred=0, expired=0,
230
- consecutive_failures=0,
231
- findings=str(captured) if is_warning else None,
232
- output=str(captured) if not is_warning else None,
233
- age=_format_age(sent, now),
234
- )))
287
+ if is_warning:
288
+ # Findings are signal, not routine noise - never coalesced,
289
+ # always replayed individually (see module docstring).
290
+ # surface=True unconditionally: a completed run is always
291
+ # visible, so the per-job flag is not load-bearing here.
292
+ pairs.append((sort_ts, JobReport(
293
+ job_id=job_id, outcome=Outcome.RAN, surface=True, overdue="",
294
+ ran=int(cast(int, e.get("ran", 0)) or 0), deferred=0, expired=0,
295
+ consecutive_failures=0, findings=str(captured),
296
+ age=_format_age(sent, now),
297
+ )))
298
+ else:
299
+ # A clean (0-exit) run's captured stdout - eligible for
300
+ # per-job_id coalescing (§ output-coalesce fix), same
301
+ # spirit as the bare-run routine fold below but keeping
302
+ # the most recent output rather than dropping it.
303
+ clean_output.append(e)
235
304
  else:
236
305
  # A bare clean run with no captured stdout - still eligible
237
306
  # for the routine backlog fold (spam control, not the
@@ -249,6 +318,7 @@ def surface(
249
318
  # skip_expired and defer events are not surfaced as standalone lines.
250
319
 
251
320
  pairs.extend(_routine_reports(routine, now=now))
321
+ pairs.extend(_output_reports(clean_output, now=now))
252
322
  pairs.sort(key=lambda p: p[0])
253
323
 
254
324
  cursor.write_cursor(session_uuid, new_offset)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-tools
3
- Version: 2.12.2
3
+ Version: 2.12.4
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
@@ -93,7 +93,14 @@ def test_changed_job_is_reported_not_touched_and_not_overwritten(base_env):
93
93
 
94
94
  r = _run(base_env, "ccsched-jobs", "install", "--apply")
95
95
  assert r.returncode == 0, r.stderr
96
- assert "changed (not touched): telemetry-trim-weekly - timeout" in r.stdout
96
+ assert (
97
+ "changed (not touched): telemetry-trim-weekly - 1 field differs "
98
+ "from the bundled definition:" in r.stdout
99
+ )
100
+ assert "timeout:" in r.stdout
101
+ assert "- bundled: 60s" in r.stdout
102
+ assert "+ current: 9s" in r.stdout
103
+ assert "run 'ccsched edit telemetry-trim-weekly' to realign" in r.stdout
97
104
  assert "already registered: telemetry-trim-weekly" not in r.stdout
98
105
 
99
106
  show = subprocess.run(
@@ -1207,6 +1207,34 @@ def test_check_ccsched_job_registered_warns_on_drift_from_bundled_definition(
1207
1207
  assert "timeout" in result.reason
1208
1208
 
1209
1209
 
1210
+ def test_check_ccsched_job_registered_shows_before_and_after_values_not_just_field_name(
1211
+ monkeypatch, tmp_path
1212
+ ) -> None:
1213
+ """§ diff-detail fix: the reason must carry the actual bundled/current values, not just
1214
+ name the field that differs — no more 'run install --apply to see the diff' indirection."""
1215
+ monkeypatch.setenv("CCST_DATA_HOME", str(tmp_path))
1216
+ from cc_session_tools.lib.scheduler import bundled_jobs, registry
1217
+ from cc_session_tools.lib.scheduler.jobspec import validate_job_fields
1218
+
1219
+ job = next(
1220
+ j for j in bundled_jobs.BUNDLED_CCSCHED_JOBS if j.job_id == "telemetry-trim-weekly"
1221
+ )
1222
+ spec = validate_job_fields(
1223
+ job_id=job.job_id, cadence=job.cadence, coalesce=job.coalesce,
1224
+ command=list(job.command), surface=job.surface, enabled=True,
1225
+ catchup_window=job.catchup_window, timeout="9s",
1226
+ success_exit_codes=job.success_exit_codes,
1227
+ )
1228
+ registry.add_job(spec)
1229
+
1230
+ result = check_ccsched_job_registered(job.job_id, expected=job)
1231
+ assert "1 field differ" in result.reason
1232
+ assert "- bundled: 60s" in result.reason
1233
+ assert "+ current: 9s" in result.reason
1234
+ assert "run 'ccsched edit telemetry-trim-weekly' to realign" in result.reason
1235
+ assert "install --apply' to see the diff" not in result.reason
1236
+
1237
+
1210
1238
  def test_check_ccsched_job_registered_ok_when_matching_bundled_definition(
1211
1239
  monkeypatch, tmp_path
1212
1240
  ) -> None:
@@ -390,11 +390,65 @@ def test_resolve_diagnostic_mode_clean_when_no_diff(base_env):
390
390
  _build_local("proj", vector={"ltxy": 1})
391
391
  dump_pub = _run(base_env, "pdata", "dump", "--project", "proj")
392
392
  assert dump_pub.returncode == 0, dump_pub.stderr
393
+ published = dump.read_latest(store.project_root("proj"))
393
394
 
394
395
  r = _run(base_env, "pdata", "resolve", "--project", "proj")
395
396
 
396
397
  assert r.returncode == 0, r.stdout
397
398
  assert "clean" in r.stdout
399
+ assert "auto-closed" not in r.stdout
400
+ # No fork exists here (local published its own dump, nothing to converge) - diagnose mode
401
+ # must stay side-effect-free in this, the overwhelmingly common case: same vector, same
402
+ # dumped_at, i.e. nothing was silently re-published underneath the "clean" message.
403
+ still_published = dump.read_latest(store.project_root("proj"))
404
+ assert still_published.vector == published.vector
405
+ assert still_published.dumped_at == published.dumped_at
406
+
407
+
408
+ def test_resolve_diagnostic_mode_closes_a_fork_automatically_when_content_already_matches(
409
+ base_env,
410
+ ):
411
+ """Reproduces the real dead end found 2026-08-31 (`home` project's fork-testing walkthrough):
412
+ two machines each made a local write since they last synced (a genuine fork - neither vector
413
+ dominates), but the resulting content is identical on both sides, so there is nothing for a
414
+ human to choose between. `ccst pdata resolve` must close it automatically rather than report
415
+ "clean" while leaving `dump`/`rehydrate` refusing forever with only `--force` to unblock it."""
416
+ _build_local("proj", content="same-content", vector={"ltxy": 1})
417
+ project_root = store.project_root("proj")
418
+ _publish_remote_dump(
419
+ project_root, remote_project="proj-remote", content="same-content",
420
+ vector={"mbp": 1}, machine_id="mbp",
421
+ )
422
+
423
+ # Confirm the fork is real before resolving it - `dump` refuses.
424
+ precheck = _run(base_env, "pdata", "dump", "--project", "proj")
425
+ assert precheck.returncode == 1
426
+
427
+ r = _run(base_env, "pdata", "resolve", "--project", "proj")
428
+
429
+ assert r.returncode == 0, r.stdout
430
+ assert "clean" in r.stdout
431
+ assert "auto-closed" in r.stdout or "closed the vector-clock fork automatically" in r.stdout
432
+
433
+ # The fork is genuinely gone now, not just hidden - both directions work without --force.
434
+ postcheck = _run(base_env, "pdata", "dump", "--project", "proj")
435
+ assert postcheck.returncode == 0, postcheck.stderr
436
+ info = dump.read_latest(project_root)
437
+ assert info.vector == {"ltxy": 2, "mbp": 1}
438
+
439
+
440
+ def test_resolve_diagnostic_all_projects_reports_auto_closed_fork(base_env):
441
+ _build_local("proj", content="same-content", vector={"ltxy": 1})
442
+ project_root = store.project_root("proj")
443
+ _publish_remote_dump(
444
+ project_root, remote_project="proj-remote", content="same-content",
445
+ vector={"mbp": 1}, machine_id="mbp",
446
+ )
447
+
448
+ r = _run(base_env, "pdata", "resolve", "--all-projects")
449
+
450
+ assert r.returncode == 0, r.stdout
451
+ assert "proj: clean (fork auto-closed)" in r.stdout
398
452
 
399
453
 
400
454
  def test_resolve_diagnostic_mode_reports_outstanding_records_and_schema_fields(base_env):
@@ -3,7 +3,9 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
 
5
5
  from cc_session_tools.lib.scheduler import bundled_jobs
6
- from cc_session_tools.lib.scheduler.bundled_jobs import diff_from_bundled
6
+ from cc_session_tools.lib.scheduler.bundled_jobs import (
7
+ diff_from_bundled, diff_from_bundled_detail, render_field_diffs,
8
+ )
7
9
  from cc_session_tools.lib.scheduler.jobspec import validate_job_fields
8
10
 
9
11
 
@@ -209,3 +211,60 @@ def test_diff_from_bundled_detects_a_changed_command():
209
211
  job = _job("pdata-verify-all")
210
212
  spec = _spec_for(job, command=["ccst", "pdata", "verify", "--project", "x"])
211
213
  assert diff_from_bundled(spec, job) == ("command",)
214
+
215
+
216
+ def test_diff_from_bundled_detail_is_empty_when_nothing_differs():
217
+ job = _job("pdata-verify-all")
218
+ assert diff_from_bundled_detail(_spec_for(job), job) == ()
219
+
220
+
221
+ def test_diff_from_bundled_detail_carries_before_and_after_for_one_field():
222
+ job = _job("pdata-verify-all")
223
+ spec = _spec_for(job, timeout="30s")
224
+ diffs = diff_from_bundled_detail(spec, job)
225
+ assert len(diffs) == 1
226
+ assert diffs[0].field == "timeout"
227
+ assert diffs[0].bundled == job.timeout
228
+ assert diffs[0].current == "30s"
229
+
230
+
231
+ def test_diff_from_bundled_detail_carries_every_differing_field_in_order():
232
+ job = _job("pdata-verify-all")
233
+ spec = _spec_for(job, cadence="every:3d", timeout="30s")
234
+ diffs = diff_from_bundled_detail(spec, job)
235
+ assert [d.field for d in diffs] == ["cadence", "timeout"]
236
+ assert diffs[0].bundled == job.cadence
237
+ assert diffs[0].current == "every:3d"
238
+
239
+
240
+ def test_diff_from_bundled_detail_renders_command_as_a_shell_line_not_a_tuple_repr():
241
+ job = _job("pdata-verify-all")
242
+ spec = _spec_for(job, command=["ccst", "pdata", "verify", "--project", "x y"])
243
+ diffs = diff_from_bundled_detail(spec, job)
244
+ assert len(diffs) == 1
245
+ assert diffs[0].field == "command"
246
+ assert diffs[0].bundled == "ccst pdata verify --all-projects"
247
+ # shlex.join quotes the argument containing a space, unlike a bare join(" ")
248
+ assert diffs[0].current == "ccst pdata verify --project 'x y'"
249
+
250
+
251
+ def test_diff_from_bundled_detail_is_the_single_source_diff_from_bundled_wraps():
252
+ """diff_from_bundled must return exactly the field names diff_from_bundled_detail
253
+ reports, in the same order — the two can never disagree since one wraps the other."""
254
+ job = _job("pdata-verify-all")
255
+ spec = _spec_for(job, cadence="every:3d", timeout="30s")
256
+ assert diff_from_bundled(spec, job) == tuple(
257
+ d.field for d in diff_from_bundled_detail(spec, job)
258
+ )
259
+
260
+
261
+ def test_render_field_diffs_shows_dash_bundled_plus_current_per_field():
262
+ job = _job("pdata-verify-all")
263
+ spec = _spec_for(job, cadence="every:3d", timeout="30s")
264
+ out = render_field_diffs(diff_from_bundled_detail(spec, job))
265
+ assert "cadence:" in out
266
+ assert "- bundled: daily@03:00" in out
267
+ assert "+ current: every:3d" in out
268
+ assert "timeout:" in out
269
+ assert "- bundled: 300s" in out
270
+ assert "+ current: 30s" in out