tcw-cli 2.7.0__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.7.0 → tcw_cli-2.8.0}/PKG-INFO +5 -5
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/README.md +4 -4
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/pyproject.toml +1 -1
- tcw_cli-2.8.0/tcw/__init__.py +1 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/cli.py +8 -4
- tcw_cli-2.8.0/tcw/cli_suggest.py +199 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/refs.py +4 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/__init__.py +9 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/base.py +160 -11
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/fs.py +897 -139
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/validate.py +5 -1
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/cli.py +291 -70
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/create-work.md +9 -4
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/implement.md +2 -1
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/inbox.md +2 -1
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/request.md +2 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/verify.md +3 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/recursion.py +11 -3
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/PKG-INFO +5 -5
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/SOURCES.txt +8 -0
- tcw_cli-2.8.0/tests/test_already_integrated_check.py +223 -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.7.0 → tcw_cli-2.8.0}/tests/test_external_work_store.py +7 -2
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_non_git_writes.py +24 -4
- tcw_cli-2.8.0/tests/test_planning_gates_after_start.py +144 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_prompt_fallback.py +5 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_recursion.py +4 -0
- {tcw_cli-2.7.0 → 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.7.0 → tcw_cli-2.8.0}/tests/test_retention.py +1 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_stage_verb.py +4 -3
- 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.7.0 → tcw_cli-2.8.0}/tests/test_tracker_comment.py +6 -2
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_strict.py +17 -2
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_sync.py +20 -1
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_transition_hints.py +6 -2
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_upstream_projects.py +4 -2
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work.py +3 -1
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work_autocommit.py +19 -8
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work_review.py +4 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_worktree_completion.py +12 -0
- tcw_cli-2.7.0/tcw/__init__.py +0 -1
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/LICENSE +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/setup.cfg +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/capabilities/cli.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/harness.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/stdin.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/checkouts.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/config_edit.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/project.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/__init__.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/claim.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/create.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/intake.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/jira.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/ownership.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/progress.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/sync.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/generate.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/audit-backlog.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/consolidate-plans.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/decompose.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/delegation.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/documentation-sync.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/post-mortem.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/search.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/triage-issues.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/unattended-work.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/projection.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/templates.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_blocker_cycles_through_unreadable_items.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_boardless_delegate_and_slices.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities_rm.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capability_gate_children.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_check_versions.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_child_status.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_cli_help_coverage.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_committed_paths.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_config_edit.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_config_edit_writers.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_configuration_text_home.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_cross_node_blocker_cycles.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_cross_node_blockers.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_dynamic_skill_marker.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_edit_refusal.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_edit_type.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_epic_completability_across_checkouts.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_coverage.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_files_changed.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_fixture.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_grading.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_runner.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_harness.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_init_configured_tree_path.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_initiative_node_qualified.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_interrupted_claim.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_item_tags_read.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_legacy_store_config.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_config_tags.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_literal_store_names.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_local_qualified_blockers.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_override_in_linked_worktree.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_override_letter_case.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_override_references.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_procedure_config.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_procedure_verb.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_project_graph_paths.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_project_registry.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_projection.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_resolve.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_resolve_procedure.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_routing_nodes.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_shipped_procedures.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_show_json.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_sidecar_paths_in_validate.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_skill_path_pointers.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_smoke.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_stage_validate.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_stdin.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_provisioning.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_taxonomy_rm_gaps.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_absent.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_binding.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_catch_up_moves.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_claim.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_claimability.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_cli.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_client.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_config.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_create.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_gate.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_hardening.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_help.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_hold.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_import.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_import_returns.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_inheritance.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_link.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_message_tidy.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_ownership.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_pre_backlog.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_replay.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_strict_gate.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_surface.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_sync_config.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_validate.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unattended_work_skill.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unplanned_start.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unreadable_capabilities_sidecar.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unreadable_item_files.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unreadable_state_gates.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_untag_invalid_tags.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_validate.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_validate_moved_stores.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work_start.py +0 -0
- {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work_tags.py +0 -0
- {tcw_cli-2.7.0 → 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
|
|
@@ -688,9 +688,9 @@ The stages, and the document each one writes:
|
|
|
688
688
|
| Stage | Runs while the item is | Writes |
|
|
689
689
|
| ------------ | ---------------------- | ----------------------------------------------------------------- |
|
|
690
690
|
| `inbox` | not yet an item | nothing; it creates the item, keeping the raw text as `intake.md` |
|
|
691
|
-
| `request` | backlog
|
|
692
|
-
| `spec` | backlog
|
|
693
|
-
| `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 |
|
|
694
694
|
| `implement` | active | `outcome.md`: what was built, and what the plan got wrong |
|
|
695
695
|
| `verify` | review (or active) | `refined-outcome.md` if accepted, `rework.md` if not |
|
|
696
696
|
| `postmortem` | review or completed | `post-mortem.md`: which stage could first have caught a problem |
|
|
@@ -702,7 +702,7 @@ The transitions, and what each one checks before it moves an item:
|
|
|
702
702
|
| `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
|
|
703
703
|
| `submit` | `tcw work submit <slug>` | active → review | never |
|
|
704
704
|
| `rework` | `tcw work rework <slug>` | review → active | `refined-outcome.md` is present (it says the work was accepted) |
|
|
705
|
-
| `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; 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` |
|
|
706
706
|
| discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
|
|
707
707
|
| `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
|
|
708
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 |
|
|
@@ -472,9 +472,9 @@ The stages, and the document each one writes:
|
|
|
472
472
|
| Stage | Runs while the item is | Writes |
|
|
473
473
|
| ------------ | ---------------------- | ----------------------------------------------------------------- |
|
|
474
474
|
| `inbox` | not yet an item | nothing; it creates the item, keeping the raw text as `intake.md` |
|
|
475
|
-
| `request` | backlog
|
|
476
|
-
| `spec` | backlog
|
|
477
|
-
| `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 |
|
|
478
478
|
| `implement` | active | `outcome.md`: what was built, and what the plan got wrong |
|
|
479
479
|
| `verify` | review (or active) | `refined-outcome.md` if accepted, `rework.md` if not |
|
|
480
480
|
| `postmortem` | review or completed | `post-mortem.md`: which stage could first have caught a problem |
|
|
@@ -486,7 +486,7 @@ The transitions, and what each one checks before it moves an item:
|
|
|
486
486
|
| `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
|
|
487
487
|
| `submit` | `tcw work submit <slug>` | active → review | never |
|
|
488
488
|
| `rework` | `tcw work rework <slug>` | review → active | `refined-outcome.md` is present (it says the work was accepted) |
|
|
489
|
-
| `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; 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` |
|
|
490
490
|
| discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
|
|
491
491
|
| `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
|
|
492
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 |
|
|
@@ -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
|
|
@@ -479,7 +480,7 @@ def _cmd_serve(args: argparse.Namespace) -> int:
|
|
|
479
480
|
|
|
480
481
|
|
|
481
482
|
def build_parser() -> argparse.ArgumentParser:
|
|
482
|
-
parser =
|
|
483
|
+
parser = SuggestingParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
|
|
483
484
|
parser.add_argument("--version", action="version", version=f"tcw {__version__}")
|
|
484
485
|
sub = parser.add_subparsers(dest="group", required=True)
|
|
485
486
|
|
|
@@ -528,6 +529,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
528
529
|
p = sub.add_parser(name, help=f"{name} commands (not yet implemented)")
|
|
529
530
|
p.set_defaults(func=_not_yet(name))
|
|
530
531
|
|
|
532
|
+
attach_index(parser)
|
|
531
533
|
return parser
|
|
532
534
|
|
|
533
535
|
|
|
@@ -560,9 +562,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
560
562
|
return 1
|
|
561
563
|
except subprocess.CalledProcessError as error:
|
|
562
564
|
# Deliberately generic: the only policy here is "a git subprocess
|
|
563
|
-
# failed", which is true of every component.
|
|
564
|
-
#
|
|
565
|
-
#
|
|
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.
|
|
566
568
|
# `cmd` is a sequence *or* a string (stdlib contract); joining a string
|
|
567
569
|
# iterates its characters. No shipped raiser passes one, but a handler
|
|
568
570
|
# whose whole justification is that it assumes nothing about its caller
|
|
@@ -571,6 +573,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
571
573
|
shlex.join(str(a) for a in error.cmd)
|
|
572
574
|
print(f"tcw: git command failed (exit {error.returncode}): {cmd}",
|
|
573
575
|
file=sys.stderr)
|
|
576
|
+
if isinstance(error.stderr, str) and error.stderr.strip():
|
|
577
|
+
print(error.stderr.rstrip("\n"), file=sys.stderr)
|
|
574
578
|
return 1
|
|
575
579
|
|
|
576
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
|
|
@@ -967,6 +967,7 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
967
967
|
initiative = body.get("initiative", "")
|
|
968
968
|
type_val = body.get("type", "")
|
|
969
969
|
tags = body.get("tags") or None
|
|
970
|
+
work.refresh_for_creation()
|
|
970
971
|
detail = work.create_work(
|
|
971
972
|
title=title,
|
|
972
973
|
created=created,
|
|
@@ -987,6 +988,14 @@ class TcwHandler(BaseHTTPRequestHandler):
|
|
|
987
988
|
# stale — which the next sidecar write from the page would
|
|
988
989
|
# be rejected on.
|
|
989
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)
|
|
990
999
|
response = {
|
|
991
1000
|
"item": _item_payload(work, detail.item.slug, detail.item),
|
|
992
1001
|
"coreRevision": detail.core_revision,
|
|
@@ -2255,6 +2255,7 @@ LIFECYCLE_STEPS: tuple[LifecycleStep, ...] = (
|
|
|
2255
2255
|
objective="Close the item as shipped.",
|
|
2256
2256
|
moves="review | active → completed",
|
|
2257
2257
|
gates=("unresolved blockers", "open initiative children",
|
|
2258
|
+
"refined-outcome.md from review",
|
|
2258
2259
|
"declared capabilities reconciled", "worktree merge-back",
|
|
2259
2260
|
"--confirm")),
|
|
2260
2261
|
LifecycleStep(
|
|
@@ -2292,13 +2293,14 @@ LIFECYCLE_STEPS_BY_ID = {s.id: s for s in LIFECYCLE_STEPS}
|
|
|
2292
2293
|
# * `spec` and `plan` include `active` because nothing moves an item back to
|
|
2293
2294
|
# `backlog`: an item started before it was specified or planned would
|
|
2294
2295
|
# otherwise never pass either gate again. `backlog` stays first — it is where
|
|
2295
|
-
# they normally run.
|
|
2296
|
+
# they normally run. `request` joined them in 2.8 for the same reason: work
|
|
2297
|
+
# written up after it started had no gate for its request at all.
|
|
2296
2298
|
#
|
|
2297
2299
|
# `inbox` is empty: it runs before an item exists, so there is no status to be
|
|
2298
2300
|
# legal in and no item to resolve a stage against.
|
|
2299
2301
|
STAGE_STATUSES: dict[str, tuple[str, ...]] = {
|
|
2300
2302
|
"inbox": (),
|
|
2301
|
-
"request": ("backlog",),
|
|
2303
|
+
"request": ("backlog", "active"),
|
|
2302
2304
|
"spec": ("backlog", "active"),
|
|
2303
2305
|
"plan": ("backlog", "active"),
|
|
2304
2306
|
"implement": ("active",),
|
|
@@ -2360,6 +2362,7 @@ TRANSITION_NEXT_STEPS: dict[str, str] = {
|
|
|
2360
2362
|
# `inbox accept` of a raw entry prints this too: both leave a backlog item
|
|
2361
2363
|
# holding at most its intake.
|
|
2362
2364
|
"new": "run `tcw work stage gate request <slug>`",
|
|
2365
|
+
"start:request": "run `tcw work stage gate request <slug>`",
|
|
2363
2366
|
"start:spec": "run `tcw work stage gate spec <slug>`",
|
|
2364
2367
|
"start:plan": "run `tcw work stage gate plan <slug>`",
|
|
2365
2368
|
"start:implement": "run `tcw work stage gate implement <slug>`",
|
|
@@ -2376,6 +2379,7 @@ TRANSITION_NEXT_STEPS: dict[str, str] = {
|
|
|
2376
2379
|
# stage against. Nothing else reads it.
|
|
2377
2380
|
TRANSITION_LANDS_IN: dict[str, str] = {
|
|
2378
2381
|
"new": "backlog",
|
|
2382
|
+
"start:request": "active",
|
|
2379
2383
|
"start:spec": "active",
|
|
2380
2384
|
"start:plan": "active",
|
|
2381
2385
|
"start:implement": "active",
|
|
@@ -2385,16 +2389,64 @@ TRANSITION_LANDS_IN: dict[str, str] = {
|
|
|
2385
2389
|
}
|
|
2386
2390
|
|
|
2387
2391
|
|
|
2392
|
+
def refined_outcome_missing(slug: str, stray: str = "") -> str:
|
|
2393
|
+
"""Why a completion from review was refused for want of its acceptance
|
|
2394
|
+
record, and where to put it."""
|
|
2395
|
+
return (f"{slug} has no refined-outcome.md, the verify stage's record that "
|
|
2396
|
+
f"the work was accepted. Write it in the item's folder, which moved "
|
|
2397
|
+
f"to review/ at submit — `tcw work path {slug}` prints it"
|
|
2398
|
+
+ (f"; {stray}" if stray else "")
|
|
2399
|
+
+ ". Use --force to complete without it.")
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
def slugify(name: str) -> str:
|
|
2403
|
+
return re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-")
|
|
2404
|
+
|
|
2405
|
+
|
|
2406
|
+
_DATE_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}-")
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
def rename_slug(old: str, new: str) -> str:
|
|
2410
|
+
"""The full slug `tcw work rename <old> <new>` means, or ValueError.
|
|
2411
|
+
|
|
2412
|
+
`new` may be a whole slug or only the part after the date. The date stays
|
|
2413
|
+
the item's own — it records when the item was made, not when it was named —
|
|
2414
|
+
so a different date is refused rather than quietly replaced. Never
|
|
2415
|
+
rewritten into slug form: an argument that is not one already is refused
|
|
2416
|
+
naming the form it would be, so the slug that lands is the slug typed."""
|
|
2417
|
+
new = new.strip()
|
|
2418
|
+
old_date = _DATE_PREFIX.match(old)
|
|
2419
|
+
new_date = _DATE_PREFIX.match(new)
|
|
2420
|
+
if new_date and old_date and new_date.group(0) != old_date.group(0):
|
|
2421
|
+
raise ValueError(f"{new} has a different date than {old}; the date is when "
|
|
2422
|
+
f"the item was made, and a rename keeps it — give only "
|
|
2423
|
+
f"the part after it")
|
|
2424
|
+
full = new if new_date or not old_date else old_date.group(0) + new
|
|
2425
|
+
body = _DATE_PREFIX.sub("", full)
|
|
2426
|
+
if not body or slugify(body) != body:
|
|
2427
|
+
hint = slugify(body) if body and slugify(body) != "untitled" else ""
|
|
2428
|
+
raise ValueError(f"'{new}' is not a slug (lower-case letters, digits and "
|
|
2429
|
+
f"single hyphens)" + (f"; did you mean '{hint}'?" if hint else ""))
|
|
2430
|
+
if len(body) > 120:
|
|
2431
|
+
raise ValueError(f"'{new}' is longer than 120 characters after the date")
|
|
2432
|
+
return full
|
|
2433
|
+
|
|
2434
|
+
|
|
2388
2435
|
def start_next_stage(present: Collection[str]) -> str:
|
|
2389
2436
|
"""The stage to run after `start`, from the artifacts the item holds.
|
|
2390
2437
|
|
|
2391
2438
|
Not always `implement`. An item can be started before it is specified or
|
|
2392
2439
|
planned, and `start` also takes an item already `active` — with
|
|
2393
2440
|
`--take-over`, or when nobody holds it — which may already have an outcome.
|
|
2394
|
-
The order is the `work` skill's "Finding your place",
|
|
2395
|
-
|
|
2396
|
-
|
|
2441
|
+
The order is the `work` skill's "Finding your place", with a reworked item
|
|
2442
|
+
sent back to `implement` as `rework`'s own hint does.
|
|
2443
|
+
|
|
2444
|
+
The request counts as missing only while the spec is too. A spec supersedes
|
|
2445
|
+
its job of recording what was asked, so an item already specified — the
|
|
2446
|
+
usual shape of work written up after it started — is never sent back for it.
|
|
2397
2447
|
"""
|
|
2448
|
+
if "initial-request" not in present and "spec" not in present:
|
|
2449
|
+
return "request"
|
|
2398
2450
|
if "spec" not in present:
|
|
2399
2451
|
return "spec"
|
|
2400
2452
|
if "plan" not in present:
|
|
@@ -3334,6 +3386,10 @@ def priority_order(items: list[WorkItem]) -> list[WorkItem]:
|
|
|
3334
3386
|
class WorkStore(ABC):
|
|
3335
3387
|
"""The work axis: raw intake plus a three-status item state machine.
|
|
3336
3388
|
|
|
3389
|
+
An adapter keeps concurrent state changes on one store apart — the
|
|
3390
|
+
filesystem store with a lock around each commit, a database with a
|
|
3391
|
+
transaction.
|
|
3392
|
+
|
|
3337
3393
|
The status vocabulary + legal-transition graph are core (above); adapters
|
|
3338
3394
|
implement the abstract primitives and `_effect_transition`. The named
|
|
3339
3395
|
operations (`start`/`complete`/`drop`) are concrete here so every adapter
|
|
@@ -3401,6 +3457,49 @@ class WorkStore(ABC):
|
|
|
3401
3457
|
def get(self, slug: str) -> WorkItem | None:
|
|
3402
3458
|
"""Resolve a stable id (slug) to its item, or None. Raises `MultipleMatch`."""
|
|
3403
3459
|
|
|
3460
|
+
def renamed(self, slug: str) -> str | None:
|
|
3461
|
+
"""The slug `slug` now goes by after `rename`, or None. A store that
|
|
3462
|
+
keeps the slug as a field keeps a table of old names to answer this; one
|
|
3463
|
+
that cannot rename never has an answer."""
|
|
3464
|
+
return None
|
|
3465
|
+
|
|
3466
|
+
def rename(self, slug: str, new_slug: str, *,
|
|
3467
|
+
owner: str = "") -> "tuple[WorkItem, list[str]]":
|
|
3468
|
+
"""Give an open item a new slug, rewriting what names it on this store.
|
|
3469
|
+
Returns the renamed item and notes on what was left naming the old one.
|
|
3470
|
+
An adapter checks `rename_refusal` first, which holds every refusal that
|
|
3471
|
+
does not depend on how items are stored."""
|
|
3472
|
+
raise NotImplementedError
|
|
3473
|
+
|
|
3474
|
+
def rename_refusal(self, item: WorkItem, new_slug: str, owner: str) -> str:
|
|
3475
|
+
"""The new slug for `item`, or raise `ValueError` saying why it may not
|
|
3476
|
+
be renamed. Storage-neutral: status, fields, holder, and whether the new
|
|
3477
|
+
slug is taken as a live item, a resolved item's record or an old name."""
|
|
3478
|
+
slug = item.slug
|
|
3479
|
+
new = rename_slug(slug, new_slug)
|
|
3480
|
+
if new == slug:
|
|
3481
|
+
raise ValueError(f"{slug} already has that slug")
|
|
3482
|
+
if item.status in RESOLVED_STATUSES:
|
|
3483
|
+
raise ValueError(f"{slug} is {item.status}; only an open item is renamed — "
|
|
3484
|
+
f"its folder may not exist in other clones, and its "
|
|
3485
|
+
f"old slug is already recorded as resolved")
|
|
3486
|
+
if item.worktree or item.branch:
|
|
3487
|
+
raise ValueError(
|
|
3488
|
+
f"{slug} has a worktree or branch ({item.branch or item.worktree}), "
|
|
3489
|
+
f"which a rename would strand. Complete or tear that down first, or "
|
|
3490
|
+
f"rename by hand: `git branch -m`, move .worktrees/{slug}, and update "
|
|
3491
|
+
f"the item's `branch` and `worktree` fields")
|
|
3492
|
+
if item.owner and item.owner != owner:
|
|
3493
|
+
raise ValueError(
|
|
3494
|
+
f"{slug} is held by {item.owner}. Run it as them "
|
|
3495
|
+
f"(`TCW_WORK_OWNER={item.owner} tcw work rename {slug} {new_slug}`), "
|
|
3496
|
+
f"or take the item over with `tcw work start {slug} --take-over`")
|
|
3497
|
+
if (self.get(new) is not None or self.tombstone(new) is not None
|
|
3498
|
+
or self.renamed(new) is not None):
|
|
3499
|
+
raise ValueError(f"{new} is already taken by an item, a resolved item's "
|
|
3500
|
+
f"record, or an earlier rename")
|
|
3501
|
+
return new
|
|
3502
|
+
|
|
3404
3503
|
@abstractmethod
|
|
3405
3504
|
def tombstone(self, slug: str) -> Tombstone | None:
|
|
3406
3505
|
"""The record of an item this store once held and has since resolved, or
|
|
@@ -3567,6 +3666,35 @@ class WorkStore(ABC):
|
|
|
3567
3666
|
"""
|
|
3568
3667
|
return []
|
|
3569
3668
|
|
|
3669
|
+
def commit_claim(self, slug: str, owner: str, label: str) -> None:
|
|
3670
|
+
"""Record that `owner` holds `slug` — a claim that is not a transition.
|
|
3671
|
+
A store whose writes are already durable only sets the field."""
|
|
3672
|
+
self.set_field(slug, "owner", owner)
|
|
3673
|
+
|
|
3674
|
+
def stray_folders(self, slug: str | None = None) -> list[tuple[Any, list[str]]]:
|
|
3675
|
+
"""`(location, files)` for each place named like an item that is not it —
|
|
3676
|
+
what writing through a location held since before a transition leaves
|
|
3677
|
+
behind. A store whose items have no location to go stale has none."""
|
|
3678
|
+
return []
|
|
3679
|
+
|
|
3680
|
+
def duplicate_slugs(self) -> dict[str, list[Any]]:
|
|
3681
|
+
"""Slugs held by more than one item. A store that keys items by slug
|
|
3682
|
+
cannot have any."""
|
|
3683
|
+
return {}
|
|
3684
|
+
|
|
3685
|
+
def refresh_for_creation(self) -> None:
|
|
3686
|
+
"""Bring the store up to date before a creation writes, where it has a
|
|
3687
|
+
remote copy to fall behind. A store with no copy of its own has nothing
|
|
3688
|
+
to do."""
|
|
3689
|
+
|
|
3690
|
+
def commit_writes(self, message: str, *paths: Any, removed: tuple = (),
|
|
3691
|
+
publish: bool = True) -> str | None:
|
|
3692
|
+
"""Record what a creation just wrote, as a transition records its move.
|
|
3693
|
+
None when recorded or when there is nothing to do; otherwise the rest of
|
|
3694
|
+
a sentence beginning "created X, but …". A store whose every write is
|
|
3695
|
+
already durable — a database, a tracker — records nothing more."""
|
|
3696
|
+
return None
|
|
3697
|
+
|
|
3570
3698
|
@abstractmethod
|
|
3571
3699
|
def artifacts(self, slug: str) -> list[Artifact]:
|
|
3572
3700
|
"""The bounded lifecycle artifact set for `slug`, with presence only.
|
|
@@ -4398,10 +4526,14 @@ class WorkStore(ABC):
|
|
|
4398
4526
|
if not text or "/" in text:
|
|
4399
4527
|
return False, ""
|
|
4400
4528
|
try:
|
|
4529
|
+
renamed = self.renamed(text)
|
|
4401
4530
|
live = self.get(text)
|
|
4531
|
+
if live is None and renamed:
|
|
4532
|
+
live = self.get(renamed)
|
|
4402
4533
|
if live is not None:
|
|
4403
4534
|
return live.status in RESOLVED_STATUSES, ""
|
|
4404
|
-
|
|
4535
|
+
# Resolved under its new slug, whose record is the one written.
|
|
4536
|
+
return self.tombstone(renamed or text) is not None, ""
|
|
4405
4537
|
except Exception: # a blocker never fails its reader
|
|
4406
4538
|
return False, ""
|
|
4407
4539
|
|
|
@@ -4421,6 +4553,10 @@ class WorkStore(ABC):
|
|
|
4421
4553
|
elif "slug" in b:
|
|
4422
4554
|
try:
|
|
4423
4555
|
blocker = self.get(b["slug"])
|
|
4556
|
+
# A renamed blocker is still the same work: follow it, or a
|
|
4557
|
+
# reference the rename could not rewrite would unblock.
|
|
4558
|
+
if blocker is None and (renamed := self.renamed(b["slug"])):
|
|
4559
|
+
blocker = self.get(renamed)
|
|
4424
4560
|
except ValueError:
|
|
4425
4561
|
# An adapter can refuse to settle a blocker — a claim on it
|
|
4426
4562
|
# was abandoned. That is still a blocker, and reporting it as
|
|
@@ -4495,11 +4631,11 @@ class WorkStore(ABC):
|
|
|
4495
4631
|
a transition is the wrong shape — so the refusal names the file and the
|
|
4496
4632
|
action instead.
|
|
4497
4633
|
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4634
|
+
`complete` from `review` gates on the same file the other way round: it
|
|
4635
|
+
needs it for `--resolution done`, since the file is what says the work
|
|
4636
|
+
was accepted. Abandoning verified work as `wontfix` is a legitimate
|
|
4637
|
+
decision, so a discard needs nothing. Only `rework` asserts the opposite
|
|
4638
|
+
of what the file says.
|
|
4503
4639
|
"""
|
|
4504
4640
|
if any(a.name == "refined-outcome" and a.present
|
|
4505
4641
|
for a in self.artifacts(slug)):
|
|
@@ -4571,6 +4707,19 @@ class WorkStore(ABC):
|
|
|
4571
4707
|
if blockers:
|
|
4572
4708
|
raise ValueError("blocked by: " + ", ".join(blockers)
|
|
4573
4709
|
+ " (use --force to override)")
|
|
4710
|
+
# Shipping out of review needs the verify stage's acceptance record.
|
|
4711
|
+
# Without it nothing distinguishes accepted work from work nobody
|
|
4712
|
+
# verified — and the usual way it goes missing is quiet: written to
|
|
4713
|
+
# the folder the item left at `submit`. Completing from `active`
|
|
4714
|
+
# skips verify on purpose (the CLI says so), and an epic closing
|
|
4715
|
+
# from `backlog` never had one; neither is refused.
|
|
4716
|
+
# An epic is exempt: its children carry the verification, and
|
|
4717
|
+
# `reconcile --complete-when-ready` closes it with no `--force`.
|
|
4718
|
+
if dest == "completed" and item.status == "review" \
|
|
4719
|
+
and item.type != "epic" and not any(
|
|
4720
|
+
a.name == "refined-outcome" and a.present
|
|
4721
|
+
for a in self.artifacts(slug)):
|
|
4722
|
+
raise ValueError(refined_outcome_missing(slug))
|
|
4574
4723
|
# The resolution rides the transition rather than preceding it: written
|
|
4575
4724
|
# first, a `complete` that then loses the move would leave its resolution
|
|
4576
4725
|
# on the item the winner moved — an item reading `wontfix` in
|