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