tcw-cli 2.8.0__tar.gz → 2.8.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/PKG-INFO +27 -6
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/README.md +26 -5
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/pyproject.toml +1 -1
- tcw_cli-2.8.2/tcw/__init__.py +1 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/cli.py +9 -3
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/__init__.py +26 -3
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/base.py +14 -3
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/config_edit.py +4 -3
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/fs.py +148 -24
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/project.py +3 -1
- tcw_cli-2.8.2/tcw/store/yaml_source.py +25 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/validate.py +2 -1
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/cli.py +53 -5
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/PKG-INFO +27 -6
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/SOURCES.txt +5 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_already_integrated_check.py +142 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_blocker_cycles_through_unreadable_items.py +1 -1
- tcw_cli-2.8.2/tests/test_damaged_state_on_edit.py +125 -0
- tcw_cli-2.8.2/tests/test_detached_worktree_teardown.py +145 -0
- tcw_cli-2.8.2/tests/test_duplicate_slug_refusals.py +139 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_retention.py +3 -1
- tcw_cli-2.8.2/tests/test_serve_duplicate_slug.py +47 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unreadable_capabilities_sidecar.py +2 -1
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work.py +3 -1
- tcw_cli-2.8.0/tcw/__init__.py +0 -1
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/LICENSE +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/setup.cfg +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/capabilities/cli.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/cli_suggest.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/harness.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/refs.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/stdin.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/checkouts.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/__init__.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/claim.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/create.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/intake.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/jira.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/ownership.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/progress.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/sync.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/generate.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/audit-backlog.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/consolidate-plans.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/create-work.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/decompose.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/delegation.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/documentation-sync.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/post-mortem.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/search.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/triage-issues.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/unattended-work.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/projection.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/implement.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/inbox.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/request.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/verify.md +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/recursion.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/templates.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_boardless_delegate_and_slices.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities_rm.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capability_gate_children.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_check_versions.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_child_status.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_cli_help_coverage.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_command_suggestions.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_committed_paths.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_config_edit.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_config_edit_writers.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_configuration_text_home.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_creation_commits.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_cross_node_blocker_cycles.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_cross_node_blockers.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_dynamic_skill_marker.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_edit_refusal.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_edit_type.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_epic_completability_across_checkouts.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_coverage.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_files_changed.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_fixture.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_grading.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_runner.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_external_work_store.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_harness.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_init_configured_tree_path.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_initiative_node_qualified.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_interrupted_claim.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_item_tags_read.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_legacy_store_config.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_config_tags.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_literal_store_names.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_local_qualified_blockers.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_non_git_writes.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_override_in_linked_worktree.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_override_letter_case.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_override_references.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_planning_gates_after_start.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_procedure_config.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_procedure_verb.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_project_graph_paths.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_project_registry.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_projection.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_prompt_fallback.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_recursion.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_refs.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_rename_work_item.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_resolve.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_resolve_procedure.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_routing_nodes.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_shipped_procedures.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_show_json.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_sidecar_paths_in_validate.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_skill_path_pointers.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_smoke.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stage_validate.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stage_verb.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stale_item_path.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stdin.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_lock.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_provisioning.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_taxonomy_rm_gaps.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_absent.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_binding.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_catch_up_moves.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_claim.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_claimability.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_cli.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_client.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_comment.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_config.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_create.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_gate.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_hardening.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_help.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_hold.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_import.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_import_returns.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_inheritance.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_link.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_message_tidy.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_ownership.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_pre_backlog.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_replay.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_strict.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_strict_gate.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_surface.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_sync.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_sync_config.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_validate.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_transition_hints.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unattended_work_skill.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unplanned_start.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unreadable_item_files.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unreadable_state_gates.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_untag_invalid_tags.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_upstream_projects.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_validate.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_validate_moved_stores.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work_autocommit.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work_review.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work_start.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work_tags.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_worktree_completion.py +0 -0
- {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_worktree_sibling_nodes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tcw-cli
|
|
3
|
-
Version: 2.8.
|
|
3
|
+
Version: 2.8.2
|
|
4
4
|
Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -218,8 +218,8 @@ Dynamic: license-file
|
|
|
218
218
|
|
|
219
219
|
TCW keeps three things about a software project inside the repository, next to
|
|
220
220
|
the code: what the project deals with, what a user can do with it, and what is
|
|
221
|
-
being changed. One command-line tool, `tcw`, and an agent plugin for Claude Code
|
|
222
|
-
and
|
|
221
|
+
being changed. One command-line tool, `tcw`, and an agent plugin for Claude Code,
|
|
222
|
+
Codex, and Pi keep all three in step with the code.
|
|
223
223
|
|
|
224
224
|
| Component | Answers |
|
|
225
225
|
| ---------------- | ------------------------------------------------------- |
|
|
@@ -309,7 +309,28 @@ skill — it runs the same install script Claude runs automatically. Every tcw
|
|
|
309
309
|
skill also asks the agent to run the plugin's version check once per session,
|
|
310
310
|
which warns when the CLI and the skills come from different releases.
|
|
311
311
|
|
|
312
|
-
|
|
312
|
+
In **Pi**:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
pi install git:github.com/brocef/TCW
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Add `-l` to install for the current project instead of your personal settings.
|
|
319
|
+
For a development checkout, use `pi install /absolute/path/to/TCW`.
|
|
320
|
+
Start a new Pi session, then run `/skill:setup` to install or check the `tcw`
|
|
321
|
+
CLI. You can also install it yourself with `pipx install tcw-cli`.
|
|
322
|
+
Invoke skills with `/skill:work`, `/skill:capabilities`, or `/skill:taxonomy`.
|
|
323
|
+
|
|
324
|
+
Pi loads the same skills through the official `pi` declaration in `package.json`.
|
|
325
|
+
It does not run TCW's Claude session-start hook or shell injections; the skills
|
|
326
|
+
provide explicit commands for setup, version checks, and lifecycle instructions.
|
|
327
|
+
Claude's custom review agents are not installed as Pi agents. When delegation
|
|
328
|
+
is unavailable, run ordinary lifecycle stages in the main session; autonomous
|
|
329
|
+
work still requires the advisors named by that workflow.
|
|
330
|
+
Git installation also installs the repository's Node package dependencies,
|
|
331
|
+
which support the TCW web app; local-path installation uses the checkout directly.
|
|
332
|
+
|
|
333
|
+
The plugin ships the skills and Claude Code read-only review agents described in
|
|
313
334
|
[Skills and Agents](#skills-and-agents).
|
|
314
335
|
|
|
315
336
|
### CLI
|
|
@@ -702,7 +723,7 @@ The transitions, and what each one checks before it moves an item:
|
|
|
702
723
|
| `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
|
|
703
724
|
| `submit` | `tcw work submit <slug>` | active → review | never |
|
|
704
725
|
| `rework` | `tcw work rework <slug>` | review → active | `refined-outcome.md` is present (it says the work was accepted) |
|
|
705
|
-
| `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; with `--already-integrated`, the branch is not merged into `HEAD`; the item's folder in its worktree has uncommitted changes; no `--confirm` |
|
|
726
|
+
| `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; with `--already-integrated`, the branch is not merged into `HEAD`; the item's folder in its worktree has uncommitted changes; its worktree holds commits on no branch; no `--confirm` |
|
|
706
727
|
| discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
|
|
707
728
|
| `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
|
|
708
729
|
| not retained | happens during `complete` or discard | completed or discarded → removed | only happens when `work.retain` says that status is not kept; `tcw work delete` finishes one that was interrupted |
|
|
@@ -940,7 +961,7 @@ resolve, what has to be true before an item completes. What a command cannot
|
|
|
940
961
|
decide (what a request really asks for, whether a spec is good enough, whether
|
|
941
962
|
the work is finished) is judgment, and the plugin's skills guide an agent through
|
|
942
963
|
it. Skills name `tcw` commands and never reimplement them. Every entry point is a
|
|
943
|
-
skill,
|
|
964
|
+
skill, with the same CLI-backed lifecycle under Claude Code, Codex, and Pi.
|
|
944
965
|
|
|
945
966
|
The skills for a single axis are listed in that axis's section above. These cut
|
|
946
967
|
across the axes.
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
TCW keeps three things about a software project inside the repository, next to
|
|
4
4
|
the code: what the project deals with, what a user can do with it, and what is
|
|
5
|
-
being changed. One command-line tool, `tcw`, and an agent plugin for Claude Code
|
|
6
|
-
and
|
|
5
|
+
being changed. One command-line tool, `tcw`, and an agent plugin for Claude Code,
|
|
6
|
+
Codex, and Pi keep all three in step with the code.
|
|
7
7
|
|
|
8
8
|
| Component | Answers |
|
|
9
9
|
| ---------------- | ------------------------------------------------------- |
|
|
@@ -93,7 +93,28 @@ skill — it runs the same install script Claude runs automatically. Every tcw
|
|
|
93
93
|
skill also asks the agent to run the plugin's version check once per session,
|
|
94
94
|
which warns when the CLI and the skills come from different releases.
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
In **Pi**:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pi install git:github.com/brocef/TCW
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Add `-l` to install for the current project instead of your personal settings.
|
|
103
|
+
For a development checkout, use `pi install /absolute/path/to/TCW`.
|
|
104
|
+
Start a new Pi session, then run `/skill:setup` to install or check the `tcw`
|
|
105
|
+
CLI. You can also install it yourself with `pipx install tcw-cli`.
|
|
106
|
+
Invoke skills with `/skill:work`, `/skill:capabilities`, or `/skill:taxonomy`.
|
|
107
|
+
|
|
108
|
+
Pi loads the same skills through the official `pi` declaration in `package.json`.
|
|
109
|
+
It does not run TCW's Claude session-start hook or shell injections; the skills
|
|
110
|
+
provide explicit commands for setup, version checks, and lifecycle instructions.
|
|
111
|
+
Claude's custom review agents are not installed as Pi agents. When delegation
|
|
112
|
+
is unavailable, run ordinary lifecycle stages in the main session; autonomous
|
|
113
|
+
work still requires the advisors named by that workflow.
|
|
114
|
+
Git installation also installs the repository's Node package dependencies,
|
|
115
|
+
which support the TCW web app; local-path installation uses the checkout directly.
|
|
116
|
+
|
|
117
|
+
The plugin ships the skills and Claude Code read-only review agents described in
|
|
97
118
|
[Skills and Agents](#skills-and-agents).
|
|
98
119
|
|
|
99
120
|
### CLI
|
|
@@ -486,7 +507,7 @@ The transitions, and what each one checks before it moves an item:
|
|
|
486
507
|
| `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
|
|
487
508
|
| `submit` | `tcw work submit <slug>` | active → review | never |
|
|
488
509
|
| `rework` | `tcw work rework <slug>` | review → active | `refined-outcome.md` is present (it says the work was accepted) |
|
|
489
|
-
| `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; with `--already-integrated`, the branch is not merged into `HEAD`; the item's folder in its worktree has uncommitted changes; no `--confirm` |
|
|
510
|
+
| `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; with `--already-integrated`, the branch is not merged into `HEAD`; the item's folder in its worktree has uncommitted changes; its worktree holds commits on no branch; no `--confirm` |
|
|
490
511
|
| discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
|
|
491
512
|
| `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
|
|
492
513
|
| not retained | happens during `complete` or discard | completed or discarded → removed | only happens when `work.retain` says that status is not kept; `tcw work delete` finishes one that was interrupted |
|
|
@@ -724,7 +745,7 @@ resolve, what has to be true before an item completes. What a command cannot
|
|
|
724
745
|
decide (what a request really asks for, whether a spec is good enough, whether
|
|
725
746
|
the work is finished) is judgment, and the plugin's skills guide an agent through
|
|
726
747
|
it. Skills name `tcw` commands and never reimplement them. Every entry point is a
|
|
727
|
-
skill,
|
|
748
|
+
skill, with the same CLI-backed lifecycle under Claude Code, Codex, and Pi.
|
|
728
749
|
|
|
729
750
|
The skills for a single axis are listed in that axis's section above. These cut
|
|
730
751
|
across the axes.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tcw-cli"
|
|
7
|
-
version = "2.8.
|
|
7
|
+
version = "2.8.2"
|
|
8
8
|
description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.8.2"
|
|
@@ -19,7 +19,9 @@ from tcw.store.fs import (
|
|
|
19
19
|
FsWorkStore, declared_repository, find_node_root, git_root, init,
|
|
20
20
|
provisioned_store_root,
|
|
21
21
|
)
|
|
22
|
+
from tcw.store.base import MultipleMatch
|
|
22
23
|
from tcw.store.project import FsProjectRegistry
|
|
24
|
+
from tcw.store.yaml_source import load as _load_named_yaml
|
|
23
25
|
import yaml
|
|
24
26
|
from tcw.taxonomy import cli as taxonomy_cli
|
|
25
27
|
from tcw.work import cli as work_cli
|
|
@@ -56,7 +58,8 @@ def run_init(components: list[str], project_id: str | None = None,
|
|
|
56
58
|
sentinel = root / SENTINEL
|
|
57
59
|
if project_id is None:
|
|
58
60
|
try:
|
|
59
|
-
configured =
|
|
61
|
+
configured = (_load_named_yaml(sentinel.read_text(encoding="utf-8"), sentinel)
|
|
62
|
+
if sentinel.exists() else {})
|
|
60
63
|
except yaml.YAMLError as error:
|
|
61
64
|
print(f"tcw init: invalid {SENTINEL}: {error}", file=sys.stderr)
|
|
62
65
|
return 1
|
|
@@ -548,7 +551,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
548
551
|
args = build_parser().parse_args(_normalize(argv))
|
|
549
552
|
try:
|
|
550
553
|
return args.func(args)
|
|
551
|
-
except ValueError as error:
|
|
554
|
+
except (ValueError, MultipleMatch) as error:
|
|
555
|
+
# `MultipleMatch` is not a `ValueError`, so a verb that does not catch it
|
|
556
|
+
# itself — `start`, `submit` — printed a traceback for a slug two folders
|
|
557
|
+
# hold. Its message names both folders; this is only the safety net.
|
|
552
558
|
print(f"tcw: {error}", file=sys.stderr)
|
|
553
559
|
return 1
|
|
554
560
|
except yaml.YAMLError as error:
|
|
@@ -557,7 +563,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
557
563
|
# twenty-odd `load_yaml` calls that do not catch it: refusing is already
|
|
558
564
|
# the right answer at every one of them — `init` must not overwrite a
|
|
559
565
|
# config it cannot read — and only the presentation was wrong. The
|
|
560
|
-
# loader's message names the file.
|
|
566
|
+
# loader's message names the file (`tcw.store.yaml_source.load`).
|
|
561
567
|
print(f"tcw: {error}", file=sys.stderr)
|
|
562
568
|
return 1
|
|
563
569
|
except subprocess.CalledProcessError as error:
|
|
@@ -20,7 +20,8 @@ from urllib.parse import unquote, urlparse
|
|
|
20
20
|
from tcw.work.projection import work_item_json
|
|
21
21
|
from tcw.store.base import (
|
|
22
22
|
CAP_FIELDS, CAP_STATUSES, WORK_ARTIFACTS, WORK_SIDECARS, _UNSET,
|
|
23
|
-
IllegalTransition, PublicationError, RefError, StaleRevision,
|
|
23
|
+
IllegalTransition, MultipleMatch, PublicationError, RefError, StaleRevision,
|
|
24
|
+
TransitionCommitError,
|
|
24
25
|
)
|
|
25
26
|
from tcw.store.fs import (
|
|
26
27
|
FsCapabilitiesStore, FsTaxonomyStore, FsWorkStore, descendant_nodes,
|
|
@@ -300,6 +301,9 @@ def _map_store_error(e: Exception) -> tuple[int, bytes]:
|
|
|
300
301
|
# because strict-tracker and generated-sidecar refusals are 409 too and
|
|
301
302
|
# must show their own message instead.
|
|
302
303
|
return _err(HTTPStatus.CONFLICT, str(e), code="stale-revision")
|
|
304
|
+
if isinstance(e, MultipleMatch):
|
|
305
|
+
# Two folders hold the slug: the data needs fixing, not the request.
|
|
306
|
+
return _err(HTTPStatus.CONFLICT, str(e))
|
|
303
307
|
if isinstance(e, IllegalTransition):
|
|
304
308
|
return _err(HTTPStatus.UNPROCESSABLE_ENTITY, str(e))
|
|
305
309
|
if isinstance(e, RefError):
|
|
@@ -551,8 +555,17 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
551
555
|
for root, prefix in self._board_roots():
|
|
552
556
|
work = FsWorkStore.open(root)
|
|
553
557
|
for it in work.board():
|
|
554
|
-
|
|
555
|
-
|
|
558
|
+
try:
|
|
559
|
+
items.append(_item_payload(work, it.slug, it,
|
|
560
|
+
f"{prefix}{it.slug}" if prefix else None))
|
|
561
|
+
except MultipleMatch:
|
|
562
|
+
# Two folders hold the slug, so its artifacts cannot be
|
|
563
|
+
# read. Listed with none, so one duplicate does not take the
|
|
564
|
+
# board down; the item's own route says why.
|
|
565
|
+
data = work_item_json(it, [])
|
|
566
|
+
if prefix:
|
|
567
|
+
data["slug"] = f"{prefix}{it.slug}"
|
|
568
|
+
items.append(data)
|
|
556
569
|
return items
|
|
557
570
|
|
|
558
571
|
def _board_roots(self) -> list[tuple[Path, str]]:
|
|
@@ -580,6 +593,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
580
593
|
return
|
|
581
594
|
try:
|
|
582
595
|
self._get()
|
|
596
|
+
except MultipleMatch as e:
|
|
597
|
+
self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
|
|
583
598
|
except Exception as e:
|
|
584
599
|
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
|
|
585
600
|
|
|
@@ -588,6 +603,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
588
603
|
return
|
|
589
604
|
try:
|
|
590
605
|
self._post()
|
|
606
|
+
except MultipleMatch as e:
|
|
607
|
+
self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
|
|
591
608
|
except Exception as e:
|
|
592
609
|
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
|
|
593
610
|
|
|
@@ -596,6 +613,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
596
613
|
return
|
|
597
614
|
try:
|
|
598
615
|
self._patch()
|
|
616
|
+
except MultipleMatch as e:
|
|
617
|
+
self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
|
|
599
618
|
except Exception as e:
|
|
600
619
|
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
|
|
601
620
|
|
|
@@ -604,6 +623,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
604
623
|
return
|
|
605
624
|
try:
|
|
606
625
|
self._put()
|
|
626
|
+
except MultipleMatch as e:
|
|
627
|
+
self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
|
|
607
628
|
except Exception as e:
|
|
608
629
|
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
|
|
609
630
|
|
|
@@ -612,6 +633,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
612
633
|
return
|
|
613
634
|
try:
|
|
614
635
|
self._delete()
|
|
636
|
+
except MultipleMatch as e:
|
|
637
|
+
self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
|
|
615
638
|
except Exception as e:
|
|
616
639
|
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
|
|
617
640
|
|
|
@@ -4339,7 +4339,9 @@ class WorkStore(ABC):
|
|
|
4339
4339
|
remedies.append("Fix or replace each damaged state.yaml "
|
|
4340
4340
|
"(`tcw validate` lists them)")
|
|
4341
4341
|
if any(reason == _HELD_TWICE for _, reason in found):
|
|
4342
|
-
remedies.append("
|
|
4342
|
+
remedies.append("keep one folder of each slug held twice, merge any "
|
|
4343
|
+
"files it lacks from the others, and remove them "
|
|
4344
|
+
"(`tcw validate` names both folders)")
|
|
4343
4345
|
remedy = "; ".join(remedies)
|
|
4344
4346
|
many = len(blockers) > 1
|
|
4345
4347
|
raise self._unreadable_refusal(
|
|
@@ -4449,8 +4451,12 @@ class WorkStore(ABC):
|
|
|
4449
4451
|
holds its epic open). Built on `initiative_children` (cross-node in
|
|
4450
4452
|
adapters that override it), so the "all resolved" signal and the
|
|
4451
4453
|
`complete` gate share one source of truth. An empty epic is not
|
|
4452
|
-
completable (nothing resolved).
|
|
4453
|
-
|
|
4454
|
+
completable (nothing resolved). Nor is one whose slug two folders hold:
|
|
4455
|
+
which of them would close cannot be known."""
|
|
4456
|
+
try:
|
|
4457
|
+
if not self.epic_children_all_resolved(item):
|
|
4458
|
+
return False
|
|
4459
|
+
except MultipleMatch:
|
|
4454
4460
|
return False
|
|
4455
4461
|
# `complete` refuses while anything beneath the epic by `parent` is open,
|
|
4456
4462
|
# so calling it ready then would promise a close that cannot happen —
|
|
@@ -4557,6 +4563,11 @@ class WorkStore(ABC):
|
|
|
4557
4563
|
# reference the rename could not rewrite would unblock.
|
|
4558
4564
|
if blocker is None and (renamed := self.renamed(b["slug"])):
|
|
4559
4565
|
blocker = self.get(renamed)
|
|
4566
|
+
except MultipleMatch:
|
|
4567
|
+
# Still a blocker, and said why, since nothing else in this
|
|
4568
|
+
# item's refusal would explain it.
|
|
4569
|
+
out.append(f"{b['slug']} (held by more than one folder)")
|
|
4570
|
+
continue
|
|
4560
4571
|
except ValueError:
|
|
4561
4572
|
# An adapter can refuse to settle a blocker — a claim on it
|
|
4562
4573
|
# was abandoned. That is still a blocker, and reporting it as
|
|
@@ -40,6 +40,7 @@ from dataclasses import dataclass
|
|
|
40
40
|
from pathlib import Path
|
|
41
41
|
|
|
42
42
|
import yaml
|
|
43
|
+
from tcw.store.yaml_source import load as _load_named_yaml
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
class ConfigEditRefused(ValueError):
|
|
@@ -131,7 +132,7 @@ def edit_text(path: Path, text: str | None, edits: list[Edit]) -> str | None:
|
|
|
131
132
|
if not target:
|
|
132
133
|
return None
|
|
133
134
|
return yaml.safe_dump(target, sort_keys=False, allow_unicode=True)
|
|
134
|
-
mapping = _load(text)
|
|
135
|
+
mapping = _load(text, path)
|
|
135
136
|
if not isinstance(mapping, dict):
|
|
136
137
|
mapping = {}
|
|
137
138
|
target = intended(mapping, edits)
|
|
@@ -167,9 +168,9 @@ def edit_text(path: Path, text: str | None, edits: list[Edit]) -> str | None:
|
|
|
167
168
|
|
|
168
169
|
# ── reading ─────────────────────────────────────────────────────────────────
|
|
169
170
|
|
|
170
|
-
def _load(text: str):
|
|
171
|
+
def _load(text: str, path: Path | str = "the edited text"):
|
|
171
172
|
from tcw.store.fs import _UniqueKeyLoader # fs.py imports this module
|
|
172
|
-
return
|
|
173
|
+
return _load_named_yaml(text, path, _UniqueKeyLoader)
|
|
173
174
|
|
|
174
175
|
|
|
175
176
|
def _assemble(text: str, replacements: list[_Replacement]) -> str:
|
|
@@ -37,6 +37,7 @@ except ImportError: # not POSIX
|
|
|
37
37
|
|
|
38
38
|
import yaml
|
|
39
39
|
|
|
40
|
+
from tcw.store.yaml_source import load as _load_named_yaml
|
|
40
41
|
from tcw.store.base import (
|
|
41
42
|
_HELD_TWICE, BODY_ORDER, CAP_FIELDS, CAP_LIFECYCLES, CAP_PRIORITIES, CAP_STATUSES,
|
|
42
43
|
DEFAULT_DOD, InboxEntryNotFound,
|
|
@@ -1134,7 +1135,21 @@ def branch_integration(node_root: Path, branch: str) -> str | None:
|
|
|
1134
1135
|
Fails closed. A `git` without `merge-tree --write-tree` (older than 2.38)
|
|
1135
1136
|
exits non-zero, which reads as "not shown to be merged", the same as a
|
|
1136
1137
|
conflict does. Never run against the store's repository: the branch belongs
|
|
1137
|
-
to the code the node's checkout holds.
|
|
1138
|
+
to the code the node's checkout holds.
|
|
1139
|
+
|
|
1140
|
+
**A repository's own merge drivers do not get a say.** `merge-tree` runs
|
|
1141
|
+
them, and one configured to keep `HEAD`'s side (`merge.<name>.driver=true`
|
|
1142
|
+
under a `merge=<name>` attribute, from any attributes file) hides every
|
|
1143
|
+
change the branch made to a file both sides touched — the trial then
|
|
1144
|
+
produces `HEAD`'s tree and a branch that was never merged would be deleted.
|
|
1145
|
+
Every configured driver is replaced by `false` for the trial, so any file one
|
|
1146
|
+
would decide becomes a conflict. That refusal rests on the **exit code**, not
|
|
1147
|
+
the tree: git keeps `HEAD`'s side when the driver fails, so the tree still
|
|
1148
|
+
matches — comparing trees alone would reopen this. The cost: a squash merge
|
|
1149
|
+
touching such a file on both sides is not confirmed, and the way out is the
|
|
1150
|
+
one every refusal names. Passed with `-c`, last on the command line, because
|
|
1151
|
+
that beats an inherited `GIT_CONFIG_PARAMETERS` — which git exports to hooks
|
|
1152
|
+
run under `git -c` — where the `GIT_CONFIG_COUNT` variables do not."""
|
|
1138
1153
|
if git_root(node_root) is None:
|
|
1139
1154
|
return (f"the primary checkout at {node_root} is not in a git repository, "
|
|
1140
1155
|
f"so {branch} cannot be checked")
|
|
@@ -1159,7 +1174,16 @@ def branch_integration(node_root: Path, branch: str) -> str | None:
|
|
|
1159
1174
|
ref = f"refs/heads/{branch}"
|
|
1160
1175
|
if run("merge-base", "--is-ancestor", ref, "HEAD").returncode == 0:
|
|
1161
1176
|
return None
|
|
1162
|
-
|
|
1177
|
+
drivers, unusable = _merge_drivers(node_root)
|
|
1178
|
+
if unusable:
|
|
1179
|
+
return (f"{branch} cannot be checked against HEAD {head} ({current}): "
|
|
1180
|
+
f"{unusable}. Pull the merge into this checkout, or merge {branch}, "
|
|
1181
|
+
f"then re-run")
|
|
1182
|
+
# `union` too, configured or not: the built-in keeps both sides' lines, so a
|
|
1183
|
+
# line the branch deleted and `HEAD` changed comes back as `HEAD`'s.
|
|
1184
|
+
overrides = [a for name in dict.fromkeys([*drivers, "union"])
|
|
1185
|
+
for a in ("-c", f"merge.{name}.driver=false")]
|
|
1186
|
+
merged = run(*overrides, "merge-tree", "--write-tree", "HEAD", ref)
|
|
1163
1187
|
tree = run("rev-parse", "HEAD^{tree}").stdout.strip()
|
|
1164
1188
|
if merged.returncode == 0 and merged.stdout.split("\n", 1)[0].strip() == tree:
|
|
1165
1189
|
return None
|
|
@@ -1169,17 +1193,105 @@ def branch_integration(node_root: Path, branch: str) -> str | None:
|
|
|
1169
1193
|
detail = (merged.stderr.strip().splitlines() or ["no output"])[0]
|
|
1170
1194
|
return (f"git could not check whether {branch} is merged into HEAD {head} "
|
|
1171
1195
|
f"({detail}); `git merge-tree --write-tree` needs git 2.38 or newer")
|
|
1196
|
+
because = (f" This repository defines its own merge drivers "
|
|
1197
|
+
f"({', '.join(drivers)}), which this check does not let decide a "
|
|
1198
|
+
f"file (nor git's `union`), so a squash merge touching a file one "
|
|
1199
|
+
f"governs is not confirmed." if drivers else "")
|
|
1172
1200
|
return (f"{branch} is not merged into this checkout's HEAD {head} ({current}): "
|
|
1173
|
-
f"merging it would still change files. Pull the merge into
|
|
1174
|
-
f"checkout, or merge {branch}, then re-run")
|
|
1201
|
+
f"merging it would still change files.{because} Pull the merge into "
|
|
1202
|
+
f"this checkout, or merge {branch}, then re-run")
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _merge_drivers(node_root: Path) -> tuple[list[str], str | None]:
|
|
1206
|
+
"""The names of the custom merge drivers configured for the node's
|
|
1207
|
+
repository — every scope, includes and the environment's too — and a reason
|
|
1208
|
+
the trial merge cannot be trusted, or None.
|
|
1209
|
+
|
|
1210
|
+
A name holding `=` cannot be written as `-c merge.<name>.driver=false`, and
|
|
1211
|
+
git failing to list them at all (a malformed config) leaves unknown what
|
|
1212
|
+
would run; both fail closed."""
|
|
1213
|
+
r = _git(["git", "-C", str(node_root), "config", "--null", "--name-only",
|
|
1214
|
+
"--get-regexp", r"^merge\..+\.driver$"], capture_output=True, text=True)
|
|
1215
|
+
if r.returncode == 1:
|
|
1216
|
+
return [], None # none configured
|
|
1217
|
+
if r.returncode != 0:
|
|
1218
|
+
detail = (r.stderr.strip().splitlines() or ["no output"])[0]
|
|
1219
|
+
return [], f"git could not list the repository's merge drivers ({detail})"
|
|
1220
|
+
names = list(dict.fromkeys(key[len("merge."):-len(".driver")]
|
|
1221
|
+
for key in r.stdout.split("\0") if key))
|
|
1222
|
+
if odd := [n for n in names if "=" in n]:
|
|
1223
|
+
return names, (f"it configures a merge driver whose name holds '=' "
|
|
1224
|
+
f"({', '.join(odd)}), which this check cannot switch off")
|
|
1225
|
+
return names, None
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
UNBRANCHED_SHOWN = 10
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def unbranched_commits(worktree: Path) -> "list[str] | str":
|
|
1232
|
+
"""Short hashes of the commits removing `worktree` would lose — reachable
|
|
1233
|
+
from its `HEAD` and from no branch, tag or remote-tracking branch — or a
|
|
1234
|
+
reason when git cannot say. `[]` when there is no such folder.
|
|
1235
|
+
|
|
1236
|
+
Only a detached `HEAD` can hold them: a worktree on a branch is covered by
|
|
1237
|
+
`--branches`. `--all` would not do, since it counts every worktree's `HEAD`,
|
|
1238
|
+
this one's included. A stash is deliberately not counted as keeping a commit:
|
|
1239
|
+
it is a scratch area people clear without thinking of it as a save.
|
|
1240
|
+
|
|
1241
|
+
`git worktree remove` also deletes the worktree's own reflog, so these
|
|
1242
|
+
commits then survive only as unreachable objects until garbage collection.
|
|
1243
|
+
|
|
1244
|
+
Checked to be the worktree's own top first: `.worktrees/` sits inside the
|
|
1245
|
+
primary checkout, so `git -C` on a plain folder there answers silently for
|
|
1246
|
+
the *primary checkout* — naming its commits as this folder's."""
|
|
1247
|
+
if not worktree.is_dir():
|
|
1248
|
+
return []
|
|
1249
|
+
run = lambda *a: _git(["git", "-C", str(worktree), *a], # noqa: E731
|
|
1250
|
+
capture_output=True, text=True)
|
|
1251
|
+
top = run("rev-parse", "--show-toplevel")
|
|
1252
|
+
# By folder identity, not text: git spells the path as the disk does, and a
|
|
1253
|
+
# project reached through an override in other letter case spells it its way.
|
|
1254
|
+
if top.returncode != 0 or not top.stdout.strip() or \
|
|
1255
|
+
not _same_folder(Path(top.stdout.strip()), worktree):
|
|
1256
|
+
return f"git could not check {worktree}: it is not a git worktree of its own"
|
|
1257
|
+
r = run("rev-list", "--abbrev-commit", "HEAD",
|
|
1258
|
+
"--not", "--branches", "--tags", "--remotes")
|
|
1259
|
+
if r.returncode != 0:
|
|
1260
|
+
detail = (r.stderr.strip().splitlines() or ["no output"])[0]
|
|
1261
|
+
return f"git could not check {worktree} ({detail})"
|
|
1262
|
+
return r.stdout.split()
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def unbranched_summary(commits: list[str]) -> str:
|
|
1266
|
+
"""`commits` as a refusal names them: at most `UNBRANCHED_SHOWN`."""
|
|
1267
|
+
shown = ", ".join(commits[:UNBRANCHED_SHOWN])
|
|
1268
|
+
more = len(commits) - UNBRANCHED_SHOWN
|
|
1269
|
+
return shown + (f" and {more} more" if more > 0 else "")
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def unbranched_problem(worktree: Path) -> str | None:
|
|
1273
|
+
"""Why `worktree` must not be removed, or None when nothing would be lost."""
|
|
1274
|
+
found = unbranched_commits(worktree)
|
|
1275
|
+
if isinstance(found, str):
|
|
1276
|
+
return f"{found}, so it was not removed"
|
|
1277
|
+
if not found:
|
|
1278
|
+
return None
|
|
1279
|
+
return (f"the worktree at {worktree} has commits on no branch — "
|
|
1280
|
+
f"{unbranched_summary(found)} — and removing it would lose them. Save "
|
|
1281
|
+
f"them with `git -C {worktree} branch <name>`, then remove it with "
|
|
1282
|
+
f"`git worktree remove {worktree}`")
|
|
1175
1283
|
|
|
1176
1284
|
|
|
1177
1285
|
def remove_worktree(node_root: Path, slug: str, branch: str | None = None) -> list[str]:
|
|
1178
1286
|
"""Best-effort teardown (Spec 2 §3.4): `git worktree remove` refuses on a
|
|
1179
|
-
dirty worktree — the safety net against losing uncommitted work
|
|
1180
|
-
|
|
1287
|
+
dirty worktree — the safety net against losing uncommitted work — and this
|
|
1288
|
+
refuses one whose detached `HEAD` holds commits no ref does, which git
|
|
1289
|
+
would remove. Returns warnings (empty == clean); a refusal keeps the branch
|
|
1290
|
+
as well as the worktree."""
|
|
1181
1291
|
warns: list[str] = []
|
|
1182
1292
|
wt = node_root / WORKTREES_DIR / slug
|
|
1293
|
+
if problem := unbranched_problem(wt):
|
|
1294
|
+
return [problem]
|
|
1183
1295
|
r = _git(["git", "-C", str(node_root), "worktree", "remove", str(wt)],
|
|
1184
1296
|
capture_output=True, text=True)
|
|
1185
1297
|
if r.returncode != 0:
|
|
@@ -1547,7 +1659,8 @@ def load_yaml(path: Path, unique: bool = False) -> dict:
|
|
|
1547
1659
|
if not path.exists():
|
|
1548
1660
|
return {}
|
|
1549
1661
|
text = path.read_text(encoding="utf-8")
|
|
1550
|
-
data =
|
|
1662
|
+
data = _load_named_yaml(text, path,
|
|
1663
|
+
_UniqueKeyLoader if unique else yaml.SafeLoader)
|
|
1551
1664
|
if data is None: # absent content: empty file, or `null`
|
|
1552
1665
|
return {}
|
|
1553
1666
|
if not isinstance(data, dict):
|
|
@@ -4946,7 +5059,11 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
4946
5059
|
matches = [d for d in self._item_dirs() if d.name == slug]
|
|
4947
5060
|
if len(matches) <= 1:
|
|
4948
5061
|
return matches[0] if matches else None
|
|
4949
|
-
raise MultipleMatch(
|
|
5062
|
+
raise MultipleMatch(
|
|
5063
|
+
f"{slug} is held by {len(matches)} folders — "
|
|
5064
|
+
f"{', '.join(self._shown_path(d) for d in sorted(matches))}. Keep the one "
|
|
5065
|
+
f"whose state.yaml is right, merge any files it lacks from the others, "
|
|
5066
|
+
f"and remove them; `tcw validate` lists every slug held twice")
|
|
4950
5067
|
|
|
4951
5068
|
def _require_dir(self, slug: str) -> Path:
|
|
4952
5069
|
d = self._find(slug)
|
|
@@ -5260,21 +5377,25 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
5260
5377
|
return "it is not a regular file"
|
|
5261
5378
|
return None
|
|
5262
5379
|
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
"""Refuse to move an item whose `state.yaml` cannot be read.
|
|
5380
|
+
def _require_readable_state(self, d: Path, slug: str) -> None:
|
|
5381
|
+
"""Refuse to move or edit an item whose `state.yaml` cannot be read.
|
|
5266
5382
|
|
|
5267
5383
|
The board reads it through `_safe_yaml`, which degrades a damaged file
|
|
5268
5384
|
to defaults — no blockers, no owner, no type — so every gate a move
|
|
5269
5385
|
checks would pass on values the item does not hold, and the strict read
|
|
5270
5386
|
that follows the move would fail with the item already moved. Asked
|
|
5271
5387
|
before anything moves, so the refusal means nothing happened. A folder
|
|
5272
|
-
gone meanwhile is the caller's lost race to report, not damage.
|
|
5273
|
-
|
|
5388
|
+
gone meanwhile is the caller's lost race to report, not damage.
|
|
5389
|
+
|
|
5390
|
+
An edit asks too, before its own strict read: that read refuses as well,
|
|
5391
|
+
but with only the parser's text, which names neither item nor file."""
|
|
5392
|
+
state = d / "state.yaml"
|
|
5393
|
+
reason = FsWorkStore._state_damage(state)
|
|
5274
5394
|
if reason is None:
|
|
5275
5395
|
return
|
|
5276
|
-
raise ValueError(f"{slug}: state
|
|
5277
|
-
f"fix or replace it before changing the item"
|
|
5396
|
+
raise ValueError(f"{slug}: {self._shown_path(state)} cannot be read "
|
|
5397
|
+
f"({reason}); fix or replace it before changing the item "
|
|
5398
|
+
f"— `tcw validate` lists every item it cannot read")
|
|
5278
5399
|
|
|
5279
5400
|
def _item_from_dir(self, d: Path) -> WorkItem | None:
|
|
5280
5401
|
"""`None` when the folder went away mid-read — a concurrent claim moved
|
|
@@ -5301,27 +5422,28 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
5301
5422
|
return None
|
|
5302
5423
|
return item if (d / "state.yaml").exists() else None
|
|
5303
5424
|
|
|
5304
|
-
|
|
5305
|
-
def _read_capabilities_sidecar(caps: Path) -> object:
|
|
5425
|
+
def _read_capabilities_sidecar(self, caps: Path) -> object:
|
|
5306
5426
|
"""The parsed sidecar, `None` if it went away, or the `_tcw_parse_error`
|
|
5307
5427
|
value for anything that cannot be read: one item's bad file must list as
|
|
5308
5428
|
that item's problem, never take down the board or hang a projection.
|
|
5309
5429
|
`is_file()` comes first because no exception rescues a read that blocks
|
|
5310
|
-
on a named pipe.
|
|
5430
|
+
on a named pipe. Problems name the file from the project root, so a
|
|
5431
|
+
parse error says which item's sidecar it is."""
|
|
5432
|
+
shown = self._shown_path(caps)
|
|
5311
5433
|
problem = None
|
|
5312
5434
|
try:
|
|
5313
5435
|
if not caps.is_file():
|
|
5314
|
-
return ({"_tcw_parse_error": f"{
|
|
5436
|
+
return ({"_tcw_parse_error": f"{shown} is not a regular file"}
|
|
5315
5437
|
if caps.exists() else None)
|
|
5316
5438
|
if caps.stat().st_size > SIDECAR_MAX_BYTES:
|
|
5317
|
-
problem = f"{
|
|
5439
|
+
problem = f"{shown} is larger than {SIDECAR_MAX_BYTES} bytes"
|
|
5318
5440
|
else:
|
|
5319
|
-
parsed =
|
|
5441
|
+
parsed = _load_named_yaml(caps.read_text(encoding="utf-8"), shown)
|
|
5320
5442
|
problem = sidecar_value_problem(parsed)
|
|
5321
5443
|
except FileNotFoundError:
|
|
5322
5444
|
return None
|
|
5323
5445
|
except UnicodeDecodeError:
|
|
5324
|
-
problem = f"{
|
|
5446
|
+
problem = f"{shown} is not valid UTF-8"
|
|
5325
5447
|
except (OSError, ValueError, yaml.YAMLError, RecursionError) as e:
|
|
5326
5448
|
problem = str(e)
|
|
5327
5449
|
if problem is not None:
|
|
@@ -6977,7 +7099,7 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
6977
7099
|
def dod_checklist(self) -> list[str]:
|
|
6978
7100
|
p = self.root / "dod.yaml"
|
|
6979
7101
|
if p.exists():
|
|
6980
|
-
data =
|
|
7102
|
+
data = _load_named_yaml(p.read_text(encoding="utf-8"), self._shown_path(p))
|
|
6981
7103
|
if isinstance(data, list):
|
|
6982
7104
|
return [str(x) for x in data]
|
|
6983
7105
|
if isinstance(data, dict) and isinstance(data.get("checklist"), list):
|
|
@@ -8154,6 +8276,7 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
8154
8276
|
locations by a move landing between them.
|
|
8155
8277
|
"""
|
|
8156
8278
|
self._require_repository()
|
|
8279
|
+
self._require_readable_state(d, d.name)
|
|
8157
8280
|
state = load_yaml(d / "state.yaml")
|
|
8158
8281
|
state.update(fields)
|
|
8159
8282
|
self._write_staged([(d / "state.yaml",
|
|
@@ -8514,6 +8637,7 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
8514
8637
|
# Read current state. A body write always targets the request, never the
|
|
8515
8638
|
# read fallback: following it would either mutate raw intake or quietly
|
|
8516
8639
|
# satisfy the `request` stage with text the author meant as an edit.
|
|
8640
|
+
self._require_readable_state(d, slug)
|
|
8517
8641
|
state = load_yaml(d / "state.yaml")
|
|
8518
8642
|
body_path = d / "initial-request.md"
|
|
8519
8643
|
had_request = self._present(body_path)
|
|
@@ -8791,7 +8915,7 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
8791
8915
|
validation = sc_info.get("validation")
|
|
8792
8916
|
if validation == "yaml_mapping":
|
|
8793
8917
|
try:
|
|
8794
|
-
parsed =
|
|
8918
|
+
parsed = _load_named_yaml(content, self._shown_path(p))
|
|
8795
8919
|
if parsed is not None and not isinstance(parsed, dict):
|
|
8796
8920
|
raise ValueError(
|
|
8797
8921
|
f"sidecar '{name}' must be a YAML mapping, "
|
|
@@ -17,6 +17,7 @@ from tcw.store.base import (
|
|
|
17
17
|
WORK_STATUSES, parse_connected_entry,
|
|
18
18
|
)
|
|
19
19
|
from tcw.store.checkouts import normalized_url, provisioned_root
|
|
20
|
+
from tcw.store.yaml_source import load as _load_named_yaml
|
|
20
21
|
|
|
21
22
|
SENTINEL = "tcw-config.yaml"
|
|
22
23
|
PROJECT_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
@@ -568,7 +569,8 @@ class FsProjectRegistry(ProjectRegistry):
|
|
|
568
569
|
declaration)
|
|
569
570
|
return None
|
|
570
571
|
try:
|
|
571
|
-
raw =
|
|
572
|
+
raw = _load_named_yaml(path.read_text(encoding="utf-8"), path,
|
|
573
|
+
_UniqueKeyLoader) or {}
|
|
572
574
|
except (OSError, yaml.YAMLError) as error:
|
|
573
575
|
self._problem(path, f"invalid YAML: {error}")
|
|
574
576
|
return None
|