cc-session-tools 2.7.2__tar.gz → 2.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cc_session_tools-2.7.2/src/cc_session_tools.egg-info → cc_session_tools-2.8.1}/PKG-INFO +1 -1
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/pyproject.toml +3 -3
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccst.py +117 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/init_paths.py +1 -1
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/init_service.py +1 -1
- cc_session_tools-2.8.1/src/cc_session_tools/lib/pdata/reorganize.py +367 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/service.py +60 -1
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/write_log.py +16 -11
- cc_session_tools-2.8.1/src/cc_session_tools/prompts/pdata-migration-claude-md-update.md +125 -0
- cc_session_tools-2.8.1/src/cc_session_tools/prompts/pdata-migration-skills-update.md +128 -0
- cc_session_tools-2.8.1/src/cc_session_tools/skills/pm-project-layout-reference/SKILL.md +83 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1/src/cc_session_tools.egg-info}/PKG-INFO +1 -1
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/SOURCES.txt +5 -0
- cc_session_tools-2.8.1/tests/test_ccst_bundle_discovery.py +98 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_pdata_init_cli.py +12 -0
- cc_session_tools-2.8.1/tests/test_ccst_pdata_reorganize_cli.py +175 -0
- cc_session_tools-2.7.2/tests/test_ccst_bundle_discovery.py +0 -52
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/LICENSE +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/README.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/setup.cfg +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/__init__.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/__init__.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccd.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccmsg.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccr.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccs.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/ccsched.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/migrate_ccmsg.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/migrate_ccsched.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/migrate_sessions_db.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/cli/migrate_telemetry.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/config/hooks-bundle.json +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/config/session-tag-hook.json +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/hooks_install.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/__init__.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/claude_flags.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/claude_md_install.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/context_overrides.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/db.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/debug.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/doctor.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/doctor_mutes.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/hook_registry.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/install_sync.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/levenshtein.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/__init__.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/addressing.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/cursor.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/lock.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/message.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/move_safety.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/repository.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/retention.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/service.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/messaging/store.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/paths.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/__init__.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/backup.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/classify.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/cutover.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/formatting.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/importers.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/manifest.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/naming.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/repository.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/session_output.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/store.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/verify.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/picker.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/proc_lock.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/prompts.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/roots.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/rules.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/__init__.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/bundled_jobs.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/cadence.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/cursor.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/digest.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/due.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/duration.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/jobspec.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/ledger.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/lock.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/notify.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/reconcile.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/registry.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/runner.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/state.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/store.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/surface.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/throttle.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/scheduler/worker.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/session_gc.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/sessions.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/sessions_db.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/sessions_repair.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/shell_install.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/tasklist.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/telemetry_store.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/analyse-cc-usage/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/context-override/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/delete-sessions/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/delete-sessions/scripts/delete_sessions.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/do-executor-critic-assessor-loop/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/find-claude-code-session/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/generate-8digit-code/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/generate-8digit-code/scripts/generate_8digit_code.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/list-empty-sessions/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/list-empty-sessions/scripts/list_empty_sessions.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/manage-recurring-cc-jobs-using-ccsched/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/move-session/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/move-session/hooks/sessionstart-pending-rename.sh +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/move-session/scripts/cc_session_rules.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/move-session/scripts/move_session.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/pm-pdata-conflict-resolution/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/pm-pdata-schema-design/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/pm-project-init/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/pm-update-central-files/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/CALIBRATION.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/analyze_context.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/duplicates.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/measure.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/report.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/tokens.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/reduce-persistent-context/scripts/usage.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/select-agent-model/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/send-session-message/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/update-command-cache/SKILL.md +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/skills/update-command-cache/scripts/update_command_cache.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/dependency_links.txt +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/entry_points.txt +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/requires.txt +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools.egg-info/top_level.txt +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/__init__.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/after_response.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/bash_hard_deny.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/bash_security_review.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/cache.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/catchup.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/confirm_8digit.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/context_window_warning.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/last_screenshot.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/marker_allow.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/markers.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/messaging_deliver.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/model_info.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/normalise.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/pending_migration.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/pending_rename.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/session_tag.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/stats.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/telemetry.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/telemetry_query.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/telemetry_trim.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/transcript.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cccs_hooks/worklog_guard.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/__init__.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/attribution.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/cache.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/ccusage_wrapper.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/data/pricing.json +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/parent_inference.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/parser.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/pricing.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/query.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/report.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/schema.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/claude_code_usage/session_names.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_after_response.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_attribution.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_bash_hard_deny.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_bash_security_review.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cache.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cache_sqlite.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccr_orphans.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_emptiness.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_eta.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_list_mode.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_scope_flags.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_sentinel_sort.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccs_session_counts.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_ccsched_jobs_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_context_override_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_doctor.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_gc_report.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_hook_dispatcher.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_hooks_install_prune.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_hooks_install_zero_arg.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_hooks_uninstall.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_install_everything.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_main_gate.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_pdata_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_pdata_reconcile_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_pdata_verify_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_repair_sessions_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_sessions_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_shell_install.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_skills_install.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_skills_uninstall.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_telemetry_query.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccst_telemetry_trim.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_ccusage_wrapper.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_claude_flags.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_claude_md_install.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_ccd.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_ccd_dry_run.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_ccr.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_ccs.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_cli_version.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_confirm_8digit.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_context_overrides.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_context_window_warning.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_db.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_debug.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_doctor_mutes.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_empty_session.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_hook_cache.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_hooks_install.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_install_sync.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_last_screenshot.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_levenshtein.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_marker_allow.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_migrate_ccmsg.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_migrate_ccsched.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_migrate_sessions_db.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_migrate_telemetry.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_model_info.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_normalise.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_parent_inference.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_parser.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_paths.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_pending_migration_hook.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_picker.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_pricing.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_proc_lock.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_prompts.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_query.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_report.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_roots.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_rules.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_scheduler_bundled_jobs.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_schema.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_session_names.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_session_tag.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_sessions.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_sessions_db.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_sessions_repair.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_tasklist.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_telemetry.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_telemetry_store.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/tests/test_transcript.py +0 -0
- {cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/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.8.1"
|
|
8
8
|
description = "Claude Code session management CLI tools (ccd, ccr, ccs, claude-code-usage, ccst) and hook library (cccs_hooks)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -62,11 +62,11 @@ where = ["src"]
|
|
|
62
62
|
# only filters within the file's *own* discovered package, and a wrongly
|
|
63
63
|
# auto-discovered "cc_session_tools.skills.<name>.tests" package was never
|
|
64
64
|
# covered by an exclude-package-data entry keyed just "cc_session_tools").
|
|
65
|
-
exclude = ["cc_session_tools.skills", "cc_session_tools.skills.*", "cc_session_tools.config", "cc_session_tools.config.*"]
|
|
65
|
+
exclude = ["cc_session_tools.skills", "cc_session_tools.skills.*", "cc_session_tools.config", "cc_session_tools.config.*", "cc_session_tools.prompts", "cc_session_tools.prompts.*"]
|
|
66
66
|
|
|
67
67
|
[tool.setuptools.package-data]
|
|
68
68
|
claude_code_usage = ["data/*.json"]
|
|
69
|
-
cc_session_tools = ["skills/**/*", "config/*"]
|
|
69
|
+
cc_session_tools = ["skills/**/*", "config/*", "prompts/**/*"]
|
|
70
70
|
|
|
71
71
|
[tool.setuptools.exclude-package-data]
|
|
72
72
|
cc_session_tools = ["**/tests/*", "**/__pycache__/*"]
|
|
@@ -122,6 +122,40 @@ def _discover_bundle() -> Path:
|
|
|
122
122
|
)
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
def _discover_prompts_dir() -> Path:
|
|
126
|
+
"""Return the bundled prompts/ directory (pdata-migration follow-up prompts).
|
|
127
|
+
|
|
128
|
+
prompts/ is packaged inside cc_session_tools the same way skills/ and config/ are (see
|
|
129
|
+
_discover_source_dir) - src/cc_session_tools/cli/ccst.py -> ../prompts. No install-location
|
|
130
|
+
fallback, same reasoning as the other two: a fallback here would just mask a broken install.
|
|
131
|
+
"""
|
|
132
|
+
candidate = Path(__file__).resolve().parent.parent / "prompts"
|
|
133
|
+
if candidate.is_dir():
|
|
134
|
+
return candidate
|
|
135
|
+
raise FileNotFoundError(
|
|
136
|
+
"Cannot locate bundled prompts/ directory. Run from the source tree or reinstall ccst."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _print_migration_prompt_reminders(*labeled_filenames: tuple[str, str]) -> None:
|
|
141
|
+
"""Print one "<label>: <path>" reminder line per (label, filename) pair, for the bundled
|
|
142
|
+
prompts/ directory.
|
|
143
|
+
|
|
144
|
+
Best-effort, deliberately swallowing a missing prompts/ directory: these reminders are a
|
|
145
|
+
nicety layered on top of an otherwise-already-completed classification or migration, not
|
|
146
|
+
the operation itself — a broken/partial install must never turn a successful --write (rows
|
|
147
|
+
already written, files already cut over) or dry-run (proposal already written) into a
|
|
148
|
+
reported failure just because this follow-up reminder couldn't be printed.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
prompts_dir = _discover_prompts_dir()
|
|
152
|
+
except FileNotFoundError as exc:
|
|
153
|
+
print(f"({exc})", file=sys.stderr)
|
|
154
|
+
return
|
|
155
|
+
for label, filename in labeled_filenames:
|
|
156
|
+
print(f"{label}: {prompts_dir / filename}")
|
|
157
|
+
|
|
158
|
+
|
|
125
159
|
# ---------- skills install ----------
|
|
126
160
|
|
|
127
161
|
|
|
@@ -1056,6 +1090,9 @@ def _cmd_pdata_init(args: argparse.Namespace) -> int:
|
|
|
1056
1090
|
return 2
|
|
1057
1091
|
print(result.report)
|
|
1058
1092
|
print(f"\nProposal: {result.proposal_path}")
|
|
1093
|
+
_print_migration_prompt_reminders(
|
|
1094
|
+
("After a successful --write, update project docs", "pdata-migration-claude-md-update.md"),
|
|
1095
|
+
)
|
|
1059
1096
|
return 0
|
|
1060
1097
|
|
|
1061
1098
|
try:
|
|
@@ -1091,6 +1128,71 @@ def _cmd_pdata_init(args: argparse.Namespace) -> int:
|
|
|
1091
1128
|
print()
|
|
1092
1129
|
print(write_result.report) # spec §7.1 step 4's diff report, for review
|
|
1093
1130
|
print(f"\nVerify: ccst pdata verify --project {args.project} --full")
|
|
1131
|
+
_print_migration_prompt_reminders(
|
|
1132
|
+
("Update project docs", "pdata-migration-claude-md-update.md"),
|
|
1133
|
+
("Update consuming skills", "pdata-migration-skills-update.md"),
|
|
1134
|
+
)
|
|
1135
|
+
print("SUCCESS")
|
|
1136
|
+
return 0
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _cmd_pdata_reorganize(args: argparse.Namespace) -> int:
|
|
1140
|
+
from cc_session_tools.lib.pdata import init_paths, reorganize, write_log
|
|
1141
|
+
|
|
1142
|
+
try:
|
|
1143
|
+
project_root = init_paths.resolve_project_root(args.project, rehearse=None)
|
|
1144
|
+
except ValueError as exc:
|
|
1145
|
+
# Matches _cmd_pdata_init's own handling of the identical resolve_project_root() call -
|
|
1146
|
+
# a bad --project name (e.g. containing '/') must give the same clean exit-2 message
|
|
1147
|
+
# every other pdata command gives, not an uncaught traceback.
|
|
1148
|
+
print(f"ccst pdata: {exc}", file=sys.stderr)
|
|
1149
|
+
return 2
|
|
1150
|
+
|
|
1151
|
+
if not args.write:
|
|
1152
|
+
try:
|
|
1153
|
+
plan = reorganize.dry_run(
|
|
1154
|
+
project=args.project, project_root=project_root,
|
|
1155
|
+
folder=args.folder, strategy=args.strategy,
|
|
1156
|
+
)
|
|
1157
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
1158
|
+
print(f"ccst pdata: {exc}", file=sys.stderr)
|
|
1159
|
+
return 2
|
|
1160
|
+
for move in plan.moves:
|
|
1161
|
+
print(f"{move.old_relative} -> {move.new_relative}")
|
|
1162
|
+
for matched in plan.matched_records:
|
|
1163
|
+
print(f" pdata record {matched.record.id} (group={matched.record.record_group}): "
|
|
1164
|
+
f"file_path -> {matched.new_file_path}")
|
|
1165
|
+
for ref in plan.external_references:
|
|
1166
|
+
print(f" external reference: {ref.file}:{ref.line_number}: {ref.line_text}")
|
|
1167
|
+
if not plan.moves:
|
|
1168
|
+
print(f"no files found directly under {args.folder}/")
|
|
1169
|
+
return 0
|
|
1170
|
+
|
|
1171
|
+
# --write can move many files over a long-running, potentially flaky I/O path - durably
|
|
1172
|
+
# logged the same way `ccst pdata init --write` is, under this operation's own filename so
|
|
1173
|
+
# the two never truncate each other's log (see reorganize.REORGANIZE_WRITE_LOG_FILENAME).
|
|
1174
|
+
with write_log.WriteLog(project_root, log_filename=reorganize.REORGANIZE_WRITE_LOG_FILENAME):
|
|
1175
|
+
try:
|
|
1176
|
+
result = reorganize.write(
|
|
1177
|
+
project=args.project, project_root=project_root,
|
|
1178
|
+
folder=args.folder, strategy=args.strategy,
|
|
1179
|
+
)
|
|
1180
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
1181
|
+
print(f"ccst pdata: {exc}", file=sys.stderr)
|
|
1182
|
+
print(f"ERROR: {exc}")
|
|
1183
|
+
return 2
|
|
1184
|
+
|
|
1185
|
+
if result.failure is not None:
|
|
1186
|
+
print("ccst pdata reorganize: failed, rolled back:", file=sys.stderr)
|
|
1187
|
+
for reason in result.failure.reasons:
|
|
1188
|
+
print(f" - {reason}", file=sys.stderr)
|
|
1189
|
+
print(f"ERROR: failed, rolled back ({len(result.failure.reasons)} reason(s))")
|
|
1190
|
+
return 1
|
|
1191
|
+
|
|
1192
|
+
print(f"Moved {len(result.plan.moves)} file(s) under {args.folder}/")
|
|
1193
|
+
print(f"Backup: {result.backup_path}")
|
|
1194
|
+
for ref in result.plan.external_references:
|
|
1195
|
+
print(f" still needs manual review: {ref.file}:{ref.line_number}: {ref.line_text}")
|
|
1094
1196
|
print("SUCCESS")
|
|
1095
1197
|
return 0
|
|
1096
1198
|
|
|
@@ -2105,6 +2207,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2105
2207
|
),
|
|
2106
2208
|
)
|
|
2107
2209
|
|
|
2210
|
+
pdata_reorganize_parser = pdata_sub.add_parser(
|
|
2211
|
+
"reorganize", help="Split a flat folder into a nested (by-year or by-year-month) structure"
|
|
2212
|
+
)
|
|
2213
|
+
pdata_reorganize_parser.add_argument("--project", required=True, metavar="NAME")
|
|
2214
|
+
pdata_reorganize_parser.add_argument("--folder", required=True, metavar="RELATIVE_PATH")
|
|
2215
|
+
pdata_reorganize_parser.add_argument(
|
|
2216
|
+
"--strategy", required=True, choices=("by-year", "by-year-month"),
|
|
2217
|
+
)
|
|
2218
|
+
pdata_reorganize_parser.add_argument(
|
|
2219
|
+
"--write", action="store_true",
|
|
2220
|
+
help="Perform the move and update matching pdata records (default: dry-run only)",
|
|
2221
|
+
)
|
|
2222
|
+
|
|
2108
2223
|
pdata_reconcile_parser = pdata_sub.add_parser(
|
|
2109
2224
|
"reconcile-session-output",
|
|
2110
2225
|
help="Backfill the session-output index from cc-sessions/*/out/ on disk (idempotent)",
|
|
@@ -2409,6 +2524,8 @@ def main() -> None:
|
|
|
2409
2524
|
sys.exit(_cmd_pdata_restore(args))
|
|
2410
2525
|
if args.verb == "init":
|
|
2411
2526
|
sys.exit(_cmd_pdata_init(args))
|
|
2527
|
+
if args.verb == "reorganize":
|
|
2528
|
+
sys.exit(_cmd_pdata_reorganize(args))
|
|
2412
2529
|
if args.verb == "reconcile-session-output":
|
|
2413
2530
|
sys.exit(_cmd_pdata_reconcile_session_output(args))
|
|
2414
2531
|
if args.verb == "verify":
|
{cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/init_paths.py
RENAMED
|
@@ -76,7 +76,7 @@ def backup_dir_override(rehearse: Path | None) -> Iterator[None]:
|
|
|
76
76
|
whatever CCST_PDATA_BACKUP_DIR is already set to), by reusing this plan's own
|
|
77
77
|
backup.BACKUP_DIR_ENV seam — mirrors project_db_dir_override's shape exactly.
|
|
78
78
|
Without this, a rehearsed --write would still deposit a
|
|
79
|
-
<project>-<
|
|
79
|
+
<project>-<YYYYMMDD-HHMMSS>.tar.gz into the same directory a genuine production
|
|
80
80
|
migration's backup would use, indistinguishable by filename. No-op when
|
|
81
81
|
rehearse is None."""
|
|
82
82
|
if rehearse is None:
|
{cc_session_tools-2.7.2 → cc_session_tools-2.8.1}/src/cc_session_tools/lib/pdata/init_service.py
RENAMED
|
@@ -164,7 +164,7 @@ def write(
|
|
|
164
164
|
# A's CCST_PROJECT_DB_DIR seam), backup_dir_override redirects where
|
|
165
165
|
# backup.create_backup() below writes its tar.gz (this module's own
|
|
166
166
|
# CCST_PDATA_BACKUP_DIR seam). Without the second seam, a rehearsed --write
|
|
167
|
-
# would still deposit a real <project>-<
|
|
167
|
+
# would still deposit a real <project>-<YYYYMMDD-HHMMSS>.tar.gz into the production
|
|
168
168
|
# backup directory — indistinguishable by filename from a genuine migration's
|
|
169
169
|
# backup. Both are no-ops when rehearse is None.
|
|
170
170
|
with (
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Flat-to-nested folder restructuring for `ccst pdata reorganize` (design spec
|
|
2
|
+
2026-08-21-pm-project-layout-reference-design.md, §3). Scoped to exactly one operation:
|
|
3
|
+
splitting a flat folder into year or year/month subfolders once it's grown past the
|
|
4
|
+
`pm-project-layout-reference` skill's 500-file guidance - not general-purpose reorganisation.
|
|
5
|
+
|
|
6
|
+
dry_run() never touches the filesystem or the database; write() (a later task) performs the
|
|
7
|
+
moves and DB updates dry_run() planned, reusing backup.create_backup() as its safety net
|
|
8
|
+
exactly the way `ccst pdata init --write` does.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from cc_session_tools.lib.pdata import backup, init_paths, service
|
|
19
|
+
from cc_session_tools.lib.pdata.init_paths import PROPOSAL_FILENAME
|
|
20
|
+
from cc_session_tools.lib.pdata.service import Record
|
|
21
|
+
from cc_session_tools.lib.pdata.write_log import LOG_FILENAME as WRITE_LOG_FILENAME
|
|
22
|
+
|
|
23
|
+
_LEADING_DATE_RE = re.compile(r"^(?P<year>\d{4})\.(?P<month>\d{2})\.(?P<day>\d{2})-")
|
|
24
|
+
|
|
25
|
+
_STRATEGIES = frozenset({"by-year", "by-year-month"})
|
|
26
|
+
|
|
27
|
+
# This operation's own durable per-run log (mirrors ccst-pdata-init-write.log, but under its
|
|
28
|
+
# own name so a reorganize run never truncates - or gets excluded/included alongside - an
|
|
29
|
+
# unrelated pdata init run's log). Named here, not in write_log.py, since it's this module's
|
|
30
|
+
# own concern; the CLI layer imports it to pass to write_log.WriteLog(log_filename=...).
|
|
31
|
+
REORGANIZE_WRITE_LOG_FILENAME = "ccst-pdata-reorganize-write.log"
|
|
32
|
+
|
|
33
|
+
# Filenames excluded from the external-reference scan by name, not just by directory - all
|
|
34
|
+
# three persist at the project root indefinitely (classify.py excludes the first two from
|
|
35
|
+
# classification for the same reason) and can literally contain the file_path of every
|
|
36
|
+
# classified/moved entry, including files under whatever folder is being reorganized. Without
|
|
37
|
+
# this, a project that has ever run `ccst pdata init` or a previous `ccst pdata reorganize`
|
|
38
|
+
# would get a spurious "external reference" hit inside its own bookkeeping every time
|
|
39
|
+
# dry_run() runs.
|
|
40
|
+
_EXCLUDED_FILENAMES = frozenset({
|
|
41
|
+
PROPOSAL_FILENAME, WRITE_LOG_FILENAME, REORGANIZE_WRITE_LOG_FILENAME,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Move:
|
|
47
|
+
old_relative: str
|
|
48
|
+
new_relative: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class ExternalReference:
|
|
53
|
+
file: Path
|
|
54
|
+
line_number: int
|
|
55
|
+
line_text: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class MatchedRecord:
|
|
60
|
+
record: Record
|
|
61
|
+
new_file_path: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class ReorganizePlan:
|
|
66
|
+
project: str
|
|
67
|
+
project_root: Path
|
|
68
|
+
folder: str
|
|
69
|
+
strategy: str
|
|
70
|
+
moves: list[Move]
|
|
71
|
+
matched_records: list[MatchedRecord]
|
|
72
|
+
external_references: list[ExternalReference]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _year_and_month_for(path: Path) -> tuple[str, str]:
|
|
76
|
+
"""(year, month) for path - from a leading YYYY.MM.DD- date in the filename if present
|
|
77
|
+
(this codebase's own naming convention, e.g. archive-correspondence's output), else the
|
|
78
|
+
file's mtime. Shared by _year_for/_year_month_for so the date-vs-mtime decision and the
|
|
79
|
+
regex match happen exactly once, not twice with the same logic duplicated between them.
|
|
80
|
+
|
|
81
|
+
No validation that a regex-matched month is actually 01-12 (or day 01-31) - a malformed
|
|
82
|
+
but shape-matching date in a filename (e.g. "2025.13.99-foo.md") is accepted at face value.
|
|
83
|
+
Low-risk for a dry-run/planning tool whose output is reviewed before any --write, but worth
|
|
84
|
+
knowing if this is ever reused somewhere the plan isn't reviewed first."""
|
|
85
|
+
match = _LEADING_DATE_RE.match(path.name)
|
|
86
|
+
if match:
|
|
87
|
+
return match.group("year"), match.group("month")
|
|
88
|
+
localtime = time.localtime(path.stat().st_mtime)
|
|
89
|
+
return time.strftime("%Y", localtime), time.strftime("%m", localtime)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _year_for(path: Path) -> str:
|
|
93
|
+
year, _month = _year_and_month_for(path)
|
|
94
|
+
return year
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _year_month_for(path: Path) -> str:
|
|
98
|
+
year, month = _year_and_month_for(path)
|
|
99
|
+
return f"{year}/{month}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _new_relative(folder: str, entry: Path, strategy: str) -> str:
|
|
103
|
+
subdir = _year_for(entry) if strategy == "by-year" else _year_month_for(entry)
|
|
104
|
+
return f"{folder}/{subdir}/{entry.name}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _scan_external_references(
|
|
108
|
+
*, project_root: Path, folder: str, old_relatives: list[str],
|
|
109
|
+
) -> list[ExternalReference]:
|
|
110
|
+
"""Grep every text file in project_root (excluding folder itself and the usual
|
|
111
|
+
ccst/git bookkeeping dirs) for a literal occurrence of any old_relatives entry - reported
|
|
112
|
+
only, never edited (design spec §3: rewriting prose isn't safe to automate).
|
|
113
|
+
|
|
114
|
+
folder is excluded by path-prefix comparison, not single-component name matching - folder
|
|
115
|
+
can itself be multi-segment (e.g. "workstreams/ws-01"), which a plain
|
|
116
|
+
`part in excluded_names` check would never match against any single path component.
|
|
117
|
+
|
|
118
|
+
Reuses init_paths.EXCLUDED_DIR_NAMES - the same set classify.py's own directory walk
|
|
119
|
+
already excludes - rather than hardcoding a second, incomplete copy: a hardcoded
|
|
120
|
+
".pdata-migrated" literal here would also miss REHEARSAL_DB_DIRNAME/
|
|
121
|
+
REHEARSAL_BACKUP_DIRNAME, walking into a rehearsal sandbox's own contents on any project
|
|
122
|
+
that has ever used --rehearse and reporting spurious matches from inside it."""
|
|
123
|
+
excluded_dir_names = init_paths.EXCLUDED_DIR_NAMES
|
|
124
|
+
folder_parts = Path(folder).parts
|
|
125
|
+
refs: list[ExternalReference] = []
|
|
126
|
+
for candidate in sorted(project_root.rglob("*")):
|
|
127
|
+
if not candidate.is_file():
|
|
128
|
+
continue
|
|
129
|
+
rel_parts = candidate.relative_to(project_root).parts
|
|
130
|
+
if rel_parts[:len(folder_parts)] == folder_parts:
|
|
131
|
+
continue # inside the folder being reorganized itself
|
|
132
|
+
if any(part in excluded_dir_names for part in rel_parts[:-1]):
|
|
133
|
+
continue
|
|
134
|
+
if candidate.name in _EXCLUDED_FILENAMES:
|
|
135
|
+
continue # ccst's own bookkeeping - see _EXCLUDED_FILENAMES's comment
|
|
136
|
+
try:
|
|
137
|
+
text = candidate.read_text(encoding="utf-8")
|
|
138
|
+
except (UnicodeDecodeError, OSError):
|
|
139
|
+
continue
|
|
140
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
141
|
+
for old_relative in old_relatives:
|
|
142
|
+
if old_relative in line:
|
|
143
|
+
refs.append(ExternalReference(
|
|
144
|
+
file=candidate, line_number=line_number, line_text=line.strip(),
|
|
145
|
+
))
|
|
146
|
+
return refs
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _validate_relative_folder(folder: str) -> None:
|
|
150
|
+
"""A separate check from (not a call to) service._validate_relative_file_path - similar in
|
|
151
|
+
spirit, but folder has two failure modes file_path doesn't need to guard against, since
|
|
152
|
+
file_path is never joined with mkdir() to build a new directory tree the way folder is here:
|
|
153
|
+
|
|
154
|
+
- An absolute path silently discards project_root entirely when joined with `/` (pathlib's
|
|
155
|
+
own behaviour, not a bug in this code - `Path("/a") / "/etc"` is `Path("/etc")`), and a
|
|
156
|
+
'..' segment can escape project_root the same way a crafted --file could.
|
|
157
|
+
- An EMPTY folder ("") makes old_relative/new_relative leading-slash strings
|
|
158
|
+
(f"{folder}/{name}" -> "/name"), which then trigger the exact same absolute-path escape
|
|
159
|
+
above once project_root / new_relative is evaluated - so empty must be rejected as its
|
|
160
|
+
own case, before the absolute-path check ever sees it (an empty string doesn't start with
|
|
161
|
+
"/", so that check alone wouldn't catch it). A trailing or doubled slash
|
|
162
|
+
("correspondence/", "a//b") produces a doubled-slash old_relative/new_relative that no
|
|
163
|
+
longer matches the single-slash file_path values pdata records and project docs actually
|
|
164
|
+
use - write() would then silently update zero matching records and report success while
|
|
165
|
+
every affected record's file_path goes stale, instead of erroring loudly."""
|
|
166
|
+
if folder.startswith("/"):
|
|
167
|
+
raise ValueError(f"--folder must be relative to the project root, got absolute path: {folder!r}")
|
|
168
|
+
segments = folder.split("/")
|
|
169
|
+
if not folder or any(not segment for segment in segments):
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"--folder must not be empty or contain empty/trailing path segments: {folder!r}"
|
|
172
|
+
)
|
|
173
|
+
if any(segment == ".." for segment in segments):
|
|
174
|
+
raise ValueError(f"--folder must not contain '..' path-traversal segments: {folder!r}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def dry_run(*, project: str, project_root: Path, folder: str, strategy: str) -> ReorganizePlan:
|
|
178
|
+
if strategy not in _STRATEGIES:
|
|
179
|
+
raise ValueError(f"unknown strategy {strategy!r} - must be one of {sorted(_STRATEGIES)}")
|
|
180
|
+
_validate_relative_folder(folder)
|
|
181
|
+
folder_path = project_root / folder
|
|
182
|
+
if not folder_path.is_dir():
|
|
183
|
+
raise FileNotFoundError(f"no such folder to reorganize: {folder_path}")
|
|
184
|
+
|
|
185
|
+
entries = sorted(p for p in folder_path.iterdir() if p.is_file())
|
|
186
|
+
moves = [
|
|
187
|
+
Move(
|
|
188
|
+
old_relative=f"{folder}/{entry.name}",
|
|
189
|
+
new_relative=_new_relative(folder, entry, strategy),
|
|
190
|
+
)
|
|
191
|
+
for entry in entries
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
matched = service.find_records_by_file_path_prefix(project=project, prefix=f"{folder}/")
|
|
195
|
+
move_by_old = {m.old_relative: m.new_relative for m in moves}
|
|
196
|
+
matched_records = [
|
|
197
|
+
MatchedRecord(record=record, new_file_path=move_by_old[record.file_path])
|
|
198
|
+
for record in matched
|
|
199
|
+
if record.file_path is not None and record.file_path in move_by_old
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
external_references = _scan_external_references(
|
|
203
|
+
project_root=project_root, folder=folder,
|
|
204
|
+
old_relatives=[m.old_relative for m in moves],
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return ReorganizePlan(
|
|
208
|
+
project=project, project_root=project_root, folder=folder, strategy=strategy,
|
|
209
|
+
moves=moves, matched_records=matched_records, external_references=external_references,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True, slots=True)
|
|
214
|
+
class ReorganizeFailure:
|
|
215
|
+
reasons: list[str]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, slots=True)
|
|
219
|
+
class ReorganizeResult:
|
|
220
|
+
plan: ReorganizePlan
|
|
221
|
+
backup_path: Path | None
|
|
222
|
+
failure: ReorganizeFailure | None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _is_git_repo(project_root: Path) -> bool:
|
|
226
|
+
return (project_root / ".git").exists()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _remove_empty_dirs_up_to(*, start: Path, boundary: Path) -> None:
|
|
230
|
+
"""Remove start and each successive empty parent directory, stopping at (never removing)
|
|
231
|
+
boundary itself. Shared by _move_file's own failure cleanup and _move_file_back's rollback
|
|
232
|
+
cleanup - both need to walk back up through however many nesting levels by-year-month left
|
|
233
|
+
(two - correspondence/2025/06/) without ever deleting the folder being reorganized."""
|
|
234
|
+
current = start
|
|
235
|
+
while current != boundary and current.is_dir() and not any(current.iterdir()):
|
|
236
|
+
current.rmdir()
|
|
237
|
+
current = current.parent
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _first_missing_ancestor(*, start: Path, boundary: Path) -> Path | None:
|
|
241
|
+
"""Walking from start up towards (not including) boundary, the shallowest directory that
|
|
242
|
+
does not yet exist - exactly the topmost directory `start.mkdir(parents=True)` would
|
|
243
|
+
actually create. None if start already exists (mkdir would be a complete no-op).
|
|
244
|
+
|
|
245
|
+
Used so _move_file's failure-cleanup only ever removes directories its own mkdir call
|
|
246
|
+
genuinely created, never one that already existed before it - e.g. left over from an
|
|
247
|
+
earlier partial/crashed run, or present for an unrelated reason. Without this, cleanup
|
|
248
|
+
would delete any empty directory sitting at the destination path regardless of whether
|
|
249
|
+
this call was the one that created it."""
|
|
250
|
+
if start.exists():
|
|
251
|
+
return None
|
|
252
|
+
missing = start
|
|
253
|
+
while missing.parent != boundary and not missing.parent.exists():
|
|
254
|
+
missing = missing.parent
|
|
255
|
+
return missing
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _move_file(*, project_root: Path, move: Move, folder: str, use_git: bool) -> None:
|
|
259
|
+
src = project_root / move.old_relative
|
|
260
|
+
dest = project_root / move.new_relative
|
|
261
|
+
folder_path = project_root / folder
|
|
262
|
+
newly_created_root = _first_missing_ancestor(start=dest.parent, boundary=folder_path)
|
|
263
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
try:
|
|
265
|
+
if use_git:
|
|
266
|
+
subprocess.run(
|
|
267
|
+
["git", "mv", move.old_relative, move.new_relative],
|
|
268
|
+
cwd=project_root, check=True, capture_output=True, text=True,
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
src.rename(dest)
|
|
272
|
+
except (OSError, subprocess.CalledProcessError):
|
|
273
|
+
# The mkdir above may have just created dest.parent (and its own new parents, for
|
|
274
|
+
# by-year-month) - if the move itself then failed, that directory was never populated
|
|
275
|
+
# and would otherwise be left behind as debris: write()'s own rollback only knows
|
|
276
|
+
# about *completed* moves (this one was never added to `moved`), so nothing else
|
|
277
|
+
# would ever clean it up. newly_created_root is None when dest.parent already existed
|
|
278
|
+
# before this call (nothing to clean up - it wasn't ours to remove).
|
|
279
|
+
if newly_created_root is not None:
|
|
280
|
+
_remove_empty_dirs_up_to(start=dest.parent, boundary=newly_created_root.parent)
|
|
281
|
+
raise
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _move_file_back(*, project_root: Path, move: Move, folder: str, use_git: bool) -> None:
|
|
285
|
+
"""Undo _move_file - used by write()'s rollback path only."""
|
|
286
|
+
dest_parent = (project_root / move.new_relative).parent
|
|
287
|
+
if use_git:
|
|
288
|
+
subprocess.run(
|
|
289
|
+
["git", "mv", move.new_relative, move.old_relative],
|
|
290
|
+
cwd=project_root, check=True, capture_output=True, text=True,
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
(project_root / move.new_relative).rename(project_root / move.old_relative)
|
|
294
|
+
_remove_empty_dirs_up_to(start=dest_parent, boundary=project_root / folder)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def write(*, project: str, project_root: Path, folder: str, strategy: str) -> ReorganizeResult:
|
|
298
|
+
plan = dry_run(project=project, project_root=project_root, folder=folder, strategy=strategy)
|
|
299
|
+
use_git = _is_git_repo(project_root)
|
|
300
|
+
|
|
301
|
+
# Matches init_service.write()'s own handling of this exact call (init_service.py
|
|
302
|
+
# ~lines 220-232): create_backup() can exhaust its retries and raise BackupError, which
|
|
303
|
+
# must become a structured failure here too, not an uncaught crash before anything has
|
|
304
|
+
# been moved.
|
|
305
|
+
try:
|
|
306
|
+
backup_path = backup.create_backup(project=project, project_root=project_root)
|
|
307
|
+
except backup.BackupError as exc:
|
|
308
|
+
return ReorganizeResult(plan=plan, backup_path=None, failure=ReorganizeFailure(reasons=[str(exc)]))
|
|
309
|
+
|
|
310
|
+
moved: list[Move] = []
|
|
311
|
+
# (matched, post-update Record incl. its bumped version) for every update_record() call
|
|
312
|
+
# that succeeded so far - needed to reverse them in the right order on failure, each with
|
|
313
|
+
# the expected_version its OWN update actually left the row at, not its pre-update version.
|
|
314
|
+
updated: list[tuple[MatchedRecord, Record]] = []
|
|
315
|
+
try:
|
|
316
|
+
for move in plan.moves:
|
|
317
|
+
_move_file(project_root=project_root, move=move, folder=folder, use_git=use_git)
|
|
318
|
+
moved.append(move)
|
|
319
|
+
|
|
320
|
+
for matched in plan.matched_records:
|
|
321
|
+
updated_record = service.update_record(
|
|
322
|
+
project=project, record_id=matched.record.id,
|
|
323
|
+
expected_version=matched.record.version,
|
|
324
|
+
content=None, file_path=matched.new_file_path, fields={},
|
|
325
|
+
)
|
|
326
|
+
updated.append((matched, updated_record))
|
|
327
|
+
except (OSError, subprocess.CalledProcessError, service.VersionConflictError,
|
|
328
|
+
service.RecordNotFoundError) as exc:
|
|
329
|
+
reasons = [str(exc)]
|
|
330
|
+
# Roll back each moved file and its matching DB update (if any) as one paired unit,
|
|
331
|
+
# in reverse order - move the file back FIRST, then revert its record, so the two
|
|
332
|
+
# can never end up disagreeing about where the file actually is:
|
|
333
|
+
# - if the move-back fails, the record is left un-reverted too (still pointing at
|
|
334
|
+
# the new path) - consistent, since the file is still physically there.
|
|
335
|
+
# - if the move-back succeeds but the reversal update_record() call then fails,
|
|
336
|
+
# that's flagged explicitly rather than silently diverging - matched.record is
|
|
337
|
+
# filtered upstream (dry_run) to always have a non-None file_path, so the
|
|
338
|
+
# reversal always restores a real prior value, never a no-op None.
|
|
339
|
+
# Each step is wrapped individually, matching init_service._rollback()'s own contract:
|
|
340
|
+
# an unwrapped raise here would abort the rest of the rollback (and this whole
|
|
341
|
+
# function) instead of returning a ReorganizeResult at all.
|
|
342
|
+
updated_by_new_path = {m.new_file_path: (m, r) for m, r in updated}
|
|
343
|
+
for move in reversed(moved):
|
|
344
|
+
pair = updated_by_new_path.get(move.new_relative)
|
|
345
|
+
try:
|
|
346
|
+
_move_file_back(project_root=project_root, move=move, folder=folder, use_git=use_git)
|
|
347
|
+
except (OSError, subprocess.CalledProcessError) as rollback_exc:
|
|
348
|
+
reasons.append(f"{move.new_relative}: rollback failed: {rollback_exc}")
|
|
349
|
+
continue # file never moved back - its record (if any) must keep the new path
|
|
350
|
+
if pair is None:
|
|
351
|
+
continue
|
|
352
|
+
matched, updated_record = pair
|
|
353
|
+
try:
|
|
354
|
+
service.update_record(
|
|
355
|
+
project=project, record_id=matched.record.id,
|
|
356
|
+
expected_version=updated_record.version,
|
|
357
|
+
content=None, file_path=matched.record.file_path, fields={},
|
|
358
|
+
)
|
|
359
|
+
except (service.VersionConflictError, service.RecordNotFoundError) as rollback_exc:
|
|
360
|
+
reasons.append(f"record {matched.record.id}: rollback failed: {rollback_exc}")
|
|
361
|
+
|
|
362
|
+
return ReorganizeResult(
|
|
363
|
+
plan=plan, backup_path=backup_path,
|
|
364
|
+
failure=ReorganizeFailure(reasons=reasons),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return ReorganizeResult(plan=plan, backup_path=backup_path, failure=None)
|
|
@@ -9,7 +9,7 @@ import re as _re
|
|
|
9
9
|
from collections.abc import Mapping
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
|
|
12
|
-
from cc_session_tools.lib.pdata import naming, repository
|
|
12
|
+
from cc_session_tools.lib.pdata import naming, repository, store
|
|
13
13
|
|
|
14
14
|
# op is deliberately \S+ here (not the literal alternation of valid ops) — matching only a
|
|
15
15
|
# literal alternation would make an invalid op (e.g. "~=") fail to match the whole regex at all,
|
|
@@ -213,6 +213,65 @@ def query_records(
|
|
|
213
213
|
conn.close()
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
def find_records_by_file_path_prefix(*, project: str, prefix: str) -> list[Record]:
|
|
217
|
+
"""Every active record across every record_group in this project whose file_path starts
|
|
218
|
+
with prefix - used by lib/pdata/reorganize.py to find which rows need their file_path
|
|
219
|
+
updated when a folder is split into a nested structure. Returns [] both when the project
|
|
220
|
+
has no .db yet and when it has one but nothing matches - callers that need to distinguish
|
|
221
|
+
"no store" from "store, no match" should check store.db_path(project).exists() themselves.
|
|
222
|
+
|
|
223
|
+
Goes straight to repository.query_records() with an already-parsed (field, op, value)
|
|
224
|
+
condition, one connection reused across every record_group, rather than routing through
|
|
225
|
+
this module's own query_records()/_parse_where_clause() string DSL: that DSL's
|
|
226
|
+
`\\s+`-based regex would silently strip any leading whitespace off prefix on the round
|
|
227
|
+
trip through it (a real, if narrow, correctness bug for a prefix that legitimately starts
|
|
228
|
+
with a space), and re-resolving a fresh connection per record_group here would be needless
|
|
229
|
+
overhead for what's logically one read. list_extension_columns/get_extension_row's
|
|
230
|
+
row-materialisation is duplicated inline here rather than shared with list_records/
|
|
231
|
+
query_records above - matching how those two already duplicate the same block between
|
|
232
|
+
each other rather than a factored-out helper.
|
|
233
|
+
|
|
234
|
+
Known limitation: prefix is still used as a raw SQL LIKE pattern, so a literal '%' or '_'
|
|
235
|
+
in it is interpreted as a wildcard rather than a literal character - SQLite's LIKE has no
|
|
236
|
+
ESCAPE-clause support wired up anywhere in this codebase. Not a practical issue for this
|
|
237
|
+
callsite specifically - prefix is always a project-relative folder path (this codebase's
|
|
238
|
+
own naming convention uses hyphens, not underscores, e.g. ws-01-slug), but worth knowing if
|
|
239
|
+
this function is ever reused somewhere prefix isn't a controlled folder name."""
|
|
240
|
+
if not prefix:
|
|
241
|
+
raise ValueError("find_records_by_file_path_prefix requires a non-empty prefix")
|
|
242
|
+
if not store.db_path(project).exists():
|
|
243
|
+
return []
|
|
244
|
+
conn = repository.connect(project)
|
|
245
|
+
try:
|
|
246
|
+
matches: list[Record] = []
|
|
247
|
+
for group in repository.list_record_groups(conn):
|
|
248
|
+
record_group = group["record_group"]
|
|
249
|
+
# list_record_groups() types its return as dict[str, object] since row_count and
|
|
250
|
+
# max_updated_at are int-shaped columns in the same row - record_group itself is
|
|
251
|
+
# always the TEXT column's value, never anything else, so this narrows it for
|
|
252
|
+
# mypy the same way an impossible-state assert would (coding-standards: make the
|
|
253
|
+
# impossible state visible rather than silently re-checking it).
|
|
254
|
+
assert isinstance(record_group, str)
|
|
255
|
+
rows = repository.query_records(
|
|
256
|
+
conn, record_group=record_group,
|
|
257
|
+
conditions=[("file_path", "LIKE", f"{prefix}%")],
|
|
258
|
+
limit=None, include_deleted=False,
|
|
259
|
+
)
|
|
260
|
+
has_ext = repository.extension_table_exists(conn, record_group)
|
|
261
|
+
for row in rows:
|
|
262
|
+
record = _row_to_record(row)
|
|
263
|
+
if has_ext:
|
|
264
|
+
ext_row = repository.get_extension_row(conn, record_group, row["id"])
|
|
265
|
+
if ext_row is not None:
|
|
266
|
+
record.fields = {
|
|
267
|
+
k: ext_row[k] for k in ext_row.keys() if k != "record_id"
|
|
268
|
+
}
|
|
269
|
+
matches.append(record)
|
|
270
|
+
return matches
|
|
271
|
+
finally:
|
|
272
|
+
conn.close()
|
|
273
|
+
|
|
274
|
+
|
|
216
275
|
class RecordNotFoundError(Exception):
|
|
217
276
|
"""Raised when a record id resolves to no row (or no active row)."""
|
|
218
277
|
|