cc-session-tools 2.1.0__tar.gz → 2.2.0__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.1.0/src/cc_session_tools.egg-info → cc_session_tools-2.2.0}/PKG-INFO +1 -1
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/pyproject.toml +1 -1
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccr.py +25 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccs.py +60 -4
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccst.py +83 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/doctor.py +33 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/sessions_db.py +37 -0
- cc_session_tools-2.2.0/src/cc_session_tools/lib/sessions_repair.py +128 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0/src/cc_session_tools.egg-info}/PKG-INFO +1 -1
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/SOURCES.txt +3 -0
- cc_session_tools-2.2.0/src/cccs_hooks/after_response.py +46 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/session_tag.py +27 -8
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_after_response.py +21 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_sentinel_sort.py +120 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_doctor.py +73 -0
- cc_session_tools-2.2.0/tests/test_ccst_repair_sessions_cli.py +147 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_ccr.py +71 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_session_tag.py +26 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_sessions_db.py +30 -0
- cc_session_tools-2.2.0/tests/test_sessions_repair.py +208 -0
- cc_session_tools-2.1.0/src/cccs_hooks/after_response.py +0 -34
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/LICENSE +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/README.md +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/setup.cfg +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/__init__.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/__init__.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccd.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccmsg.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/ccsched.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/migrate_ccmsg.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/migrate_ccsched.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/migrate_sessions_db.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/cli/migrate_telemetry.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/hooks_install.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/__init__.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/claude_flags.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/claude_md_install.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/db.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/debug.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/doctor_mutes.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/hook_registry.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/levenshtein.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/__init__.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/addressing.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/cursor.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/lock.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/message.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/move_safety.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/repository.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/retention.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/service.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/messaging/store.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/paths.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/__init__.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/backup.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/classify.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/cutover.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/formatting.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/importers.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/init_paths.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/init_service.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/manifest.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/naming.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/repository.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/service.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/session_output.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/store.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/pdata/verify.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/picker.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/prompts.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/roots.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/rules.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/__init__.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/bundled_jobs.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/cadence.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/cursor.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/digest.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/due.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/duration.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/jobspec.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/ledger.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/lock.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/notify.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/reconcile.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/registry.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/runner.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/state.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/store.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/surface.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/throttle.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/scheduler/worker.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/session_gc.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/sessions.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/shell_install.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/tasklist.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools/lib/telemetry_store.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/dependency_links.txt +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/entry_points.txt +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/requires.txt +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cc_session_tools.egg-info/top_level.txt +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/__init__.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/bash_hard_deny.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/bash_security_review.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/cache.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/catchup.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/confirm_8digit.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/last_screenshot.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/marker_allow.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/markers.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/messaging_deliver.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/normalise.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/pending_migration.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/stats.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/telemetry.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/telemetry_query.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/telemetry_trim.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/transcript.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/cccs_hooks/worklog_guard.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/__init__.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/attribution.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/cache.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/ccusage_wrapper.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/data/pricing.json +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/parent_inference.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/parser.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/pricing.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/query.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/report.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/schema.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/src/claude_code_usage/session_names.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_attribution.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_bash_hard_deny.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_bash_security_review.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cache.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cache_sqlite.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccr_orphans.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_emptiness.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_eta.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_list_mode.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_scope_flags.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccs_session_counts.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_ccsched_jobs_cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_gc_report.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_hook_dispatcher.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_hooks_install_prune.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_hooks_install_zero_arg.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_hooks_uninstall.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_install_everything.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_pdata_cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_pdata_init_cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_pdata_reconcile_cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_pdata_verify_cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_sessions_cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_shell_install.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_skills_install.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_skills_uninstall.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_telemetry_query.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccst_telemetry_trim.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_ccusage_wrapper.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_claude_flags.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_claude_md_install.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_ccd.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_ccd_dry_run.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_ccs.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_cli_version.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_confirm_8digit.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_db.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_debug.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_doctor_mutes.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_empty_session.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_hook_cache.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_hooks_install.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_last_screenshot.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_levenshtein.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_marker_allow.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_migrate_ccmsg.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_migrate_ccsched.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_migrate_sessions_db.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_migrate_telemetry.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_normalise.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_parent_inference.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_parser.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_paths.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_pending_migration_hook.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_picker.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_pricing.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_prompts.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_query.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_report.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_roots.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_rules.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_scheduler_bundled_jobs.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_schema.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_session_names.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_sessions.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_tasklist.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_telemetry.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_telemetry_store.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/tests/test_transcript.py +0 -0
- {cc_session_tools-2.1.0 → cc_session_tools-2.2.0}/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.
|
|
7
|
+
version = "2.2.0"
|
|
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" }
|
|
@@ -23,6 +23,30 @@ def _format_size(num_bytes: int) -> str:
|
|
|
23
23
|
return f"{size:.1f}GB"
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _warn_if_fragment_matches_corrupted_row(fragment: str) -> None:
|
|
27
|
+
"""Print a stderr diagnostic when `fragment` matches a sessions.db row whose project_dir
|
|
28
|
+
is non-absolute. Such a row is invisible to every lookup path here — the exact-match fast
|
|
29
|
+
path and find_matching_sessions both filter by root, and find_orphan_transcripts skips it
|
|
30
|
+
because its cc-sessions/<name>/ directory already exists on disk — so without this it reads
|
|
31
|
+
as an ordinary "not found" rather than a fixable data problem. Called unconditionally
|
|
32
|
+
whenever `matches` is computed, not only when it's empty, so a corrupted sibling of a
|
|
33
|
+
resumable match is still surfaced. Mirrors ccs.py's _warn_if_corrupted_rows_present."""
|
|
34
|
+
from cc_session_tools.lib import sessions_repair
|
|
35
|
+
|
|
36
|
+
corrupted = [
|
|
37
|
+
row for row in sessions_repair.find_non_absolute_rows()
|
|
38
|
+
if fragment in row.basename
|
|
39
|
+
]
|
|
40
|
+
if corrupted:
|
|
41
|
+
names = ", ".join(row.basename for row in corrupted)
|
|
42
|
+
print(
|
|
43
|
+
f"ccr: {len(corrupted)} session(s) matching '{fragment}' have an invalid "
|
|
44
|
+
f"(non-absolute) project_dir and can't be resumed by name: {names} — run "
|
|
45
|
+
"'ccst repair sessions --dry-run' to see them, then --execute to fix.",
|
|
46
|
+
file=sys.stderr,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
26
50
|
def _pick_duplicate_transcript(basename: str, candidates: list[Path]) -> Path | None:
|
|
27
51
|
"""Multiple JSONL transcripts share one session tag - e.g. hitting Ctrl-L
|
|
28
52
|
twice mid-session clears one copy while `claude --resume` still lists it
|
|
@@ -114,6 +138,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
114
138
|
matches.extend(o for o in orphans if o.basename not in on_disk_basenames)
|
|
115
139
|
debug(f"fragment: {args.fragment!r}")
|
|
116
140
|
debug(f"matches: {[m.basename for m in matches]}")
|
|
141
|
+
_warn_if_fragment_matches_corrupted_row(args.fragment)
|
|
117
142
|
|
|
118
143
|
if not matches:
|
|
119
144
|
print(f"ccr: no sessions match '{args.fragment}'", file=sys.stderr)
|
|
@@ -398,8 +398,10 @@ def _collect_session_rows(
|
|
|
398
398
|
order_by/limit push an indexed ORDER BY ... LIMIT into SQL (see
|
|
399
399
|
sessions_db.list_sessions) when order_by is a DB-backed column - this is
|
|
400
400
|
what makes "most recent N" an O(log n) lookup, not a fetch-everything-then-
|
|
401
|
-
slice. For --global, the root filter is applied after the DB query
|
|
402
|
-
|
|
401
|
+
slice. For --global, the root filter is applied after the DB query — see
|
|
402
|
+
_fetch_global_limited() for how the limit case stays correct despite that,
|
|
403
|
+
and _warn_if_corrupted_rows_present() for the stderr diagnostic printed
|
|
404
|
+
whenever a non-absolute project_dir row is found and omitted.
|
|
403
405
|
|
|
404
406
|
`order_by` uses ccs's flag vocabulary ("opened"/"active"); it is mapped
|
|
405
407
|
here to the sessions.db column names ("last_opened"/"last_active").
|
|
@@ -413,8 +415,11 @@ def _collect_session_rows(
|
|
|
413
415
|
except RootsConfigError as e:
|
|
414
416
|
print(str(e), file=sys.stderr)
|
|
415
417
|
sys.exit(1)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
+
if db_limit is None:
|
|
419
|
+
rows = sessions_db.list_sessions(order_by=db_order_by, limit=None)
|
|
420
|
+
_warn_if_corrupted_rows_present(rows)
|
|
421
|
+
return [r for r in rows if r.project_dir.parent in roots]
|
|
422
|
+
return _fetch_global_limited(roots, db_order_by, db_limit)
|
|
418
423
|
|
|
419
424
|
cwd = Path.cwd().resolve()
|
|
420
425
|
return sessions_db.list_sessions(
|
|
@@ -422,6 +427,49 @@ def _collect_session_rows(
|
|
|
422
427
|
)
|
|
423
428
|
|
|
424
429
|
|
|
430
|
+
_GLOBAL_LIMIT_GROWTH_FACTOR = 4
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _warn_if_corrupted_rows_present(rows: list["sessions_db.SessionRow"]) -> None:
|
|
434
|
+
"""Print a visible (user- and Claude-Code-visible, via stderr) heads-up when any fetched
|
|
435
|
+
row has a non-absolute project_dir — these are the specific rows `ccst repair sessions`
|
|
436
|
+
can fix, as opposed to ordinary out-of-root rows (a project simply not under a configured
|
|
437
|
+
root), which are normal scoping and not flagged."""
|
|
438
|
+
corrupted = sum(1 for r in rows if not r.project_dir.is_absolute())
|
|
439
|
+
if corrupted:
|
|
440
|
+
print(
|
|
441
|
+
f"ccs: {corrupted} session(s) in sessions.db have an invalid (non-absolute) "
|
|
442
|
+
"project_dir and are missing from this --global listing — run "
|
|
443
|
+
"'ccst repair sessions --dry-run' to see them, then --execute to fix.",
|
|
444
|
+
file=sys.stderr,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _fetch_global_limited(
|
|
449
|
+
roots: list[Path], db_order_by: str, db_limit: int
|
|
450
|
+
) -> list["sessions_db.SessionRow"]:
|
|
451
|
+
"""Fetch the db_limit most-recent in-root rows for --global.
|
|
452
|
+
|
|
453
|
+
A plain `ORDER BY ... LIMIT db_limit` applied before the root filter can
|
|
454
|
+
starve the result: any out-of-root (or corrupted — see
|
|
455
|
+
_warn_if_corrupted_rows_present) row sitting ahead of a legitimate one in
|
|
456
|
+
last_active/last_opened order consumes a LIMIT slot that never gets
|
|
457
|
+
root-filtered back in. Grow the DB-side fetch window geometrically until
|
|
458
|
+
either db_limit in-root rows have survived the filter or the table is
|
|
459
|
+
exhausted (`len(rows) < fetch` signals exhaustion) — this keeps the
|
|
460
|
+
common case (most rows are in-root) at one query while staying correct
|
|
461
|
+
when it isn't. The corrupted-row warning fires once, on the final fetch.
|
|
462
|
+
"""
|
|
463
|
+
fetch = db_limit
|
|
464
|
+
while True:
|
|
465
|
+
rows = sessions_db.list_sessions(order_by=db_order_by, limit=fetch)
|
|
466
|
+
filtered = [r for r in rows if r.project_dir.parent in roots]
|
|
467
|
+
if len(filtered) >= db_limit or len(rows) < fetch:
|
|
468
|
+
_warn_if_corrupted_rows_present(rows)
|
|
469
|
+
return filtered[:db_limit]
|
|
470
|
+
fetch *= _GLOBAL_LIMIT_GROWTH_FACTOR
|
|
471
|
+
|
|
472
|
+
|
|
425
473
|
def _display_path(p: Path) -> str:
|
|
426
474
|
home = Path.home()
|
|
427
475
|
try:
|
|
@@ -1056,6 +1104,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1056
1104
|
file=sys.stderr,
|
|
1057
1105
|
)
|
|
1058
1106
|
return 2
|
|
1107
|
+
if args.limit is not None and args.limit < 1:
|
|
1108
|
+
print(
|
|
1109
|
+
f"ccs: --limit must be a positive integer, got {args.limit} "
|
|
1110
|
+
"(SQLite treats a negative LIMIT as unbounded, and Python's "
|
|
1111
|
+
"list[:N] slicing would silently drop from the wrong end)",
|
|
1112
|
+
file=sys.stderr,
|
|
1113
|
+
)
|
|
1114
|
+
return 2
|
|
1059
1115
|
|
|
1060
1116
|
# Validate date filter args eagerly so bad values are caught before any I/O.
|
|
1061
1117
|
date_filter: tuple[str | None, str | None] | None = None
|
|
@@ -674,6 +674,7 @@ def _cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
674
674
|
legacy_migration_paths=legacy_migration_paths,
|
|
675
675
|
projects_root=default_projects_root(),
|
|
676
676
|
pdata_verify_projects=_pdata_verify.discover_projects(),
|
|
677
|
+
sessions_db_path=store_paths["sessions"],
|
|
677
678
|
)
|
|
678
679
|
|
|
679
680
|
if args.drift or getattr(args, "mode", None) == "drift":
|
|
@@ -1245,6 +1246,58 @@ def _cmd_sessions_list(args: argparse.Namespace) -> int:
|
|
|
1245
1246
|
return 0
|
|
1246
1247
|
|
|
1247
1248
|
|
|
1249
|
+
def _cmd_repair_sessions(args: argparse.Namespace) -> int:
|
|
1250
|
+
from cc_session_tools.lib import db as db_lib
|
|
1251
|
+
from cc_session_tools.lib import sessions_db, sessions_repair
|
|
1252
|
+
from cc_session_tools.lib.roots import RootsConfigError, load_session_roots
|
|
1253
|
+
|
|
1254
|
+
db_path = Path(args.sessions_db) if args.sessions_db else sessions_db.default_db_path()
|
|
1255
|
+
try:
|
|
1256
|
+
roots = load_session_roots()
|
|
1257
|
+
except RootsConfigError as e:
|
|
1258
|
+
print(str(e), file=sys.stderr)
|
|
1259
|
+
return 1
|
|
1260
|
+
|
|
1261
|
+
if args.execute:
|
|
1262
|
+
if not db_path.exists():
|
|
1263
|
+
# sqlite3.connect() auto-creates an empty file, so without this check
|
|
1264
|
+
# db_lib.backup_to() below would silently back up (and repair() would open)
|
|
1265
|
+
# a brand-new, empty sessions.db instead of failing loudly on the mistake.
|
|
1266
|
+
print(f"No sessions.db found at {db_path} — nothing to back up or repair.", file=sys.stderr)
|
|
1267
|
+
return 1
|
|
1268
|
+
backup_dir = db_path.parent / "repair-backups"
|
|
1269
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
1270
|
+
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
1271
|
+
backup_path = backup_dir / f"sessions-{stamp}.db"
|
|
1272
|
+
db_lib.backup_to(db_path, backup_path)
|
|
1273
|
+
print(f"Backed up sessions.db to {backup_path}")
|
|
1274
|
+
|
|
1275
|
+
report = sessions_repair.repair(roots, path=db_path, dry_run=not args.execute)
|
|
1276
|
+
if not any((report.repaired, report.unresolved, report.ambiguous, report.conflicts)):
|
|
1277
|
+
print("No non-absolute project_dir rows found in sessions.db.")
|
|
1278
|
+
return 0
|
|
1279
|
+
|
|
1280
|
+
verb = "fixed" if args.execute else "would fix"
|
|
1281
|
+
for basename, new_dir in report.repaired:
|
|
1282
|
+
print(f" {verb}: {basename} -> {new_dir}")
|
|
1283
|
+
for basename in report.unresolved:
|
|
1284
|
+
print(
|
|
1285
|
+
f" UNRESOLVED (no on-disk cc-sessions/{basename} under any root): {basename}",
|
|
1286
|
+
file=sys.stderr,
|
|
1287
|
+
)
|
|
1288
|
+
for basename, candidates in report.ambiguous.items():
|
|
1289
|
+
print(f" AMBIGUOUS ({len(candidates)} candidates, skipped): {basename}", file=sys.stderr)
|
|
1290
|
+
for basename in report.conflicts:
|
|
1291
|
+
print(
|
|
1292
|
+
f" CONFLICT (a correct row for this basename already exists, skipped): {basename}",
|
|
1293
|
+
file=sys.stderr,
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
if not args.execute and report.repaired:
|
|
1297
|
+
print(f"\n{len(report.repaired)} row(s) would be repaired. Re-run with --execute to apply.")
|
|
1298
|
+
return 1 if (report.unresolved or report.ambiguous or report.conflicts) else 0
|
|
1299
|
+
|
|
1300
|
+
|
|
1248
1301
|
def _fmt_ts(epoch: float) -> str:
|
|
1249
1302
|
if not epoch:
|
|
1250
1303
|
return "(never)"
|
|
@@ -2034,6 +2087,32 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2034
2087
|
"Claude Code session.")
|
|
2035
2088
|
m_all.add_argument("--dry-run", action="store_true")
|
|
2036
2089
|
|
|
2090
|
+
# ---- repair ----
|
|
2091
|
+
repair_parser = sub.add_parser("repair", help="Repair known sessions.db/store corruption")
|
|
2092
|
+
repair_sub = repair_parser.add_subparsers(dest="verb", metavar="<verb>")
|
|
2093
|
+
repair_sub.required = True
|
|
2094
|
+
r_sessions = repair_sub.add_parser(
|
|
2095
|
+
"sessions",
|
|
2096
|
+
help=(
|
|
2097
|
+
"Fix sessions.db rows whose project_dir is not an absolute path (invisible "
|
|
2098
|
+
"to `ccl`/`ccs --global`) by re-resolving them from on-disk cc-sessions/ "
|
|
2099
|
+
"directories. Dry-run by default."
|
|
2100
|
+
),
|
|
2101
|
+
)
|
|
2102
|
+
r_sessions_mode = r_sessions.add_mutually_exclusive_group()
|
|
2103
|
+
r_sessions_mode.add_argument(
|
|
2104
|
+
"--execute", action="store_true",
|
|
2105
|
+
help="Apply the repair (default: dry-run, print what would change).",
|
|
2106
|
+
)
|
|
2107
|
+
r_sessions_mode.add_argument(
|
|
2108
|
+
"--dry-run", action="store_true",
|
|
2109
|
+
help="Print what would change without writing (this is the default).",
|
|
2110
|
+
)
|
|
2111
|
+
r_sessions.add_argument(
|
|
2112
|
+
"--sessions-db", default=None, metavar="PATH",
|
|
2113
|
+
help="sessions.db path override (default: from CCST_SESSIONS_DIR)",
|
|
2114
|
+
)
|
|
2115
|
+
|
|
2037
2116
|
# ---- ccsched-jobs ----
|
|
2038
2117
|
ccsched_jobs_parser = sub.add_parser(
|
|
2039
2118
|
"ccsched-jobs", help="Provision CCST-bundled ccsched jobs",
|
|
@@ -2188,6 +2267,10 @@ def main() -> None:
|
|
|
2188
2267
|
if args.verb == "all":
|
|
2189
2268
|
sys.exit(_cmd_migrate_all(args))
|
|
2190
2269
|
|
|
2270
|
+
if args.noun == "repair":
|
|
2271
|
+
if args.verb == "sessions":
|
|
2272
|
+
sys.exit(_cmd_repair_sessions(args))
|
|
2273
|
+
|
|
2191
2274
|
if args.noun == "ccsched-jobs":
|
|
2192
2275
|
if args.verb == "install":
|
|
2193
2276
|
sys.exit(_cmd_ccsched_jobs_install(args))
|
|
@@ -677,6 +677,31 @@ def check_pdata_verify(projects: list[str]) -> list[CheckResult]:
|
|
|
677
677
|
return results
|
|
678
678
|
|
|
679
679
|
|
|
680
|
+
def check_sessions_project_dir_absolute(sessions_db_path: Path) -> list[CheckResult]:
|
|
681
|
+
"""WARN if any sessions.db row has a non-absolute project_dir. Every reader that
|
|
682
|
+
scopes --global by root (ccs.py, sessions.find_matching_sessions) compares
|
|
683
|
+
project_dir.parent against a resolved, absolute root — a relative project_dir can
|
|
684
|
+
never match, so the row becomes permanently invisible to --global listings without
|
|
685
|
+
ever raising an error. Not FAIL: it degrades --global listings but does not block
|
|
686
|
+
core functionality, and `ccst repair sessions` fixes it non-destructively."""
|
|
687
|
+
from cc_session_tools.lib import sessions_repair
|
|
688
|
+
|
|
689
|
+
bad = sessions_repair.find_non_absolute_rows(path=sessions_db_path)
|
|
690
|
+
if not bad:
|
|
691
|
+
return [CheckResult(
|
|
692
|
+
name="sessions:project-dir-absolute", status=Status.OK,
|
|
693
|
+
reason="all sessions.db rows have an absolute project_dir",
|
|
694
|
+
)]
|
|
695
|
+
return [CheckResult(
|
|
696
|
+
name="sessions:project-dir-absolute", status=Status.WARN,
|
|
697
|
+
reason=(
|
|
698
|
+
f"{len(bad)} sessions.db row(s) have a non-absolute project_dir and are "
|
|
699
|
+
"invisible to `ccl`/`ccs --global` — run 'ccst repair sessions --dry-run' "
|
|
700
|
+
"to see them, then --execute to fix"
|
|
701
|
+
),
|
|
702
|
+
)]
|
|
703
|
+
|
|
704
|
+
|
|
680
705
|
# ---------- high-level runner ----------
|
|
681
706
|
|
|
682
707
|
|
|
@@ -693,6 +718,7 @@ def run_all_checks(
|
|
|
693
718
|
legacy_migration_paths: LegacyMigrationPaths | None = None,
|
|
694
719
|
projects_root: Path | None = None,
|
|
695
720
|
pdata_verify_projects: list[str] | None = None,
|
|
721
|
+
sessions_db_path: Path | None = None,
|
|
696
722
|
) -> list[CheckResult]:
|
|
697
723
|
"""Run the full doctor suite and return results.
|
|
698
724
|
|
|
@@ -726,6 +752,9 @@ def run_all_checks(
|
|
|
726
752
|
Project names whose last persisted ``ccst pdata verify`` result should
|
|
727
753
|
be reported; when None, the pdata-verify checks are skipped. Never
|
|
728
754
|
triggers a verify run — only reads what the recurring job left behind.
|
|
755
|
+
sessions_db_path:
|
|
756
|
+
Path to sessions.db; when None, the project_dir-absolute check is
|
|
757
|
+
skipped.
|
|
729
758
|
"""
|
|
730
759
|
results: list[CheckResult] = []
|
|
731
760
|
|
|
@@ -794,6 +823,10 @@ def run_all_checks(
|
|
|
794
823
|
if pdata_verify_projects is not None:
|
|
795
824
|
results.extend(check_pdata_verify(pdata_verify_projects))
|
|
796
825
|
|
|
826
|
+
# Non-absolute project_dir rows in sessions.db
|
|
827
|
+
if sessions_db_path is not None:
|
|
828
|
+
results.extend(check_sessions_project_dir_absolute(sessions_db_path))
|
|
829
|
+
|
|
797
830
|
# PyPI version check
|
|
798
831
|
if not skip_pypi:
|
|
799
832
|
results.append(check_pypi_version(installed_version))
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
import os
|
|
23
23
|
import sqlite3
|
|
24
|
+
import sys
|
|
24
25
|
import time
|
|
25
26
|
from dataclasses import dataclass
|
|
26
27
|
from datetime import datetime, timezone
|
|
@@ -156,6 +157,27 @@ def _row_to_session(row: sqlite3.Row) -> SessionRow:
|
|
|
156
157
|
)
|
|
157
158
|
|
|
158
159
|
|
|
160
|
+
def _reject_non_absolute_project_dir(project_dir: Path, basename: str, *, source: str) -> None:
|
|
161
|
+
"""Print a clear, unmissable diagnostic to stderr — visible to a human reading the
|
|
162
|
+
terminal AND to Claude Code, which surfaces hook stderr as tool/hook output. A silent
|
|
163
|
+
rejection here means this row is permanently invisible to `ccl`/`ccs --global` until
|
|
164
|
+
repaired, so every rejection must say so out loud."""
|
|
165
|
+
print(
|
|
166
|
+
f"[sessions-db] REJECTED write from {source}: project_dir={str(project_dir)!r} for "
|
|
167
|
+
f"session {basename!r} is not absolute. This session will be MISSING from `ccl`/"
|
|
168
|
+
"`ccs --global` listings until fixed — run 'ccst repair sessions --dry-run' to see "
|
|
169
|
+
"it, then --execute to fix.",
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _is_valid_project_dir(project_dir: Path, basename: str, *, source: str) -> bool:
|
|
175
|
+
if project_dir.is_absolute():
|
|
176
|
+
return True
|
|
177
|
+
_reject_non_absolute_project_dir(project_dir, basename, source=source)
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
159
181
|
def ensure_session_row(
|
|
160
182
|
project_dir: Path,
|
|
161
183
|
basename: str,
|
|
@@ -169,12 +191,17 @@ def ensure_session_row(
|
|
|
169
191
|
fires (hooks disabled/broken); the hook's own touch_last_opened() upsert
|
|
170
192
|
is the normal path and would create the same row moments later regardless.
|
|
171
193
|
|
|
194
|
+
Also a no-op — with a loud stderr diagnostic — when `project_dir` is not
|
|
195
|
+
absolute; see `_is_valid_project_dir`.
|
|
196
|
+
|
|
172
197
|
conn, if given, is reused as-is (caller owns commit/close) — see write_tag."""
|
|
173
198
|
from cc_session_tools.lib.sessions import session_start_date
|
|
174
199
|
|
|
175
200
|
start_date = session_start_date(basename)
|
|
176
201
|
if start_date is None:
|
|
177
202
|
return
|
|
203
|
+
if not _is_valid_project_dir(project_dir, basename, source="ensure_session_row"):
|
|
204
|
+
return
|
|
178
205
|
owns_conn = conn is None
|
|
179
206
|
c = conn if conn is not None else connect(path=path)
|
|
180
207
|
try:
|
|
@@ -201,12 +228,17 @@ def touch_last_opened(
|
|
|
201
228
|
) -> None:
|
|
202
229
|
"""Upsert the last_opened timestamp (epoch seconds) for (project_dir, basename).
|
|
203
230
|
|
|
231
|
+
Also a no-op — with a loud stderr diagnostic — when `project_dir` is not
|
|
232
|
+
absolute; see `_is_valid_project_dir`.
|
|
233
|
+
|
|
204
234
|
conn, if given, is reused as-is (caller owns commit/close) — see write_tag."""
|
|
205
235
|
from cc_session_tools.lib.sessions import session_start_date
|
|
206
236
|
|
|
207
237
|
start_date = session_start_date(basename)
|
|
208
238
|
if start_date is None:
|
|
209
239
|
return
|
|
240
|
+
if not _is_valid_project_dir(project_dir, basename, source="touch_last_opened"):
|
|
241
|
+
return
|
|
210
242
|
ts = when if when is not None else time.time()
|
|
211
243
|
owns_conn = conn is None
|
|
212
244
|
c = conn if conn is not None else connect(path=path)
|
|
@@ -234,12 +266,17 @@ def touch_last_active(
|
|
|
234
266
|
) -> None:
|
|
235
267
|
"""Upsert the last_active timestamp (epoch seconds) for (project_dir, basename).
|
|
236
268
|
|
|
269
|
+
Also a no-op — with a loud stderr diagnostic — when `project_dir` is not
|
|
270
|
+
absolute; see `_is_valid_project_dir`.
|
|
271
|
+
|
|
237
272
|
conn, if given, is reused as-is (caller owns commit/close) — see write_tag."""
|
|
238
273
|
from cc_session_tools.lib.sessions import session_start_date
|
|
239
274
|
|
|
240
275
|
start_date = session_start_date(basename)
|
|
241
276
|
if start_date is None:
|
|
242
277
|
return
|
|
278
|
+
if not _is_valid_project_dir(project_dir, basename, source="touch_last_active"):
|
|
279
|
+
return
|
|
243
280
|
ts = when if when is not None else time.time()
|
|
244
281
|
owns_conn = conn is None
|
|
245
282
|
c = conn if conn is not None else connect(path=path)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Repair sessions.db rows whose project_dir was written as a non-absolute Path.
|
|
2
|
+
Resolves each affected row's correct project_dir by locating its on-disk
|
|
3
|
+
cc-sessions/<basename>/ directory under the configured roots, then updates the row
|
|
4
|
+
in place, preserving its timestamps. Never guesses when a basename's on-disk
|
|
5
|
+
location is ambiguous (found under >1 root/project) or missing (found under none)
|
|
6
|
+
— those are reported, not silently resolved, because a wrong guess would
|
|
7
|
+
silently point `ccl`/`ccs` at the wrong project's session data, which is worse
|
|
8
|
+
than the original missing-from-listings bug."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from cc_session_tools.lib import sessions_db
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class RepairReport:
|
|
20
|
+
"""Mutable accumulator, built up in place by repair() — not a frozen value object.
|
|
21
|
+
|
|
22
|
+
Note the duplicate-basename semantics differ across fields: `ambiguous` is a
|
|
23
|
+
dict keyed by basename (so a duplicate basename overwrites its own entry),
|
|
24
|
+
while `repaired`/`unresolved`/`conflicts` are lists (so a duplicate basename
|
|
25
|
+
can appear more than once). A caller counting rows across fields must account
|
|
26
|
+
for this asymmetry."""
|
|
27
|
+
|
|
28
|
+
repaired: list[tuple[str, Path]] = field(default_factory=list)
|
|
29
|
+
unresolved: list[str] = field(default_factory=list)
|
|
30
|
+
ambiguous: dict[str, list[Path]] = field(default_factory=dict)
|
|
31
|
+
conflicts: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_non_absolute_rows(*, path: Path | None = None) -> list[sessions_db.SessionRow]:
|
|
35
|
+
"""Every sessions.db row whose project_dir is not an absolute path."""
|
|
36
|
+
return [r for r in sessions_db.list_sessions(path=path) if not r.project_dir.is_absolute()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_on_disk(basename: str, roots: list[Path]) -> list[Path]:
|
|
40
|
+
"""Every project directly under a configured root whose cc-sessions/<basename>/
|
|
41
|
+
exists on disk — the set of plausible correct project_dir values for this row."""
|
|
42
|
+
matches: list[Path] = []
|
|
43
|
+
for root in roots:
|
|
44
|
+
if not root.is_dir():
|
|
45
|
+
continue
|
|
46
|
+
for proj in root.iterdir():
|
|
47
|
+
if proj.is_dir() and (proj / "cc-sessions" / basename).is_dir():
|
|
48
|
+
matches.append(proj)
|
|
49
|
+
return matches
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def repair(
|
|
53
|
+
roots: list[Path], *, path: Path | None = None, dry_run: bool = True
|
|
54
|
+
) -> RepairReport:
|
|
55
|
+
"""Resolve and (unless dry_run) fix every non-absolute project_dir row.
|
|
56
|
+
|
|
57
|
+
dry_run=True (the default) only computes and returns the report. dry_run=False
|
|
58
|
+
updates sessions.db in place — one UPDATE per uniquely-resolved row, leaving
|
|
59
|
+
start_date/last_opened/last_active/discovered_at untouched: a project_dir
|
|
60
|
+
correction is not a new session-activity event, so it must not move
|
|
61
|
+
last_opened/last_active. Callers that want a safety copy first should back up
|
|
62
|
+
the DB file (e.g. via db.backup_to()) before calling with dry_run=False; this
|
|
63
|
+
function does not.
|
|
64
|
+
|
|
65
|
+
A resolved row is never blindly UPDATEd: (project_dir, basename) is the table's
|
|
66
|
+
PRIMARY KEY, so if a correct row for (resolved_dir, basename) already exists —
|
|
67
|
+
e.g. ccd.py's ensure_session_row() wrote it independently at session-creation
|
|
68
|
+
time — writing the corrupted row on top would violate that key. Such rows are
|
|
69
|
+
reported as `conflicts` and left untouched rather than raising mid-batch. The
|
|
70
|
+
same applies when two bad rows in the SAME batch resolve to the same target
|
|
71
|
+
(e.g. one row has project_dir='.', another has '..', and both name the same
|
|
72
|
+
basename): applying one would make the other's UPDATE collide with it, so
|
|
73
|
+
both are reported as conflicts rather than letting row-processing order
|
|
74
|
+
arbitrarily pick a "winner".
|
|
75
|
+
"""
|
|
76
|
+
bad_rows = find_non_absolute_rows(path=path)
|
|
77
|
+
report = RepairReport()
|
|
78
|
+
if not bad_rows:
|
|
79
|
+
return report
|
|
80
|
+
|
|
81
|
+
resolutions: list[tuple[sessions_db.SessionRow, Path]] = []
|
|
82
|
+
for row in bad_rows:
|
|
83
|
+
candidates = _resolve_on_disk(row.basename, roots)
|
|
84
|
+
if len(candidates) == 1:
|
|
85
|
+
resolutions.append((row, candidates[0]))
|
|
86
|
+
elif len(candidates) == 0:
|
|
87
|
+
report.unresolved.append(row.basename)
|
|
88
|
+
else:
|
|
89
|
+
report.ambiguous[row.basename] = candidates
|
|
90
|
+
|
|
91
|
+
if not resolutions:
|
|
92
|
+
return report
|
|
93
|
+
|
|
94
|
+
# Split out any resolution whose target (new_dir, basename) already has a row —
|
|
95
|
+
# applying it would violate the PRIMARY KEY. Check before writing anything, not
|
|
96
|
+
# via try/except around the UPDATE, so one conflict can't abort sibling rows'
|
|
97
|
+
# otherwise-valid updates. `batch_target_counts` catches the same collision
|
|
98
|
+
# when it happens WITHIN this batch (two bad rows resolving to the same
|
|
99
|
+
# target): every row sharing such a target is a conflict, not just whichever
|
|
100
|
+
# one is processed second — picking a "winner" by iteration order would be
|
|
101
|
+
# arbitrary and non-deterministic.
|
|
102
|
+
existing_keys = {
|
|
103
|
+
(r.project_dir, r.basename) for r in sessions_db.list_sessions(path=path)
|
|
104
|
+
}
|
|
105
|
+
batch_target_counts = Counter((new_dir, row.basename) for row, new_dir in resolutions)
|
|
106
|
+
applyable: list[tuple[sessions_db.SessionRow, Path]] = []
|
|
107
|
+
for row, new_dir in resolutions:
|
|
108
|
+
target = (new_dir, row.basename)
|
|
109
|
+
if target in existing_keys or batch_target_counts[target] > 1:
|
|
110
|
+
report.conflicts.append(row.basename)
|
|
111
|
+
else:
|
|
112
|
+
applyable.append((row, new_dir))
|
|
113
|
+
report.repaired.append((row.basename, new_dir))
|
|
114
|
+
|
|
115
|
+
if dry_run or not applyable:
|
|
116
|
+
return report
|
|
117
|
+
|
|
118
|
+
conn = sessions_db.connect(path=path)
|
|
119
|
+
try:
|
|
120
|
+
for row, new_dir in applyable:
|
|
121
|
+
conn.execute(
|
|
122
|
+
"UPDATE sessions SET project_dir = ? WHERE project_dir = ? AND basename = ?",
|
|
123
|
+
(str(new_dir), str(row.project_dir), row.basename),
|
|
124
|
+
)
|
|
125
|
+
conn.commit()
|
|
126
|
+
finally:
|
|
127
|
+
conn.close()
|
|
128
|
+
return report
|
|
@@ -37,6 +37,7 @@ src/cc_session_tools/lib/rules.py
|
|
|
37
37
|
src/cc_session_tools/lib/session_gc.py
|
|
38
38
|
src/cc_session_tools/lib/sessions.py
|
|
39
39
|
src/cc_session_tools/lib/sessions_db.py
|
|
40
|
+
src/cc_session_tools/lib/sessions_repair.py
|
|
40
41
|
src/cc_session_tools/lib/shell_install.py
|
|
41
42
|
src/cc_session_tools/lib/tasklist.py
|
|
42
43
|
src/cc_session_tools/lib/telemetry_store.py
|
|
@@ -143,6 +144,7 @@ tests/test_ccst_pdata_cli.py
|
|
|
143
144
|
tests/test_ccst_pdata_init_cli.py
|
|
144
145
|
tests/test_ccst_pdata_reconcile_cli.py
|
|
145
146
|
tests/test_ccst_pdata_verify_cli.py
|
|
147
|
+
tests/test_ccst_repair_sessions_cli.py
|
|
146
148
|
tests/test_ccst_sessions_cli.py
|
|
147
149
|
tests/test_ccst_shell_install.py
|
|
148
150
|
tests/test_ccst_skills_install.py
|
|
@@ -190,6 +192,7 @@ tests/test_session_names.py
|
|
|
190
192
|
tests/test_session_tag.py
|
|
191
193
|
tests/test_sessions.py
|
|
192
194
|
tests/test_sessions_db.py
|
|
195
|
+
tests/test_sessions_repair.py
|
|
193
196
|
tests/test_tasklist.py
|
|
194
197
|
tests/test_telemetry.py
|
|
195
198
|
tests/test_telemetry_store.py
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Stop hook: records a session-activity sentinel into sessions.db.
|
|
2
|
+
|
|
3
|
+
Fires via Stop event (after each Claude response, not once per session).
|
|
4
|
+
Upserts the sessions table's last_active timestamp so `ccs --order-by active`
|
|
5
|
+
can sort sessions by recency of Claude activity without a filesystem walk.
|
|
6
|
+
Never blocks. Silent on the normal successful-write path; emits a
|
|
7
|
+
systemMessage only when CLD_SESSION_DIR is shaped like cc-sessions/<basename>
|
|
8
|
+
but not absolute, since writing would otherwise collapse project_dir to '.'.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from cc_session_tools.lib import sessions_db
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
session_dir_str = os.environ.get("CLD_SESSION_DIR", "")
|
|
23
|
+
if session_dir_str:
|
|
24
|
+
session_dir_path = Path(session_dir_str)
|
|
25
|
+
if session_dir_path.parent.name == "cc-sessions":
|
|
26
|
+
if not session_dir_path.is_absolute():
|
|
27
|
+
msg = (
|
|
28
|
+
f"[after-response] CLD_SESSION_DIR is not absolute "
|
|
29
|
+
f"({session_dir_str!r}); skipping sessions.db update to avoid writing "
|
|
30
|
+
"a corrupt project_dir. Run 'ccst repair sessions --dry-run' if "
|
|
31
|
+
"ccl/ccs --global later looks incomplete."
|
|
32
|
+
)
|
|
33
|
+
print(json.dumps({"systemMessage": msg}))
|
|
34
|
+
else:
|
|
35
|
+
try:
|
|
36
|
+
sessions_db.touch_last_active(
|
|
37
|
+
session_dir_path.parent.parent, session_dir_path.name
|
|
38
|
+
)
|
|
39
|
+
except (OSError, sqlite3.Error) as exc:
|
|
40
|
+
print(f"[after-response] Failed to record .last-active: {exc}", file=sys.stderr)
|
|
41
|
+
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
sys.exit(main())
|