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.
Files changed (228) hide show
  1. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/PKG-INFO +5 -5
  2. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/README.md +4 -4
  3. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/pyproject.toml +1 -1
  4. tcw_cli-2.8.0/tcw/__init__.py +1 -0
  5. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/cli.py +8 -4
  6. tcw_cli-2.8.0/tcw/cli_suggest.py +199 -0
  7. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/refs.py +4 -0
  8. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/__init__.py +9 -0
  9. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/base.py +160 -11
  10. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/fs.py +897 -139
  11. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/validate.py +5 -1
  12. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/cli.py +291 -70
  13. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/create-work.md +9 -4
  14. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/implement.md +2 -1
  15. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/inbox.md +2 -1
  16. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/request.md +2 -0
  17. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/verify.md +3 -0
  18. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/recursion.py +11 -3
  19. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/PKG-INFO +5 -5
  20. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/SOURCES.txt +8 -0
  21. tcw_cli-2.8.0/tests/test_already_integrated_check.py +223 -0
  22. tcw_cli-2.8.0/tests/test_command_suggestions.py +154 -0
  23. tcw_cli-2.8.0/tests/test_creation_commits.py +262 -0
  24. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_external_work_store.py +7 -2
  25. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_non_git_writes.py +24 -4
  26. tcw_cli-2.8.0/tests/test_planning_gates_after_start.py +144 -0
  27. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_prompt_fallback.py +5 -0
  28. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_recursion.py +4 -0
  29. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_refs.py +8 -4
  30. tcw_cli-2.8.0/tests/test_rename_work_item.py +453 -0
  31. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_retention.py +1 -0
  32. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_stage_verb.py +4 -3
  33. tcw_cli-2.8.0/tests/test_stale_item_path.py +283 -0
  34. tcw_cli-2.8.0/tests/test_store_lock.py +287 -0
  35. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_comment.py +6 -2
  36. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_strict.py +17 -2
  37. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_sync.py +20 -1
  38. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_transition_hints.py +6 -2
  39. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_upstream_projects.py +4 -2
  40. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work.py +3 -1
  41. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work_autocommit.py +19 -8
  42. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work_review.py +4 -0
  43. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_worktree_completion.py +12 -0
  44. tcw_cli-2.7.0/tcw/__init__.py +0 -1
  45. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/LICENSE +0 -0
  46. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/setup.cfg +0 -0
  47. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/capabilities/__init__.py +0 -0
  48. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/capabilities/cli.py +0 -0
  49. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/harness.py +0 -0
  50. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
  51. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
  52. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/client/index.html +0 -0
  53. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/client/theme-init.js +0 -0
  54. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/dist/server.cjs +0 -0
  55. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/serve/runtime.py +0 -0
  56. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/stdin.py +0 -0
  57. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/__init__.py +0 -0
  58. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/checkouts.py +0 -0
  59. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/config_edit.py +0 -0
  60. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/store/project.py +0 -0
  61. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/taxonomy/__init__.py +0 -0
  62. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/taxonomy/cli.py +0 -0
  63. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/__init__.py +0 -0
  64. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/claim.py +0 -0
  65. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/create.py +0 -0
  66. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/intake.py +0 -0
  67. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/jira.py +0 -0
  68. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/ownership.py +0 -0
  69. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/progress.py +0 -0
  70. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/tracker/sync.py +0 -0
  71. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/__init__.py +0 -0
  72. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/generate.py +0 -0
  73. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/hooks.py +0 -0
  74. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/audit-backlog.md +0 -0
  75. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/consolidate-plans.md +0 -0
  76. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/decompose.md +0 -0
  77. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/delegation.md +0 -0
  78. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/documentation-sync.md +0 -0
  79. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/post-mortem.md +0 -0
  80. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/search.md +0 -0
  81. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/triage-issues.md +0 -0
  82. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/procedures/unattended-work.md +0 -0
  83. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/projection.py +0 -0
  84. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/plan.md +0 -0
  85. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/postmortem.md +0 -0
  86. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/prompts/spec.md +0 -0
  87. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/resolve.py +0 -0
  88. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw/work/templates.py +0 -0
  89. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
  90. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/entry_points.txt +0 -0
  91. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/requires.txt +0 -0
  92. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tcw_cli.egg-info/top_level.txt +0 -0
  93. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_blocker_cycles_through_unreadable_items.py +0 -0
  94. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_boardless_delegate_and_slices.py +0 -0
  95. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_body_prompt.py +0 -0
  96. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities.py +0 -0
  97. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities_federation.py +0 -0
  98. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities_reset.py +0 -0
  99. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities_rm.py +0 -0
  100. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capabilities_sidecar.py +0 -0
  101. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capability_gate_children.py +0 -0
  102. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_capability_ref_wording.py +0 -0
  103. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_check_versions.py +0 -0
  104. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_child_status.py +0 -0
  105. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_cli_help_coverage.py +0 -0
  106. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_committed_paths.py +0 -0
  107. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_config_edit.py +0 -0
  108. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_config_edit_writers.py +0 -0
  109. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_configuration_text_home.py +0 -0
  110. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_cross_node_blocker_cycles.py +0 -0
  111. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_cross_node_blockers.py +0 -0
  112. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_cut_version.py +0 -0
  113. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_documentation_config.py +0 -0
  114. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_documentation_prompt.py +0 -0
  115. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_documentation_sync_wiring.py +0 -0
  116. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_documented_cli_surface.py +0 -0
  117. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_dynamic_skill_marker.py +0 -0
  118. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_edit_refusal.py +0 -0
  119. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_edit_type.py +0 -0
  120. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_environment_hardness.py +0 -0
  121. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_epic_completability_across_checkouts.py +0 -0
  122. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_epic_completable.py +0 -0
  123. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_coverage.py +0 -0
  124. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_files_changed.py +0 -0
  125. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_fixture.py +0 -0
  126. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_grading.py +0 -0
  127. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_eval_runner.py +0 -0
  128. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_falsification_rule.py +0 -0
  129. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_generate_hook.py +0 -0
  130. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_harness.py +0 -0
  131. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_inbox_title.py +0 -0
  132. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_init_configured_tree_path.py +0 -0
  133. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_initiative_node_qualified.py +0 -0
  134. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_interrupted_claim.py +0 -0
  135. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_item_tags_read.py +0 -0
  136. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_legacy_store_config.py +0 -0
  137. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_baseline.py +0 -0
  138. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_config_tags.py +0 -0
  139. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_hooks.py +0 -0
  140. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_inert.py +0 -0
  141. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_policy.py +0 -0
  142. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_lifecycle_validation.py +0 -0
  143. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_literal_store_names.py +0 -0
  144. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_local_qualified_blockers.py +0 -0
  145. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_multiproject.py +0 -0
  146. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_override_in_linked_worktree.py +0 -0
  147. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_override_letter_case.py +0 -0
  148. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_override_references.py +0 -0
  149. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_plugin_manifests.py +0 -0
  150. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_procedure_config.py +0 -0
  151. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_procedure_verb.py +0 -0
  152. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_project_graph_paths.py +0 -0
  153. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_project_overrides.py +0 -0
  154. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_project_registry.py +0 -0
  155. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_projection.py +0 -0
  156. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_qualified_ref.py +0 -0
  157. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_remote_session_setup.py +0 -0
  158. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_repo_lifecycle.py +0 -0
  159. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_resolve.py +0 -0
  160. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_resolve_procedure.py +0 -0
  161. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_routing_nodes.py +0 -0
  162. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_scaffold.py +0 -0
  163. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve.py +0 -0
  164. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_descendants.py +0 -0
  165. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_projection.py +0 -0
  166. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_resolve.py +0 -0
  167. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_runtime.py +0 -0
  168. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_serve_write.py +0 -0
  169. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_session_bootstrap.py +0 -0
  170. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_shipped_procedures.py +0 -0
  171. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_shipped_prompts.py +0 -0
  172. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_show_json.py +0 -0
  173. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_sidecar_paths_in_validate.py +0 -0
  174. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_skill_flow.py +0 -0
  175. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_skill_lifecycle_parity.py +0 -0
  176. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_skill_path_pointers.py +0 -0
  177. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_smoke.py +0 -0
  178. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_stage_validate.py +0 -0
  179. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_status_parity.py +0 -0
  180. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_stdin.py +0 -0
  181. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_stdin_cli.py +0 -0
  182. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_bounds.py +0 -0
  183. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_editor.py +0 -0
  184. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_nodes.py +0 -0
  185. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_provisioning.py +0 -0
  186. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_store_publication.py +0 -0
  187. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_subprocess_stdin.py +0 -0
  188. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_taxonomy.py +0 -0
  189. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_taxonomy_rm_gaps.py +0 -0
  190. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tombstone.py +0 -0
  191. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_absent.py +0 -0
  192. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_binding.py +0 -0
  193. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_catch_up_moves.py +0 -0
  194. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_claim.py +0 -0
  195. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_claimability.py +0 -0
  196. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_cli.py +0 -0
  197. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_client.py +0 -0
  198. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_config.py +0 -0
  199. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_create.py +0 -0
  200. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_gate.py +0 -0
  201. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_hardening.py +0 -0
  202. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_help.py +0 -0
  203. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_hold.py +0 -0
  204. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_import.py +0 -0
  205. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_import_returns.py +0 -0
  206. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_inheritance.py +0 -0
  207. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_link.py +0 -0
  208. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_message_tidy.py +0 -0
  209. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_ownership.py +0 -0
  210. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_pre_backlog.py +0 -0
  211. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_replay.py +0 -0
  212. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_strict_gate.py +0 -0
  213. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_surface.py +0 -0
  214. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_sync_config.py +0 -0
  215. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_tracker_validate.py +0 -0
  216. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unattended_work_skill.py +0 -0
  217. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unplanned_start.py +0 -0
  218. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unpushed_version_script.py +0 -0
  219. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unreadable_capabilities_sidecar.py +0 -0
  220. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unreadable_item_files.py +0 -0
  221. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_unreadable_state_gates.py +0 -0
  222. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_untag_invalid_tags.py +0 -0
  223. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_validate.py +0 -0
  224. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_validate_moved_stores.py +0 -0
  225. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_validate_target.py +0 -0
  226. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work_start.py +0 -0
  227. {tcw_cli-2.7.0 → tcw_cli-2.8.0}/tests/test_work_tags.py +0 -0
  228. {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.7.0
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 | `initial-request.md`: what is asked for, and why |
692
- | `spec` | backlog | `spec.md`: what to build, with acceptance criteria |
693
- | `plan` | backlog | `plan.md`: how to build it, as ordered tasks |
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 | `initial-request.md`: what is asked for, and why |
476
- | `spec` | backlog | `spec.md`: what to build, with acceptance criteria |
477
- | `plan` | backlog | `plan.md`: how to build it, as ordered tasks |
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.0"
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 = argparse.ArgumentParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
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. git's own diagnostic has
564
- # already reached the terminal — no `check=True` call in the filesystem
565
- # adapter captures output — so re-printing `error.stderr` would double it.
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", restricted to stages
2395
- legal in `active` (so never `request`), with a reworked item sent back to
2396
- `implement` as `rework`'s own hint does.
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
- return self.tombstone(text) is not None, ""
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
- This is the *only* transition the artifact gates. `complete` from
4499
- `review` is unaffected on either resolution: a present
4500
- `refined-outcome.md` is the normal path into `--resolution done`, and
4501
- abandoning verified work as `wontfix` is a legitimate decision. Only
4502
- `rework` asserts the opposite of what the file says.
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