tcw-cli 2.6.5__tar.gz → 2.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/PKG-INFO +6 -3
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/README.md +5 -2
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/pyproject.toml +1 -1
- tcw_cli-2.7.0/tcw/__init__.py +1 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/cli.py +21 -11
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/__init__.py +33 -1
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/base.py +243 -30
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/fs.py +155 -23
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/project.py +186 -11
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/sync.py +41 -3
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/cli.py +111 -48
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/recursion.py +3 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/PKG-INFO +6 -3
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/SOURCES.txt +6 -0
- tcw_cli-2.7.0/tests/test_blocker_cycles_through_unreadable_items.py +194 -0
- tcw_cli-2.7.0/tests/test_cross_node_blocker_cycles.py +239 -0
- tcw_cli-2.7.0/tests/test_override_letter_case.py +125 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_stage_verb.py +102 -22
- tcw_cli-2.7.0/tests/test_tracker_catch_up_moves.py +183 -0
- tcw_cli-2.7.0/tests/test_transition_hints.py +210 -0
- tcw_cli-2.7.0/tests/test_upstream_projects.py +739 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work.py +9 -6
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work_review.py +2 -2
- tcw_cli-2.6.5/tcw/__init__.py +0 -1
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/LICENSE +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/setup.cfg +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/capabilities/cli.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/harness.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/refs.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/stdin.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/checkouts.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/config_edit.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/claim.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/create.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/intake.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/jira.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/ownership.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/progress.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/generate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/audit-backlog.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/consolidate-plans.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/create-work.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/decompose.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/delegation.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/documentation-sync.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/post-mortem.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/search.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/triage-issues.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/unattended-work.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/projection.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/implement.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/inbox.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/request.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/verify.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/templates.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_boardless_delegate_and_slices.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities_rm.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capability_gate_children.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_check_versions.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_child_status.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_cli_help_coverage.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_committed_paths.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_config_edit.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_config_edit_writers.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_configuration_text_home.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_cross_node_blockers.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_dynamic_skill_marker.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_edit_refusal.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_edit_type.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_epic_completability_across_checkouts.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_coverage.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_files_changed.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_fixture.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_grading.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_runner.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_external_work_store.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_harness.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_init_configured_tree_path.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_initiative_node_qualified.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_interrupted_claim.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_item_tags_read.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_legacy_store_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_config_tags.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_literal_store_names.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_local_qualified_blockers.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_non_git_writes.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_override_in_linked_worktree.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_override_references.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_procedure_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_procedure_verb.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_project_graph_paths.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_project_registry.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_projection.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_prompt_fallback.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_recursion.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_refs.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_resolve.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_resolve_procedure.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_retention.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_routing_nodes.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_shipped_procedures.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_show_json.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_sidecar_paths_in_validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_skill_path_pointers.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_smoke.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_stage_validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_stdin.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_provisioning.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_taxonomy_rm_gaps.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_absent.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_binding.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_claim.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_claimability.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_cli.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_client.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_comment.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_create.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_gate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_hardening.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_help.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_hold.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_import.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_import_returns.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_inheritance.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_link.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_message_tidy.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_ownership.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_pre_backlog.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_replay.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_strict.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_strict_gate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_surface.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_sync.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_sync_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unattended_work_skill.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unplanned_start.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unreadable_capabilities_sidecar.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unreadable_item_files.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unreadable_state_gates.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_untag_invalid_tags.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_validate_moved_stores.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work_autocommit.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work_start.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work_tags.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_worktree_completion.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.7.0}/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.
|
|
3
|
+
Version: 2.7.0
|
|
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
|
|
@@ -476,7 +476,10 @@ agent through. See [Skills and Agents](#skills-and-agents).
|
|
|
476
476
|
|
|
477
477
|
**Many repositories, one model.** Projects are identified by ID, never by path.
|
|
478
478
|
A project can connect to others, inherit their taxonomy and capabilities, and
|
|
479
|
-
address their work items as `<project-id>/<slug>`. A
|
|
479
|
+
address their work items as `<project-id>/<slug>`. A connection is normally
|
|
480
|
+
declared by both projects; an `upstream` one is declared only by the reader and
|
|
481
|
+
is read-only from its side, so a private project can read a public one that never
|
|
482
|
+
names it. A checkout holding only some of
|
|
480
483
|
the connected repositories still works: the absent ones drop out rather than
|
|
481
484
|
breaking your commands. See [Working across repositories](docs/guide/multi-repo.md).
|
|
482
485
|
|
|
@@ -907,7 +910,7 @@ driving it to completion, verifying it, processing the inbox) described in
|
|
|
907
910
|
| Command | What it does |
|
|
908
911
|
| -------------------- | ----------------------------------------------------------------- |
|
|
909
912
|
| `tcw work inbox` | `inbox list`, `inbox show` and `inbox accept` raw requests, and Jira tickets awaiting triage |
|
|
910
|
-
| `tcw work nodes` | lists this project's parent and
|
|
913
|
+
| `tcw work nodes` | lists this project's parent, child and upstream projects |
|
|
911
914
|
| `tcw work delegate` | writes a request into the nearest child project with a board |
|
|
912
915
|
| `tcw work escalate` | writes a request into the parent project's inbox |
|
|
913
916
|
| `tcw work reconcile` | reads every project below with a board; writes the epic's roll-up |
|
|
@@ -260,7 +260,10 @@ agent through. See [Skills and Agents](#skills-and-agents).
|
|
|
260
260
|
|
|
261
261
|
**Many repositories, one model.** Projects are identified by ID, never by path.
|
|
262
262
|
A project can connect to others, inherit their taxonomy and capabilities, and
|
|
263
|
-
address their work items as `<project-id>/<slug>`. A
|
|
263
|
+
address their work items as `<project-id>/<slug>`. A connection is normally
|
|
264
|
+
declared by both projects; an `upstream` one is declared only by the reader and
|
|
265
|
+
is read-only from its side, so a private project can read a public one that never
|
|
266
|
+
names it. A checkout holding only some of
|
|
264
267
|
the connected repositories still works: the absent ones drop out rather than
|
|
265
268
|
breaking your commands. See [Working across repositories](docs/guide/multi-repo.md).
|
|
266
269
|
|
|
@@ -691,7 +694,7 @@ driving it to completion, verifying it, processing the inbox) described in
|
|
|
691
694
|
| Command | What it does |
|
|
692
695
|
| -------------------- | ----------------------------------------------------------------- |
|
|
693
696
|
| `tcw work inbox` | `inbox list`, `inbox show` and `inbox accept` raw requests, and Jira tickets awaiting triage |
|
|
694
|
-
| `tcw work nodes` | lists this project's parent and
|
|
697
|
+
| `tcw work nodes` | lists this project's parent, child and upstream projects |
|
|
695
698
|
| `tcw work delegate` | writes a request into the nearest child project with a board |
|
|
696
699
|
| `tcw work escalate` | writes a request into the parent project's inbox |
|
|
697
700
|
| `tcw work reconcile` | reads every project below with a board; writes the epic's roll-up |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tcw-cli"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.7.0"
|
|
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.7.0"
|
|
@@ -210,12 +210,14 @@ def _declared_nodes_in_graph(
|
|
|
210
210
|
# Every project the registry holds, not `ancestors + descendants`. That
|
|
211
211
|
# triple omits a sibling and a sibling of an ancestor — the shapes a
|
|
212
212
|
# workspace is actually made of — so a declaration living on one was
|
|
213
|
-
# never read, and the repository it named never obtained.
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
# never read, and the repository it named never obtained. Except an
|
|
214
|
+
# upstream: what it connects to is its own to obtain, not its readers'.
|
|
215
|
+
registry = FsProjectRegistry.open(root)
|
|
216
|
+
roots += [Path(project.locator) for project in registry.projects()
|
|
217
|
+
if registry.read_only_reason(project.id) is None]
|
|
216
218
|
except Exception:
|
|
217
219
|
pass
|
|
218
|
-
found: list[tuple[Path, str, object]] = []
|
|
220
|
+
found: list[tuple[Path, str, object, str]] = []
|
|
219
221
|
problems: list[str] = []
|
|
220
222
|
for candidate in roots:
|
|
221
223
|
candidate = candidate.resolve()
|
|
@@ -224,7 +226,7 @@ def _declared_nodes_in_graph(
|
|
|
224
226
|
seen.add(candidate)
|
|
225
227
|
declared, candidate_problems = declared_connected_projects(candidate)
|
|
226
228
|
problems += [f"{candidate / SENTINEL}: {p}" for p in candidate_problems]
|
|
227
|
-
found += [(candidate, pid, d) for pid, d in declared]
|
|
229
|
+
found += [(candidate, pid, d, relation) for pid, d, relation in declared]
|
|
228
230
|
return found, problems
|
|
229
231
|
|
|
230
232
|
|
|
@@ -250,7 +252,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
250
252
|
|
|
251
253
|
failed = False
|
|
252
254
|
seen: set[Path] = set()
|
|
253
|
-
queue: list[tuple[Path, str, object]] = []
|
|
255
|
+
queue: list[tuple[Path, str, object, str]] = []
|
|
254
256
|
# "A project that is already here always wins" is the rule the whole feature
|
|
255
257
|
# rests on, and the walk is the one place it was not applied: a declaration
|
|
256
258
|
# is written by whichever node knows about that edge, so an ancestor
|
|
@@ -298,8 +300,14 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
298
300
|
return None
|
|
299
301
|
return located if located != target.resolve() else None
|
|
300
302
|
|
|
301
|
-
def enqueue(root: Path) -> None:
|
|
303
|
+
def enqueue(root: Path, relation: str = "children") -> None:
|
|
302
304
|
nonlocal failed
|
|
305
|
+
if relation == "upstream":
|
|
306
|
+
# Obtained so it can be read; its own connections are not followed,
|
|
307
|
+
# as the registry does not follow them. A reader must never fetch a
|
|
308
|
+
# project named only by something it reads — a public upstream can
|
|
309
|
+
# still name the private repository it is migrating away from.
|
|
310
|
+
return
|
|
303
311
|
found, problems = _declared_nodes_in_graph(root, enqueued)
|
|
304
312
|
for problem in problems:
|
|
305
313
|
print(f"tcw provision: {problem}", file=sys.stderr)
|
|
@@ -314,7 +322,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
314
322
|
enqueued: set[Path] = set()
|
|
315
323
|
enqueue(node_root)
|
|
316
324
|
while queue:
|
|
317
|
-
source, project_id, declaration = queue.pop(0)
|
|
325
|
+
source, project_id, declaration, relation = queue.pop(0)
|
|
318
326
|
target = provisioned_store_root(source, declaration)
|
|
319
327
|
if target in seen:
|
|
320
328
|
continue
|
|
@@ -335,13 +343,13 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
335
343
|
# provisioning precisely when an earlier one was already in place.
|
|
336
344
|
print(f" {project_id}: already available")
|
|
337
345
|
have.add(project_id)
|
|
338
|
-
enqueue(present)
|
|
346
|
+
enqueue(present, relation)
|
|
339
347
|
continue
|
|
340
348
|
provisioner = FsStoreProvisioner(source, NODE_TARGET, declaration)
|
|
341
349
|
if provisioner.is_available() and not refresh:
|
|
342
350
|
print(f" {project_id}: already available at {target}")
|
|
343
351
|
have.add(project_id)
|
|
344
|
-
enqueue(target)
|
|
352
|
+
enqueue(target, relation)
|
|
345
353
|
continue
|
|
346
354
|
print(f"→ {project_id}: {provisioner.describe().split(': ', 1)[1]}")
|
|
347
355
|
try:
|
|
@@ -363,7 +371,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
363
371
|
# routine when a parent and a grandparent both name it — report
|
|
364
372
|
# "already available" in a run that fetched nothing.
|
|
365
373
|
have.add(project_id)
|
|
366
|
-
enqueue(target)
|
|
374
|
+
enqueue(target, relation)
|
|
367
375
|
return failed
|
|
368
376
|
|
|
369
377
|
|
|
@@ -437,6 +445,8 @@ def _cmd_validate(args: argparse.Namespace) -> int:
|
|
|
437
445
|
print(f"{entry.declared_in}: connected project '{entry.id}' is declared "
|
|
438
446
|
f"at {entry.locator}, which is not in this checkout; it was found "
|
|
439
447
|
f"at {registry.get(entry.id).locator}", file=sys.stderr)
|
|
448
|
+
for warning in registry.warnings():
|
|
449
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
440
450
|
from tcw.validate import validate
|
|
441
451
|
recurse = args.path is None and not args.no_recurse
|
|
442
452
|
projects = [registry.current, *registry.descendants()] if recurse else [registry.current]
|
|
@@ -24,7 +24,8 @@ from tcw.store.base import (
|
|
|
24
24
|
)
|
|
25
25
|
from tcw.store.fs import (
|
|
26
26
|
FsCapabilitiesStore, FsTaxonomyStore, FsWorkStore, descendant_nodes,
|
|
27
|
-
find_node_root, heading_slug, registered_project_id,
|
|
27
|
+
find_node_root, heading_slug, registered_project_id, qualified_work_ref_read_only,
|
|
28
|
+
resolve_qualified_work_ref,
|
|
28
29
|
)
|
|
29
30
|
from tcw.refs import resolve_tcw_ref
|
|
30
31
|
from tcw.validate import ValidationTarget, validate
|
|
@@ -507,6 +508,21 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
507
508
|
return resolve_qualified_work_ref(self.server.node_root, slug)
|
|
508
509
|
return FsWorkStore.open(self.server.node_root), slug
|
|
509
510
|
|
|
511
|
+
def _refused_read_only(self, slug: str) -> bool:
|
|
512
|
+
"""Answer 403 and return True when `slug` names an item in a project this
|
|
513
|
+
node reads but may not write — an upstream project, or one reached only
|
|
514
|
+
through one. Every route that changes an item or runs its project's
|
|
515
|
+
scripts asks this first; reads do not, so the board can still show it."""
|
|
516
|
+
if not self.server.include_descendants:
|
|
517
|
+
return False
|
|
518
|
+
reason = qualified_work_ref_read_only(self.server.node_root, slug)
|
|
519
|
+
if reason is None:
|
|
520
|
+
return False
|
|
521
|
+
self._send_err(HTTPStatus.FORBIDDEN,
|
|
522
|
+
f"{reason}; change it from that project itself",
|
|
523
|
+
code="read-only-project")
|
|
524
|
+
return True
|
|
525
|
+
|
|
510
526
|
def _hosted_projects(self) -> set[str]:
|
|
511
527
|
"""Project IDs whose items this server actually serves — the descendants it
|
|
512
528
|
aggregates, or nothing when not aggregating.
|
|
@@ -993,6 +1009,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
993
1009
|
slug = _decode_path_param(m.group(1))
|
|
994
1010
|
action = _decode_path_param(m.group(2))
|
|
995
1011
|
qslug = slug # preserve the addressed (qualified) slug
|
|
1012
|
+
if self._refused_read_only(slug):
|
|
1013
|
+
return
|
|
996
1014
|
resolved = self._resolve_work(slug)
|
|
997
1015
|
if resolved is None:
|
|
998
1016
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1216,6 +1234,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1216
1234
|
if m:
|
|
1217
1235
|
slug = _decode_path_param(m.group(1))
|
|
1218
1236
|
qslug = slug # preserve the addressed (qualified) slug
|
|
1237
|
+
if self._refused_read_only(slug):
|
|
1238
|
+
return
|
|
1219
1239
|
resolved = self._resolve_work(slug)
|
|
1220
1240
|
if resolved is None:
|
|
1221
1241
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1370,6 +1390,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1370
1390
|
if name not in WORK_ARTIFACTS:
|
|
1371
1391
|
self._send(HTTPStatus.BAD_REQUEST, b"unknown artifact")
|
|
1372
1392
|
return
|
|
1393
|
+
if self._refused_read_only(slug):
|
|
1394
|
+
return
|
|
1373
1395
|
resolved = self._resolve_work(slug)
|
|
1374
1396
|
if resolved is None:
|
|
1375
1397
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1408,6 +1430,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1408
1430
|
if m:
|
|
1409
1431
|
slug = _decode_path_param(m.group(1))
|
|
1410
1432
|
stage_id = _decode_path_param(m.group(2))
|
|
1433
|
+
if self._refused_read_only(slug):
|
|
1434
|
+
return
|
|
1411
1435
|
resolved = self._resolve_work(slug)
|
|
1412
1436
|
if resolved is None:
|
|
1413
1437
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1443,6 +1467,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1443
1467
|
f"{name} is written by `{owner}`, not edited; "
|
|
1444
1468
|
f"run that command instead.")
|
|
1445
1469
|
return
|
|
1470
|
+
if self._refused_read_only(slug):
|
|
1471
|
+
return
|
|
1446
1472
|
resolved = self._resolve_work(slug)
|
|
1447
1473
|
if resolved is None:
|
|
1448
1474
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1498,6 +1524,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1498
1524
|
if m:
|
|
1499
1525
|
slug = _decode_path_param(m.group(1))
|
|
1500
1526
|
stage_id = _decode_path_param(m.group(2))
|
|
1527
|
+
if self._refused_read_only(slug):
|
|
1528
|
+
return
|
|
1501
1529
|
resolved = self._resolve_work(slug)
|
|
1502
1530
|
if resolved is None:
|
|
1503
1531
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1515,6 +1543,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1515
1543
|
m = re.match(r"^/api/work/([^/]+)$", path)
|
|
1516
1544
|
if m:
|
|
1517
1545
|
slug = _decode_path_param(m.group(1))
|
|
1546
|
+
if self._refused_read_only(slug):
|
|
1547
|
+
return
|
|
1518
1548
|
resolved = self._resolve_work(slug)
|
|
1519
1549
|
if resolved is None:
|
|
1520
1550
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1584,6 +1614,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1584
1614
|
slug_q, _, stage_q = middle.partition("/plan-stages/")
|
|
1585
1615
|
slug = _decode_path_param(slug_q)
|
|
1586
1616
|
stage_id = _decode_path_param(stage_q)
|
|
1617
|
+
if self._refused_read_only(slug):
|
|
1618
|
+
return
|
|
1587
1619
|
resolved = self._resolve_work(slug)
|
|
1588
1620
|
if resolved is None:
|
|
1589
1621
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -21,7 +21,7 @@ from abc import ABC, abstractmethod
|
|
|
21
21
|
from dataclasses import dataclass, field
|
|
22
22
|
from datetime import date, datetime, timezone
|
|
23
23
|
from pathlib import PurePosixPath
|
|
24
|
-
from typing import Any
|
|
24
|
+
from typing import Any, Collection
|
|
25
25
|
from urllib.parse import quote
|
|
26
26
|
|
|
27
27
|
|
|
@@ -216,6 +216,35 @@ class ProjectRegistry(ABC):
|
|
|
216
216
|
`declared_parent_id`."""
|
|
217
217
|
return [child.id for child in self.children(project_id)]
|
|
218
218
|
|
|
219
|
+
def declared_upstream_ids(self, project_id: str | None = None) -> list[str]:
|
|
220
|
+
"""The ids of the declared upstream projects, reachable or not.
|
|
221
|
+
|
|
222
|
+
An upstream is a project this one reads from — its taxonomy, its
|
|
223
|
+
capabilities, its work items by reference — and never writes to, and
|
|
224
|
+
which does not name this project back. It is neither parent nor child,
|
|
225
|
+
so no walk over those relations reaches it. A registry that keeps no
|
|
226
|
+
such relation has none."""
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
def read_only_reason(self, project_id: str,
|
|
230
|
+
from_id: str | None = None) -> str | None:
|
|
231
|
+
"""Why `from_id` (default: the current project) may not write to
|
|
232
|
+
`project_id`, or None when it may.
|
|
233
|
+
|
|
234
|
+
**The one place the write rule lives.** A project is writable from a
|
|
235
|
+
node only when the node reaches it by `parent` and `children`
|
|
236
|
+
connections alone, each followed as declared by the project it starts
|
|
237
|
+
from. Anything else the graph holds was reached across an upstream
|
|
238
|
+
connection and is read-only there — however many nodes declare it, and
|
|
239
|
+
from whichever side of the family it is named. A registry with no
|
|
240
|
+
upstream relation has nothing read-only."""
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def warnings(self) -> list[str]:
|
|
244
|
+
"""Graph facts worth saying that are neither defects nor unreachable
|
|
245
|
+
projects — printed by `tcw validate`, never fatal."""
|
|
246
|
+
return []
|
|
247
|
+
|
|
219
248
|
def overrides(self) -> list["ProjectOverride"]:
|
|
220
249
|
"""The locators the caller supplied for this graph, in place of declared ones.
|
|
221
250
|
|
|
@@ -2314,6 +2343,67 @@ STAGE_NEXT_STEPS: dict[str, str] = {
|
|
|
2314
2343
|
}
|
|
2315
2344
|
|
|
2316
2345
|
|
|
2346
|
+
# What a reader does after a transition, keyed by the command that printed it.
|
|
2347
|
+
# The transition-side sibling of `STAGE_NEXT_STEPS`, and navigation text for the
|
|
2348
|
+
# same reason: it names a stage, never a status edge, so no store answers for it.
|
|
2349
|
+
#
|
|
2350
|
+
# These drifted while they were four literals in the CLI with nothing reading
|
|
2351
|
+
# them: `start` sent readers to `tcw work complete`, skipping implement and
|
|
2352
|
+
# verify, and `submit` told them to delete a `refined-outcome.md` that verify had
|
|
2353
|
+
# not written yet.
|
|
2354
|
+
#
|
|
2355
|
+
# `start` has one entry per stage it can choose (`start_next_stage`), rather than
|
|
2356
|
+
# one template, so every entry is a complete sentence the tests can check. No
|
|
2357
|
+
# entry names `spec.md` or `plan.md`: `start`'s warning names those files when
|
|
2358
|
+
# they are missing, and its tests assert they are absent when they are not.
|
|
2359
|
+
TRANSITION_NEXT_STEPS: dict[str, str] = {
|
|
2360
|
+
# `inbox accept` of a raw entry prints this too: both leave a backlog item
|
|
2361
|
+
# holding at most its intake.
|
|
2362
|
+
"new": "run `tcw work stage gate request <slug>`",
|
|
2363
|
+
"start:spec": "run `tcw work stage gate spec <slug>`",
|
|
2364
|
+
"start:plan": "run `tcw work stage gate plan <slug>`",
|
|
2365
|
+
"start:implement": "run `tcw work stage gate implement <slug>`",
|
|
2366
|
+
"start:verify": "run `tcw work stage gate verify <slug>`",
|
|
2367
|
+
# Both endings, and no command for either: the verify stage's own footer
|
|
2368
|
+
# names `complete` and `rework`, and repeating them here is how this line
|
|
2369
|
+
# went stale before.
|
|
2370
|
+
"submit": ("run `tcw work stage gate verify <slug>`; it ends in "
|
|
2371
|
+
"refined-outcome.md to accept the work or rework.md to send it back"),
|
|
2372
|
+
"rework": "address rework.md: run `tcw work stage gate implement <slug>`",
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
# The status an item is in when each hint prints — what the tests check a named
|
|
2376
|
+
# stage against. Nothing else reads it.
|
|
2377
|
+
TRANSITION_LANDS_IN: dict[str, str] = {
|
|
2378
|
+
"new": "backlog",
|
|
2379
|
+
"start:spec": "active",
|
|
2380
|
+
"start:plan": "active",
|
|
2381
|
+
"start:implement": "active",
|
|
2382
|
+
"start:verify": "active",
|
|
2383
|
+
"submit": "review",
|
|
2384
|
+
"rework": "active",
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
|
|
2388
|
+
def start_next_stage(present: Collection[str]) -> str:
|
|
2389
|
+
"""The stage to run after `start`, from the artifacts the item holds.
|
|
2390
|
+
|
|
2391
|
+
Not always `implement`. An item can be started before it is specified or
|
|
2392
|
+
planned, and `start` also takes an item already `active` — with
|
|
2393
|
+
`--take-over`, or when nobody holds it — which may already have an outcome.
|
|
2394
|
+
The order is the `work` skill's "Finding your place", restricted to stages
|
|
2395
|
+
legal in `active` (so never `request`), with a reworked item sent back to
|
|
2396
|
+
`implement` as `rework`'s own hint does.
|
|
2397
|
+
"""
|
|
2398
|
+
if "spec" not in present:
|
|
2399
|
+
return "spec"
|
|
2400
|
+
if "plan" not in present:
|
|
2401
|
+
return "plan"
|
|
2402
|
+
if "outcome" not in present or "rework" in present:
|
|
2403
|
+
return "implement"
|
|
2404
|
+
return "verify"
|
|
2405
|
+
|
|
2406
|
+
|
|
2317
2407
|
def _parse_condition(raw: Any, where: str, problems: list[str]) -> "Condition | None":
|
|
2318
2408
|
"""Parse a `when:` mapping. Every shape is validated, not just `type`'s value.
|
|
2319
2409
|
|
|
@@ -3058,6 +3148,9 @@ class PublicationError(TransitionCommitError):
|
|
|
3058
3148
|
|
|
3059
3149
|
|
|
3060
3150
|
|
|
3151
|
+
_HELD_TWICE = "more than one folder holds this slug"
|
|
3152
|
+
|
|
3153
|
+
|
|
3061
3154
|
class MultipleMatch(Exception):
|
|
3062
3155
|
"""A slug resolves to more than one item folder (slug integrity broken)."""
|
|
3063
3156
|
|
|
@@ -3900,12 +3993,14 @@ class WorkStore(ABC):
|
|
|
3900
3993
|
|
|
3901
3994
|
@staticmethod
|
|
3902
3995
|
def _unreadable_refusal(verb: str, slug: str, what: str,
|
|
3903
|
-
found: list[tuple[str, str]]
|
|
3996
|
+
found: list[tuple[str, str]], *,
|
|
3997
|
+
noun: str = "open items",
|
|
3998
|
+
remedy: str = "Fix or replace each state.yaml "
|
|
3999
|
+
"(`tcw validate` lists them)") -> ValueError:
|
|
3904
4000
|
named = ", ".join(f"{label} ({reason})" for label, reason in found)
|
|
3905
|
-
return ValueError(f"Cannot {verb} {slug}: the state of these
|
|
4001
|
+
return ValueError(f"Cannot {verb} {slug}: the state of these {noun} "
|
|
3906
4002
|
f"cannot be read, so whether they are {what} is "
|
|
3907
|
-
f"unknown: {named}.
|
|
3908
|
-
f"(`tcw validate` lists them) and retry.")
|
|
4003
|
+
f"unknown: {named}. {remedy} and retry.")
|
|
3909
4004
|
|
|
3910
4005
|
def independent_descendants(self, slug: str) -> list[WorkItem]:
|
|
3911
4006
|
"""Every item beneath `slug` — the whole subtree, not only direct
|
|
@@ -3998,36 +4093,153 @@ class WorkStore(ABC):
|
|
|
3998
4093
|
return self._same_entry(entry, e) or (
|
|
3999
4094
|
"slug" in entry and "external" in e and self._entry_for(e["external"]) == entry)
|
|
4000
4095
|
|
|
4001
|
-
def
|
|
4002
|
-
|
|
4003
|
-
|
|
4096
|
+
def unreadable_reason(self, slug: str) -> str | None:
|
|
4097
|
+
"""Why this store holds item `slug` but cannot read its recorded fields,
|
|
4098
|
+
or `None` — when they can be read, or there is no such item. A store that
|
|
4099
|
+
never holds a record it cannot read has none."""
|
|
4100
|
+
return None
|
|
4004
4101
|
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4102
|
+
def _item_label(self, store: "WorkStore", slug: str) -> str:
|
|
4103
|
+
"""How a message from this store names `store`'s item `slug`. The slug
|
|
4104
|
+
here; a store that can address other projects qualifies theirs."""
|
|
4105
|
+
return slug
|
|
4106
|
+
|
|
4107
|
+
def _store_key(self) -> object:
|
|
4108
|
+
"""What makes two opens of this store one store, for walks that cross
|
|
4109
|
+
stores. The object itself here; an adapter that can open the same store
|
|
4110
|
+
twice says what the two opens share."""
|
|
4111
|
+
return id(self)
|
|
4112
|
+
|
|
4113
|
+
def _blocker_target(self, entry: dict) -> "tuple[WorkStore, str] | None":
|
|
4114
|
+
"""Where a stored blocker entry points, if at a work item — the same
|
|
4115
|
+
entries `external_blocker_state` settles against one, and no others.
|
|
4116
|
+
|
|
4117
|
+
Here: a slug, and an `external` text shaped like a slug, which settles
|
|
4118
|
+
against this store's item of that name — including one not created yet,
|
|
4119
|
+
since a slug is predictable and creating it would close any cycle the
|
|
4120
|
+
entry is part of. A missing item is a dead end for the walk, not an
|
|
4121
|
+
error. A store that can address other projects extends this for
|
|
4122
|
+
`<project-id>/<slug>`."""
|
|
4123
|
+
if not isinstance(entry, dict): # hand-edited data: not followed
|
|
4124
|
+
return None
|
|
4125
|
+
if "slug" in entry:
|
|
4126
|
+
return self, entry["slug"]
|
|
4127
|
+
text = str(entry.get("external") or "").strip()
|
|
4128
|
+
if re.fullmatch(r"[a-z0-9][a-z0-9-]*", text):
|
|
4129
|
+
return self, text
|
|
4130
|
+
return None
|
|
4131
|
+
|
|
4132
|
+
def _reaches(self, start: "tuple[WorkStore, str]", target: str, *,
|
|
4133
|
+
settled: frozenset[str] = frozenset(),
|
|
4134
|
+
unreadable: "list[tuple[str, str]] | None" = None) -> bool:
|
|
4135
|
+
"""True if the item at `start` — a (store, slug) pair — depends,
|
|
4136
|
+
transitively through its blockers, on this store's item `target`.
|
|
4137
|
+
|
|
4138
|
+
The walk follows every blocker `_blocker_target` resolves, into other
|
|
4139
|
+
stores too, each item through its own store: a qualified blocker means
|
|
4140
|
+
what it meant to the node that stored it. An item that cannot be read —
|
|
4141
|
+
an interrupted claim, an ambiguous slug, in this store or another — is
|
|
4142
|
+
not followed: the state of an item the edit does not name never fails
|
|
4143
|
+
the edit by raising. Those whose blockers are unknown — a slug more than
|
|
4144
|
+
one folder holds, or recorded fields the store cannot read — are
|
|
4145
|
+
appended to `unreadable` as `(label, reason)`, for the caller to decide
|
|
4146
|
+
on; an interrupted claim is not one of them.
|
|
4147
|
+
|
|
4148
|
+
`settled` items (slugs of this store) are not expanded: a caller that has
|
|
4149
|
+
already started the walk from an item's *proposed* blockers passes it,
|
|
4150
|
+
so its stored ones — which the edit is replacing — cannot be followed
|
|
4151
|
+
back in."""
|
|
4152
|
+
stores: dict[object, WorkStore] = {self._store_key(): self}
|
|
4153
|
+
goal = (self._store_key(), target)
|
|
4154
|
+
seen: set[tuple[object, str]] = {(self._store_key(), s) for s in settled}
|
|
4009
4155
|
stack = [start]
|
|
4010
4156
|
while stack:
|
|
4011
|
-
|
|
4012
|
-
|
|
4157
|
+
store, slug = stack.pop()
|
|
4158
|
+
key = store._store_key()
|
|
4159
|
+
store = stores.setdefault(key, store) # first open of a store wins
|
|
4160
|
+
node = (key, slug)
|
|
4161
|
+
if node == goal:
|
|
4013
4162
|
return True
|
|
4014
|
-
if
|
|
4163
|
+
if node in seen:
|
|
4164
|
+
continue
|
|
4165
|
+
seen.add(node)
|
|
4166
|
+
try:
|
|
4167
|
+
item = store.get(slug)
|
|
4168
|
+
except MultipleMatch:
|
|
4169
|
+
if unreadable is not None:
|
|
4170
|
+
unreadable.append((self._item_label(store, slug), _HELD_TWICE))
|
|
4171
|
+
continue
|
|
4172
|
+
except Exception:
|
|
4015
4173
|
continue
|
|
4016
|
-
seen.add(cur)
|
|
4017
|
-
item = self.get(cur)
|
|
4018
4174
|
if item is None:
|
|
4019
4175
|
continue
|
|
4020
|
-
|
|
4176
|
+
if not item.blocked_by and unreadable is not None:
|
|
4177
|
+
# Damage always reads as no blockers, so only these need asking.
|
|
4178
|
+
try:
|
|
4179
|
+
reason = store.unreadable_reason(slug)
|
|
4180
|
+
except Exception:
|
|
4181
|
+
reason = None
|
|
4182
|
+
if reason:
|
|
4183
|
+
unreadable.append((self._item_label(store, slug), reason))
|
|
4184
|
+
for entry in item.blocked_by:
|
|
4185
|
+
try:
|
|
4186
|
+
found = store._blocker_target(entry)
|
|
4187
|
+
except Exception:
|
|
4188
|
+
found = None
|
|
4189
|
+
if found is not None:
|
|
4190
|
+
stack.append(found)
|
|
4021
4191
|
return False
|
|
4022
4192
|
|
|
4193
|
+
def _refuse_blocker_walks(self, walks: "list[tuple[tuple[WorkStore, str], str]]",
|
|
4194
|
+
target: str, *, settled: frozenset[str] = frozenset(),
|
|
4195
|
+
blockers: list[str]) -> None:
|
|
4196
|
+
"""Walk from each `(start, cycle message)` of `walks` towards this
|
|
4197
|
+
store's `target`, then decide once: a cycle in any walk refuses with its
|
|
4198
|
+
message; otherwise an item on any path whose blockers could not be read
|
|
4199
|
+
refuses, naming each, because whether the edit closes a cycle is
|
|
4200
|
+
unknown; otherwise accept. Deciding after every walk is what stops a
|
|
4201
|
+
damaged path hiding a cycle through another."""
|
|
4202
|
+
unreadable: list[tuple[str, str]] = []
|
|
4203
|
+
for start, cycle in walks:
|
|
4204
|
+
if self._reaches(start, target, settled=settled, unreadable=unreadable):
|
|
4205
|
+
raise ValueError(cycle)
|
|
4206
|
+
if not unreadable:
|
|
4207
|
+
return
|
|
4208
|
+
found = list(dict.fromkeys(unreadable))
|
|
4209
|
+
remedies = []
|
|
4210
|
+
if any(reason != _HELD_TWICE for _, reason in found):
|
|
4211
|
+
remedies.append("Fix or replace each damaged state.yaml "
|
|
4212
|
+
"(`tcw validate` lists them)")
|
|
4213
|
+
if any(reason == _HELD_TWICE for _, reason in found):
|
|
4214
|
+
remedies.append("remove the extra folder of each slug held twice")
|
|
4215
|
+
remedy = "; ".join(remedies)
|
|
4216
|
+
many = len(blockers) > 1
|
|
4217
|
+
raise self._unreadable_refusal(
|
|
4218
|
+
f"add {', '.join(blockers)} as {'blockers' if many else 'a blocker'} of",
|
|
4219
|
+
target, "part of a blocking cycle with this edit", found,
|
|
4220
|
+
noun="items", remedy=remedy[0].upper() + remedy[1:])
|
|
4221
|
+
|
|
4023
4222
|
def _check_new_blocker(self, slug: str, entry: dict, ref: str) -> None:
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4223
|
+
self._check_new_blockers(slug, [(ref, entry)])
|
|
4224
|
+
|
|
4225
|
+
def _check_new_blockers(self, slug: str, new: "list[tuple[str, dict]]") -> None:
|
|
4226
|
+
"""Refuse `(ref, entry)` pairs as new blockers of `slug`, together: a
|
|
4227
|
+
self-block, or a cycle through the blockers as stored — across stores
|
|
4228
|
+
when an entry names an item in another one — or walks that cannot see
|
|
4229
|
+
through an item on the way. The one rule every blocker write uses;
|
|
4230
|
+
decided once for all of them, so one blocker's damaged path cannot
|
|
4231
|
+
hide another's cycle."""
|
|
4232
|
+
walks, refs = [], []
|
|
4233
|
+
for ref, entry in new:
|
|
4234
|
+
found = self._blocker_target(entry)
|
|
4235
|
+
if found is None:
|
|
4236
|
+
continue
|
|
4237
|
+
store, target = found
|
|
4238
|
+
if store._store_key() == self._store_key() and target == slug:
|
|
4028
4239
|
raise ValueError("an item cannot block itself")
|
|
4029
|
-
|
|
4030
|
-
|
|
4240
|
+
walks.append((found, f"{ref} → {slug} would create a blocking cycle"))
|
|
4241
|
+
refs.append(ref)
|
|
4242
|
+
self._refuse_blocker_walks(walks, slug, blockers=refs)
|
|
4031
4243
|
|
|
4032
4244
|
def add_blocker(self, slug: str, ref: str) -> None:
|
|
4033
4245
|
item = self._require(slug)
|
|
@@ -4054,18 +4266,19 @@ class WorkStore(ABC):
|
|
|
4054
4266
|
proposed = list(item.blocked_by)
|
|
4055
4267
|
for ref in remove:
|
|
4056
4268
|
proposed = self._without(proposed, slug, ref)
|
|
4057
|
-
for ref in add
|
|
4058
|
-
|
|
4059
|
-
|
|
4269
|
+
adding = [(ref, self._entry_for(ref)) for ref in add]
|
|
4270
|
+
self._check_new_blockers(slug, adding)
|
|
4271
|
+
for ref, entry in adding:
|
|
4060
4272
|
if not any(self._same_entry(entry, e) for e in proposed):
|
|
4061
4273
|
proposed = [e for e in proposed if not self._same_item(entry, e)] + [entry]
|
|
4062
4274
|
for ref in blocks:
|
|
4063
4275
|
target = self._require(ref).slug
|
|
4064
4276
|
if target == slug:
|
|
4065
4277
|
raise ValueError("an item cannot block itself")
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4278
|
+
cycle = f"{slug} → {ref} would create a blocking cycle"
|
|
4279
|
+
self._refuse_blocker_walks(
|
|
4280
|
+
[(f, cycle) for f in map(self._blocker_target, proposed) if f is not None],
|
|
4281
|
+
target, settled=frozenset({slug}), blockers=[slug])
|
|
4069
4282
|
|
|
4070
4283
|
def remove_blocker(self, slug: str, ref: str) -> None:
|
|
4071
4284
|
"""Remove one blocker. Fails closed on a ref that matches nothing.
|