tcw-cli 2.6.5__tar.gz → 2.8.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.8.0}/PKG-INFO +10 -7
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/README.md +9 -6
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/pyproject.toml +1 -1
- tcw_cli-2.8.0/tcw/__init__.py +1 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/cli.py +29 -15
- tcw_cli-2.8.0/tcw/cli_suggest.py +199 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/refs.py +4 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/__init__.py +42 -1
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/base.py +400 -38
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/fs.py +1052 -162
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/project.py +186 -11
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/sync.py +41 -3
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/validate.py +5 -1
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/cli.py +392 -108
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/create-work.md +9 -4
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/implement.md +2 -1
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/inbox.md +2 -1
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/request.md +2 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/verify.md +3 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/recursion.py +14 -3
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/PKG-INFO +10 -7
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/SOURCES.txt +14 -0
- tcw_cli-2.8.0/tests/test_already_integrated_check.py +223 -0
- tcw_cli-2.8.0/tests/test_blocker_cycles_through_unreadable_items.py +194 -0
- tcw_cli-2.8.0/tests/test_command_suggestions.py +154 -0
- tcw_cli-2.8.0/tests/test_creation_commits.py +262 -0
- tcw_cli-2.8.0/tests/test_cross_node_blocker_cycles.py +239 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_external_work_store.py +7 -2
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_non_git_writes.py +24 -4
- tcw_cli-2.8.0/tests/test_override_letter_case.py +125 -0
- tcw_cli-2.8.0/tests/test_planning_gates_after_start.py +144 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_prompt_fallback.py +5 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_recursion.py +4 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_refs.py +8 -4
- tcw_cli-2.8.0/tests/test_rename_work_item.py +453 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_retention.py +1 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_stage_verb.py +104 -23
- tcw_cli-2.8.0/tests/test_stale_item_path.py +283 -0
- tcw_cli-2.8.0/tests/test_store_lock.py +287 -0
- tcw_cli-2.8.0/tests/test_tracker_catch_up_moves.py +183 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_comment.py +6 -2
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_strict.py +17 -2
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_sync.py +20 -1
- tcw_cli-2.8.0/tests/test_transition_hints.py +214 -0
- tcw_cli-2.8.0/tests/test_upstream_projects.py +741 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work.py +11 -6
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work_autocommit.py +19 -8
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work_review.py +6 -2
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_worktree_completion.py +12 -0
- tcw_cli-2.6.5/tcw/__init__.py +0 -1
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/LICENSE +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/setup.cfg +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/capabilities/cli.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/harness.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/stdin.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/checkouts.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/config_edit.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/claim.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/create.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/intake.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/jira.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/ownership.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/progress.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/generate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/audit-backlog.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/consolidate-plans.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/decompose.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/delegation.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/documentation-sync.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/post-mortem.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/search.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/triage-issues.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/unattended-work.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/projection.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/templates.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_boardless_delegate_and_slices.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities_rm.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capability_gate_children.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_check_versions.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_child_status.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_cli_help_coverage.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_committed_paths.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_config_edit.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_config_edit_writers.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_configuration_text_home.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_cross_node_blockers.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_dynamic_skill_marker.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_edit_refusal.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_edit_type.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_epic_completability_across_checkouts.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_coverage.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_files_changed.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_fixture.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_grading.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_runner.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_harness.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_init_configured_tree_path.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_initiative_node_qualified.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_interrupted_claim.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_item_tags_read.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_legacy_store_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_config_tags.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_literal_store_names.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_local_qualified_blockers.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_override_in_linked_worktree.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_override_references.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_procedure_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_procedure_verb.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_project_graph_paths.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_project_registry.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_projection.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_resolve.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_resolve_procedure.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_routing_nodes.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_shipped_procedures.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_show_json.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_sidecar_paths_in_validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_skill_path_pointers.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_smoke.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_stage_validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_stdin.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_provisioning.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_taxonomy_rm_gaps.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_absent.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_binding.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_claim.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_claimability.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_cli.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_client.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_create.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_gate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_hardening.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_help.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_hold.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_import.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_import_returns.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_inheritance.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_link.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_message_tidy.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_ownership.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_pre_backlog.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_replay.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_strict_gate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_surface.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_sync_config.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unattended_work_skill.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unplanned_start.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unreadable_capabilities_sidecar.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unreadable_item_files.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unreadable_state_gates.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_untag_invalid_tags.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_validate.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_validate_moved_stores.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work_start.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work_tags.py +0 -0
- {tcw_cli-2.6.5 → tcw_cli-2.8.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.8.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
|
|
|
@@ -685,9 +688,9 @@ The stages, and the document each one writes:
|
|
|
685
688
|
| Stage | Runs while the item is | Writes |
|
|
686
689
|
| ------------ | ---------------------- | ----------------------------------------------------------------- |
|
|
687
690
|
| `inbox` | not yet an item | nothing; it creates the item, keeping the raw text as `intake.md` |
|
|
688
|
-
| `request` | backlog
|
|
689
|
-
| `spec` | backlog
|
|
690
|
-
| `plan` | backlog
|
|
691
|
+
| `request` | backlog (or active) | `initial-request.md`: what is asked for, and why |
|
|
692
|
+
| `spec` | backlog (or active) | `spec.md`: what to build, with acceptance criteria |
|
|
693
|
+
| `plan` | backlog (or active) | `plan.md`: how to build it, as ordered tasks |
|
|
691
694
|
| `implement` | active | `outcome.md`: what was built, and what the plan got wrong |
|
|
692
695
|
| `verify` | review (or active) | `refined-outcome.md` if accepted, `rework.md` if not |
|
|
693
696
|
| `postmortem` | review or completed | `post-mortem.md`: which stage could first have caught a problem |
|
|
@@ -699,7 +702,7 @@ The transitions, and what each one checks before it moves an item:
|
|
|
699
702
|
| `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
|
|
700
703
|
| `submit` | `tcw work submit <slug>` | active → review | never |
|
|
701
704
|
| `rework` | `tcw work rework <slug>` | review → active | `refined-outcome.md` is present (it says the work was accepted) |
|
|
702
|
-
| `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; the item's folder in its worktree has uncommitted changes; no `--confirm` |
|
|
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` |
|
|
703
706
|
| discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
|
|
704
707
|
| `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
|
|
705
708
|
| 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 |
|
|
@@ -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
|
|
|
@@ -469,9 +472,9 @@ The stages, and the document each one writes:
|
|
|
469
472
|
| Stage | Runs while the item is | Writes |
|
|
470
473
|
| ------------ | ---------------------- | ----------------------------------------------------------------- |
|
|
471
474
|
| `inbox` | not yet an item | nothing; it creates the item, keeping the raw text as `intake.md` |
|
|
472
|
-
| `request` | backlog
|
|
473
|
-
| `spec` | backlog
|
|
474
|
-
| `plan` | backlog
|
|
475
|
+
| `request` | backlog (or active) | `initial-request.md`: what is asked for, and why |
|
|
476
|
+
| `spec` | backlog (or active) | `spec.md`: what to build, with acceptance criteria |
|
|
477
|
+
| `plan` | backlog (or active) | `plan.md`: how to build it, as ordered tasks |
|
|
475
478
|
| `implement` | active | `outcome.md`: what was built, and what the plan got wrong |
|
|
476
479
|
| `verify` | review (or active) | `refined-outcome.md` if accepted, `rework.md` if not |
|
|
477
480
|
| `postmortem` | review or completed | `post-mortem.md`: which stage could first have caught a problem |
|
|
@@ -483,7 +486,7 @@ The transitions, and what each one checks before it moves an item:
|
|
|
483
486
|
| `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
|
|
484
487
|
| `submit` | `tcw work submit <slug>` | active → review | never |
|
|
485
488
|
| `rework` | `tcw work rework <slug>` | review → active | `refined-outcome.md` is present (it says the work was accepted) |
|
|
486
|
-
| `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; the item's folder in its worktree has uncommitted changes; no `--confirm` |
|
|
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` |
|
|
487
490
|
| discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
|
|
488
491
|
| `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
|
|
489
492
|
| 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 |
|
|
@@ -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.8.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.8.0"
|
|
@@ -10,6 +10,7 @@ import subprocess
|
|
|
10
10
|
import sys
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
+
from tcw.cli_suggest import SuggestingParser, attach_index
|
|
13
14
|
from tcw import __version__
|
|
14
15
|
from tcw.capabilities import cli as capabilities_cli
|
|
15
16
|
from tcw.serve import DEFAULT_PORT, serve
|
|
@@ -210,12 +211,14 @@ def _declared_nodes_in_graph(
|
|
|
210
211
|
# Every project the registry holds, not `ancestors + descendants`. That
|
|
211
212
|
# triple omits a sibling and a sibling of an ancestor — the shapes a
|
|
212
213
|
# workspace is actually made of — so a declaration living on one was
|
|
213
|
-
# never read, and the repository it named never obtained.
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
# never read, and the repository it named never obtained. Except an
|
|
215
|
+
# upstream: what it connects to is its own to obtain, not its readers'.
|
|
216
|
+
registry = FsProjectRegistry.open(root)
|
|
217
|
+
roots += [Path(project.locator) for project in registry.projects()
|
|
218
|
+
if registry.read_only_reason(project.id) is None]
|
|
216
219
|
except Exception:
|
|
217
220
|
pass
|
|
218
|
-
found: list[tuple[Path, str, object]] = []
|
|
221
|
+
found: list[tuple[Path, str, object, str]] = []
|
|
219
222
|
problems: list[str] = []
|
|
220
223
|
for candidate in roots:
|
|
221
224
|
candidate = candidate.resolve()
|
|
@@ -224,7 +227,7 @@ def _declared_nodes_in_graph(
|
|
|
224
227
|
seen.add(candidate)
|
|
225
228
|
declared, candidate_problems = declared_connected_projects(candidate)
|
|
226
229
|
problems += [f"{candidate / SENTINEL}: {p}" for p in candidate_problems]
|
|
227
|
-
found += [(candidate, pid, d) for pid, d in declared]
|
|
230
|
+
found += [(candidate, pid, d, relation) for pid, d, relation in declared]
|
|
228
231
|
return found, problems
|
|
229
232
|
|
|
230
233
|
|
|
@@ -250,7 +253,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
250
253
|
|
|
251
254
|
failed = False
|
|
252
255
|
seen: set[Path] = set()
|
|
253
|
-
queue: list[tuple[Path, str, object]] = []
|
|
256
|
+
queue: list[tuple[Path, str, object, str]] = []
|
|
254
257
|
# "A project that is already here always wins" is the rule the whole feature
|
|
255
258
|
# rests on, and the walk is the one place it was not applied: a declaration
|
|
256
259
|
# is written by whichever node knows about that edge, so an ancestor
|
|
@@ -298,8 +301,14 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
298
301
|
return None
|
|
299
302
|
return located if located != target.resolve() else None
|
|
300
303
|
|
|
301
|
-
def enqueue(root: Path) -> None:
|
|
304
|
+
def enqueue(root: Path, relation: str = "children") -> None:
|
|
302
305
|
nonlocal failed
|
|
306
|
+
if relation == "upstream":
|
|
307
|
+
# Obtained so it can be read; its own connections are not followed,
|
|
308
|
+
# as the registry does not follow them. A reader must never fetch a
|
|
309
|
+
# project named only by something it reads — a public upstream can
|
|
310
|
+
# still name the private repository it is migrating away from.
|
|
311
|
+
return
|
|
303
312
|
found, problems = _declared_nodes_in_graph(root, enqueued)
|
|
304
313
|
for problem in problems:
|
|
305
314
|
print(f"tcw provision: {problem}", file=sys.stderr)
|
|
@@ -314,7 +323,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
314
323
|
enqueued: set[Path] = set()
|
|
315
324
|
enqueue(node_root)
|
|
316
325
|
while queue:
|
|
317
|
-
source, project_id, declaration = queue.pop(0)
|
|
326
|
+
source, project_id, declaration, relation = queue.pop(0)
|
|
318
327
|
target = provisioned_store_root(source, declaration)
|
|
319
328
|
if target in seen:
|
|
320
329
|
continue
|
|
@@ -335,13 +344,13 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
335
344
|
# provisioning precisely when an earlier one was already in place.
|
|
336
345
|
print(f" {project_id}: already available")
|
|
337
346
|
have.add(project_id)
|
|
338
|
-
enqueue(present)
|
|
347
|
+
enqueue(present, relation)
|
|
339
348
|
continue
|
|
340
349
|
provisioner = FsStoreProvisioner(source, NODE_TARGET, declaration)
|
|
341
350
|
if provisioner.is_available() and not refresh:
|
|
342
351
|
print(f" {project_id}: already available at {target}")
|
|
343
352
|
have.add(project_id)
|
|
344
|
-
enqueue(target)
|
|
353
|
+
enqueue(target, relation)
|
|
345
354
|
continue
|
|
346
355
|
print(f"→ {project_id}: {provisioner.describe().split(': ', 1)[1]}")
|
|
347
356
|
try:
|
|
@@ -363,7 +372,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
|
|
|
363
372
|
# routine when a parent and a grandparent both name it — report
|
|
364
373
|
# "already available" in a run that fetched nothing.
|
|
365
374
|
have.add(project_id)
|
|
366
|
-
enqueue(target)
|
|
375
|
+
enqueue(target, relation)
|
|
367
376
|
return failed
|
|
368
377
|
|
|
369
378
|
|
|
@@ -437,6 +446,8 @@ def _cmd_validate(args: argparse.Namespace) -> int:
|
|
|
437
446
|
print(f"{entry.declared_in}: connected project '{entry.id}' is declared "
|
|
438
447
|
f"at {entry.locator}, which is not in this checkout; it was found "
|
|
439
448
|
f"at {registry.get(entry.id).locator}", file=sys.stderr)
|
|
449
|
+
for warning in registry.warnings():
|
|
450
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
440
451
|
from tcw.validate import validate
|
|
441
452
|
recurse = args.path is None and not args.no_recurse
|
|
442
453
|
projects = [registry.current, *registry.descendants()] if recurse else [registry.current]
|
|
@@ -469,7 +480,7 @@ def _cmd_serve(args: argparse.Namespace) -> int:
|
|
|
469
480
|
|
|
470
481
|
|
|
471
482
|
def build_parser() -> argparse.ArgumentParser:
|
|
472
|
-
parser =
|
|
483
|
+
parser = SuggestingParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
|
|
473
484
|
parser.add_argument("--version", action="version", version=f"tcw {__version__}")
|
|
474
485
|
sub = parser.add_subparsers(dest="group", required=True)
|
|
475
486
|
|
|
@@ -518,6 +529,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
518
529
|
p = sub.add_parser(name, help=f"{name} commands (not yet implemented)")
|
|
519
530
|
p.set_defaults(func=_not_yet(name))
|
|
520
531
|
|
|
532
|
+
attach_index(parser)
|
|
521
533
|
return parser
|
|
522
534
|
|
|
523
535
|
|
|
@@ -550,9 +562,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
550
562
|
return 1
|
|
551
563
|
except subprocess.CalledProcessError as error:
|
|
552
564
|
# Deliberately generic: the only policy here is "a git subprocess
|
|
553
|
-
# failed", which is true of every component.
|
|
554
|
-
#
|
|
555
|
-
#
|
|
565
|
+
# failed", which is true of every component. The index-touching calls
|
|
566
|
+
# capture git's output (so a retried failure does not print once per
|
|
567
|
+
# try), and it is printed here; an uncaptured call has already shown it.
|
|
556
568
|
# `cmd` is a sequence *or* a string (stdlib contract); joining a string
|
|
557
569
|
# iterates its characters. No shipped raiser passes one, but a handler
|
|
558
570
|
# whose whole justification is that it assumes nothing about its caller
|
|
@@ -561,6 +573,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
561
573
|
shlex.join(str(a) for a in error.cmd)
|
|
562
574
|
print(f"tcw: git command failed (exit {error.returncode}): {cmd}",
|
|
563
575
|
file=sys.stderr)
|
|
576
|
+
if isinstance(error.stderr, str) and error.stderr.strip():
|
|
577
|
+
print(error.stderr.rstrip("\n"), file=sys.stderr)
|
|
564
578
|
return 1
|
|
565
579
|
|
|
566
580
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Answer a mistyped subcommand with the command that was probably meant.
|
|
2
|
+
|
|
3
|
+
argparse reports a wrong subcommand by listing every valid choice at that level,
|
|
4
|
+
which says nothing when the word exists one level down (`tcw tracker` for
|
|
5
|
+
`tcw work tracker`) or is a synonym (`status` for `show`). `SuggestingParser`
|
|
6
|
+
keeps argparse's message word for word and adds one line after it.
|
|
7
|
+
|
|
8
|
+
Three rules, first match wins:
|
|
9
|
+
|
|
10
|
+
1. The word is a subcommand somewhere else in the tree: name each full command,
|
|
11
|
+
and complete it with the word that followed when that is wrong there too —
|
|
12
|
+
`tcw tracker status` → `tcw work tracker show`.
|
|
13
|
+
2. A small table of synonyms, offered only where their target is valid.
|
|
14
|
+
3. Close spellings among the choices valid at that level.
|
|
15
|
+
|
|
16
|
+
Only subcommands that `--help` lists are ever offered. That is what keeps the
|
|
17
|
+
removed `tcw work stage <id>` spellings — registered without `help=` so that the
|
|
18
|
+
old form can print its replacement — out of every suggestion, without naming
|
|
19
|
+
them here.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import difflib
|
|
26
|
+
|
|
27
|
+
# One clear target each. Words for removal are left out on purpose: `drop`,
|
|
28
|
+
# `delete` and `rm` destroy things, and pointing at one is worse than no hint.
|
|
29
|
+
SYNONYMS: dict[str, tuple[str, ...]] = {
|
|
30
|
+
"status": ("show", "list"),
|
|
31
|
+
"info": ("show",),
|
|
32
|
+
"view": ("show",),
|
|
33
|
+
"get": ("show",),
|
|
34
|
+
"cat": ("show",),
|
|
35
|
+
"ls": ("list",),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Never offered by any rule, and never answered either: pointing somebody at a
|
|
39
|
+
# command that destroys things, when they typed something else, is worse than
|
|
40
|
+
# no hint — and so is guessing what a mistyped removal was meant to remove
|
|
41
|
+
# (`tcw work tracker delete` is not `tcw work delete`, which deletes an item).
|
|
42
|
+
REMOVAL = frozenset({"rm", "drop", "delete"})
|
|
43
|
+
|
|
44
|
+
_MAX = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _subparsers(parser: argparse.ArgumentParser) -> argparse._SubParsersAction | None:
|
|
48
|
+
for action in parser._actions:
|
|
49
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
50
|
+
return action
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def visible_choices(action: argparse._SubParsersAction) -> list[str]:
|
|
55
|
+
"""The subcommands this action lists in its help, in its own order."""
|
|
56
|
+
listed = {choice.dest for choice in action._choices_actions}
|
|
57
|
+
return [name for name in action.choices if name in listed]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _offered(word: str, choices: list[str]) -> list[str]:
|
|
61
|
+
"""Rules 2 and 3 for one level: synonyms valid here, else close spellings."""
|
|
62
|
+
safe = [c for c in choices if c not in REMOVAL]
|
|
63
|
+
synonyms = [s for s in SYNONYMS.get(word, ()) if s in safe]
|
|
64
|
+
if synonyms:
|
|
65
|
+
return synonyms
|
|
66
|
+
return difflib.get_close_matches(word, safe, n=_MAX, cutoff=0.6)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _command(path: tuple[str, ...]) -> str:
|
|
70
|
+
return "`" + " ".join(path) + "`"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _phrase(commands: list[str]) -> str:
|
|
74
|
+
if len(commands) == 1:
|
|
75
|
+
return f"did you mean {commands[0]}?"
|
|
76
|
+
if len(commands) == 2:
|
|
77
|
+
return f"did you mean {commands[0]} or {commands[1]}?"
|
|
78
|
+
return f"did you mean one of {', '.join(commands)}?"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SuggestingParser(argparse.ArgumentParser):
|
|
82
|
+
"""An `ArgumentParser` whose wrong-subcommand error suggests what was meant.
|
|
83
|
+
|
|
84
|
+
Every subparser gets this class too, since `add_subparsers` defaults to the
|
|
85
|
+
parent's. `attach_index` gives the whole tree what the hint needs."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, *args, **kwargs):
|
|
88
|
+
super().__init__(*args, **kwargs)
|
|
89
|
+
# Python 3.14 has argparse's own close-spelling hint. Off, explicitly,
|
|
90
|
+
# so a later default cannot print a second, different suggestion.
|
|
91
|
+
self.suggest_on_error = False
|
|
92
|
+
|
|
93
|
+
def parse_known_args(self, args=None, namespace=None):
|
|
94
|
+
# The root keeps what it was asked to parse: a nested parser reports its
|
|
95
|
+
# own error and needs the word after the one it rejected.
|
|
96
|
+
if getattr(self, "_tcw_root", None) is self:
|
|
97
|
+
self._tcw_argv = list(args) if args is not None else None
|
|
98
|
+
return super().parse_known_args(args, namespace)
|
|
99
|
+
|
|
100
|
+
def _check_value(self, action, value):
|
|
101
|
+
if not isinstance(action, argparse._SubParsersAction) or value in action.choices:
|
|
102
|
+
return super()._check_value(action, value)
|
|
103
|
+
choices = visible_choices(action)
|
|
104
|
+
hint = self._hint(str(value), choices)
|
|
105
|
+
if len(choices) == len(action.choices):
|
|
106
|
+
# Nothing hidden: argparse's own message, exactly as this Python
|
|
107
|
+
# version words it (3.12 and 3.13 leave the choices unquoted).
|
|
108
|
+
try:
|
|
109
|
+
super()._check_value(action, value)
|
|
110
|
+
except argparse.ArgumentError as error:
|
|
111
|
+
if not hint:
|
|
112
|
+
raise
|
|
113
|
+
raise argparse.ArgumentError(action, f"{error.message}\n{hint}") from None
|
|
114
|
+
message = (f"invalid choice: {value!r} (choose from "
|
|
115
|
+
f"{', '.join(repr(c) for c in choices)})")
|
|
116
|
+
if hint:
|
|
117
|
+
message += f"\n{hint}"
|
|
118
|
+
raise argparse.ArgumentError(action, message)
|
|
119
|
+
|
|
120
|
+
def _hint(self, word: str, choices: list[str]) -> str:
|
|
121
|
+
root = getattr(self, "_tcw_root", None)
|
|
122
|
+
here = getattr(self, "_tcw_path", None)
|
|
123
|
+
if root is None or here is None or word in REMOVAL:
|
|
124
|
+
return ""
|
|
125
|
+
# Same component first — `tcw work tags show` means a `tcw work …` show,
|
|
126
|
+
# not `tcw taxonomy show` — then shallowest: `tcw show` means
|
|
127
|
+
# `tcw work show` before `tcw work inbox show`. `sorted` is stable, so
|
|
128
|
+
# ties keep tree order.
|
|
129
|
+
elsewhere = sorted(
|
|
130
|
+
(p for p in root._tcw_index.get(word, ()) if p[:-1] != here),
|
|
131
|
+
key=lambda p: (-_shared(p, here), len(p)))
|
|
132
|
+
if elsewhere:
|
|
133
|
+
following = _following(root, here, word)
|
|
134
|
+
commands = []
|
|
135
|
+
for path in elsewhere[:_MAX]:
|
|
136
|
+
commands += [_command(path + tail) for tail in _completion(root, path, following)]
|
|
137
|
+
return _phrase(commands[:_MAX])
|
|
138
|
+
offered = _offered(word, choices)
|
|
139
|
+
return _phrase([_command(here + (o,)) for o in offered]) if offered else ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _shared(path: tuple[str, ...], here: tuple[str, ...]) -> int:
|
|
143
|
+
"""How many leading words `path` shares with `here`."""
|
|
144
|
+
n = 0
|
|
145
|
+
for a, b in zip(path, here):
|
|
146
|
+
if a != b:
|
|
147
|
+
break
|
|
148
|
+
n += 1
|
|
149
|
+
return n
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _following(root: SuggestingParser, here: tuple[str, ...], word: str) -> str:
|
|
153
|
+
"""The word after `word` on the command line, if it is not an option."""
|
|
154
|
+
argv = getattr(root, "_tcw_argv", None) or []
|
|
155
|
+
start = len(here) - 1 # `here` includes "tcw"
|
|
156
|
+
for index in range(start, len(argv)):
|
|
157
|
+
if argv[index] == word:
|
|
158
|
+
after = argv[index + 1] if index + 1 < len(argv) else ""
|
|
159
|
+
return "" if after.startswith("-") else after
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _completion(root: SuggestingParser, path: tuple[str, ...],
|
|
164
|
+
following: str) -> list[tuple[str, ...]]:
|
|
165
|
+
"""How to finish `path` with the word typed after it: kept when it is valid
|
|
166
|
+
there or there is nothing to finish, else what that word was likely meant as."""
|
|
167
|
+
parser = root._tcw_parsers.get(path)
|
|
168
|
+
action = _subparsers(parser) if parser is not None else None
|
|
169
|
+
if not following or action is None:
|
|
170
|
+
return [()]
|
|
171
|
+
choices = visible_choices(action)
|
|
172
|
+
if following in choices:
|
|
173
|
+
return [(following,)]
|
|
174
|
+
offered = _offered(following, choices)
|
|
175
|
+
return [(o,) for o in offered] or [()]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def attach_index(root: SuggestingParser) -> None:
|
|
179
|
+
"""Walk the finished tree once: every parser learns its own path and the
|
|
180
|
+
root, and the root learns every listed subcommand's full paths."""
|
|
181
|
+
index: dict[str, list[tuple[str, ...]]] = {}
|
|
182
|
+
parsers: dict[tuple[str, ...], argparse.ArgumentParser] = {}
|
|
183
|
+
|
|
184
|
+
def walk(parser: argparse.ArgumentParser, path: tuple[str, ...]) -> None:
|
|
185
|
+
parser._tcw_root = root
|
|
186
|
+
parser._tcw_path = path
|
|
187
|
+
parsers[path] = parser
|
|
188
|
+
action = _subparsers(parser)
|
|
189
|
+
if action is None:
|
|
190
|
+
return
|
|
191
|
+
listed = set(visible_choices(action))
|
|
192
|
+
for name, child in action.choices.items():
|
|
193
|
+
if name in listed:
|
|
194
|
+
index.setdefault(name, []).append(path + (name,))
|
|
195
|
+
walk(child, path + (name,))
|
|
196
|
+
|
|
197
|
+
walk(root, (root.prog,))
|
|
198
|
+
root._tcw_index = index
|
|
199
|
+
root._tcw_parsers = parsers
|
|
@@ -135,6 +135,10 @@ def resolve_tcw_ref(node_root: Path | None, uri: str) -> ResolveResult:
|
|
|
135
135
|
return ResolveResult(
|
|
136
136
|
False, "W", None, qualified_work_ref_problem(node_root, ns_ref))
|
|
137
137
|
store, bare = resolved
|
|
138
|
+
# A renamed item answers to its old slug too: a link is exactly the kind
|
|
139
|
+
# of reference a rename cannot rewrite.
|
|
140
|
+
if store.get(bare) is None and (renamed := store.renamed(bare)):
|
|
141
|
+
bare = renamed
|
|
138
142
|
# Locating the store that would hold the item is not the item existing.
|
|
139
143
|
# `resolve_qualified_work_ref` answers "which store addresses this ref",
|
|
140
144
|
# which is what `tcw serve`'s routing wants; `ok` answers "does this
|
|
@@ -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.
|
|
@@ -951,6 +967,7 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
951
967
|
initiative = body.get("initiative", "")
|
|
952
968
|
type_val = body.get("type", "")
|
|
953
969
|
tags = body.get("tags") or None
|
|
970
|
+
work.refresh_for_creation()
|
|
954
971
|
detail = work.create_work(
|
|
955
972
|
title=title,
|
|
956
973
|
created=created,
|
|
@@ -971,6 +988,14 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
971
988
|
# stale — which the next sidecar write from the page would
|
|
972
989
|
# be rejected on.
|
|
973
990
|
detail = work.get_detail(detail.item.slug) or detail
|
|
991
|
+
# After the owed record, so it is in the same commit. A refusal
|
|
992
|
+
# is logged, not returned: the item exists, and an error here
|
|
993
|
+
# would invite the browser to create it a second time.
|
|
994
|
+
folder = work.path(detail.item.slug)
|
|
995
|
+
if folder is not None and (reason := work.commit_writes(
|
|
996
|
+
f"tcw work: new {detail.item.slug}", folder)):
|
|
997
|
+
print(f"tcw serve: created {detail.item.slug}, but {reason}",
|
|
998
|
+
file=sys.stderr)
|
|
974
999
|
response = {
|
|
975
1000
|
"item": _item_payload(work, detail.item.slug, detail.item),
|
|
976
1001
|
"coreRevision": detail.core_revision,
|
|
@@ -993,6 +1018,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
993
1018
|
slug = _decode_path_param(m.group(1))
|
|
994
1019
|
action = _decode_path_param(m.group(2))
|
|
995
1020
|
qslug = slug # preserve the addressed (qualified) slug
|
|
1021
|
+
if self._refused_read_only(slug):
|
|
1022
|
+
return
|
|
996
1023
|
resolved = self._resolve_work(slug)
|
|
997
1024
|
if resolved is None:
|
|
998
1025
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1216,6 +1243,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1216
1243
|
if m:
|
|
1217
1244
|
slug = _decode_path_param(m.group(1))
|
|
1218
1245
|
qslug = slug # preserve the addressed (qualified) slug
|
|
1246
|
+
if self._refused_read_only(slug):
|
|
1247
|
+
return
|
|
1219
1248
|
resolved = self._resolve_work(slug)
|
|
1220
1249
|
if resolved is None:
|
|
1221
1250
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1370,6 +1399,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1370
1399
|
if name not in WORK_ARTIFACTS:
|
|
1371
1400
|
self._send(HTTPStatus.BAD_REQUEST, b"unknown artifact")
|
|
1372
1401
|
return
|
|
1402
|
+
if self._refused_read_only(slug):
|
|
1403
|
+
return
|
|
1373
1404
|
resolved = self._resolve_work(slug)
|
|
1374
1405
|
if resolved is None:
|
|
1375
1406
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1408,6 +1439,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1408
1439
|
if m:
|
|
1409
1440
|
slug = _decode_path_param(m.group(1))
|
|
1410
1441
|
stage_id = _decode_path_param(m.group(2))
|
|
1442
|
+
if self._refused_read_only(slug):
|
|
1443
|
+
return
|
|
1411
1444
|
resolved = self._resolve_work(slug)
|
|
1412
1445
|
if resolved is None:
|
|
1413
1446
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1443,6 +1476,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1443
1476
|
f"{name} is written by `{owner}`, not edited; "
|
|
1444
1477
|
f"run that command instead.")
|
|
1445
1478
|
return
|
|
1479
|
+
if self._refused_read_only(slug):
|
|
1480
|
+
return
|
|
1446
1481
|
resolved = self._resolve_work(slug)
|
|
1447
1482
|
if resolved is None:
|
|
1448
1483
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1498,6 +1533,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1498
1533
|
if m:
|
|
1499
1534
|
slug = _decode_path_param(m.group(1))
|
|
1500
1535
|
stage_id = _decode_path_param(m.group(2))
|
|
1536
|
+
if self._refused_read_only(slug):
|
|
1537
|
+
return
|
|
1501
1538
|
resolved = self._resolve_work(slug)
|
|
1502
1539
|
if resolved is None:
|
|
1503
1540
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1515,6 +1552,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1515
1552
|
m = re.match(r"^/api/work/([^/]+)$", path)
|
|
1516
1553
|
if m:
|
|
1517
1554
|
slug = _decode_path_param(m.group(1))
|
|
1555
|
+
if self._refused_read_only(slug):
|
|
1556
|
+
return
|
|
1518
1557
|
resolved = self._resolve_work(slug)
|
|
1519
1558
|
if resolved is None:
|
|
1520
1559
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|
|
@@ -1584,6 +1623,8 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
1584
1623
|
slug_q, _, stage_q = middle.partition("/plan-stages/")
|
|
1585
1624
|
slug = _decode_path_param(slug_q)
|
|
1586
1625
|
stage_id = _decode_path_param(stage_q)
|
|
1626
|
+
if self._refused_read_only(slug):
|
|
1627
|
+
return
|
|
1587
1628
|
resolved = self._resolve_work(slug)
|
|
1588
1629
|
if resolved is None:
|
|
1589
1630
|
self._send(HTTPStatus.NOT_FOUND, b"no such work item")
|