tcw-cli 2.7.0__tar.gz → 2.8.1__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 (233) hide show
  1. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/PKG-INFO +5 -5
  2. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/README.md +4 -4
  3. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/pyproject.toml +1 -1
  4. tcw_cli-2.8.1/tcw/__init__.py +1 -0
  5. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/cli.py +17 -7
  6. tcw_cli-2.8.1/tcw/cli_suggest.py +199 -0
  7. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/refs.py +4 -0
  8. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/serve/__init__.py +35 -3
  9. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/store/base.py +174 -14
  10. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/store/config_edit.py +4 -3
  11. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/store/fs.py +1041 -159
  12. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/store/project.py +3 -1
  13. tcw_cli-2.8.1/tcw/store/yaml_source.py +25 -0
  14. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/validate.py +7 -2
  15. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/cli.py +343 -74
  16. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/create-work.md +9 -4
  17. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/prompts/implement.md +2 -1
  18. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/prompts/inbox.md +2 -1
  19. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/prompts/request.md +2 -0
  20. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/prompts/verify.md +3 -0
  21. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/recursion.py +11 -3
  22. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/PKG-INFO +5 -5
  23. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/SOURCES.txt +13 -0
  24. tcw_cli-2.8.1/tests/test_already_integrated_check.py +365 -0
  25. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_blocker_cycles_through_unreadable_items.py +1 -1
  26. tcw_cli-2.8.1/tests/test_command_suggestions.py +154 -0
  27. tcw_cli-2.8.1/tests/test_creation_commits.py +262 -0
  28. tcw_cli-2.8.1/tests/test_damaged_state_on_edit.py +125 -0
  29. tcw_cli-2.8.1/tests/test_detached_worktree_teardown.py +145 -0
  30. tcw_cli-2.8.1/tests/test_duplicate_slug_refusals.py +139 -0
  31. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_external_work_store.py +7 -2
  32. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_non_git_writes.py +24 -4
  33. tcw_cli-2.8.1/tests/test_planning_gates_after_start.py +144 -0
  34. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_prompt_fallback.py +5 -0
  35. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_recursion.py +4 -0
  36. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_refs.py +8 -4
  37. tcw_cli-2.8.1/tests/test_rename_work_item.py +453 -0
  38. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_retention.py +4 -1
  39. tcw_cli-2.8.1/tests/test_serve_duplicate_slug.py +47 -0
  40. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_stage_verb.py +4 -3
  41. tcw_cli-2.8.1/tests/test_stale_item_path.py +283 -0
  42. tcw_cli-2.8.1/tests/test_store_lock.py +287 -0
  43. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_comment.py +6 -2
  44. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_strict.py +17 -2
  45. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_sync.py +20 -1
  46. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_transition_hints.py +6 -2
  47. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_unreadable_capabilities_sidecar.py +2 -1
  48. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_upstream_projects.py +4 -2
  49. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_work.py +6 -2
  50. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_work_autocommit.py +19 -8
  51. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_work_review.py +4 -0
  52. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_worktree_completion.py +12 -0
  53. tcw_cli-2.7.0/tcw/__init__.py +0 -1
  54. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/LICENSE +0 -0
  55. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/setup.cfg +0 -0
  56. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/capabilities/__init__.py +0 -0
  57. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/capabilities/cli.py +0 -0
  58. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/harness.py +0 -0
  59. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
  60. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
  61. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/serve/dist/client/index.html +0 -0
  62. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/serve/dist/client/theme-init.js +0 -0
  63. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/serve/dist/server.cjs +0 -0
  64. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/serve/runtime.py +0 -0
  65. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/stdin.py +0 -0
  66. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/store/__init__.py +0 -0
  67. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/store/checkouts.py +0 -0
  68. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/taxonomy/__init__.py +0 -0
  69. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/taxonomy/cli.py +0 -0
  70. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/tracker/__init__.py +0 -0
  71. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/tracker/claim.py +0 -0
  72. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/tracker/create.py +0 -0
  73. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/tracker/intake.py +0 -0
  74. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/tracker/jira.py +0 -0
  75. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/tracker/ownership.py +0 -0
  76. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/tracker/progress.py +0 -0
  77. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/tracker/sync.py +0 -0
  78. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/__init__.py +0 -0
  79. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/generate.py +0 -0
  80. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/hooks.py +0 -0
  81. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/audit-backlog.md +0 -0
  82. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/consolidate-plans.md +0 -0
  83. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/decompose.md +0 -0
  84. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/delegation.md +0 -0
  85. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/documentation-sync.md +0 -0
  86. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/post-mortem.md +0 -0
  87. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/search.md +0 -0
  88. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/triage-issues.md +0 -0
  89. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/procedures/unattended-work.md +0 -0
  90. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/projection.py +0 -0
  91. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/prompts/plan.md +0 -0
  92. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/prompts/postmortem.md +0 -0
  93. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/prompts/spec.md +0 -0
  94. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/resolve.py +0 -0
  95. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw/work/templates.py +0 -0
  96. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/dependency_links.txt +0 -0
  97. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/entry_points.txt +0 -0
  98. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/requires.txt +0 -0
  99. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/top_level.txt +0 -0
  100. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_boardless_delegate_and_slices.py +0 -0
  101. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_body_prompt.py +0 -0
  102. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_capabilities.py +0 -0
  103. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_capabilities_federation.py +0 -0
  104. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_capabilities_reset.py +0 -0
  105. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_capabilities_rm.py +0 -0
  106. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_capabilities_sidecar.py +0 -0
  107. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_capability_gate_children.py +0 -0
  108. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_capability_ref_wording.py +0 -0
  109. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_check_versions.py +0 -0
  110. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_child_status.py +0 -0
  111. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_cli_help_coverage.py +0 -0
  112. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_committed_paths.py +0 -0
  113. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_config_edit.py +0 -0
  114. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_config_edit_writers.py +0 -0
  115. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_configuration_text_home.py +0 -0
  116. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_cross_node_blocker_cycles.py +0 -0
  117. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_cross_node_blockers.py +0 -0
  118. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_cut_version.py +0 -0
  119. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_documentation_config.py +0 -0
  120. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_documentation_prompt.py +0 -0
  121. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_documentation_sync_wiring.py +0 -0
  122. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_documented_cli_surface.py +0 -0
  123. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_dynamic_skill_marker.py +0 -0
  124. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_edit_refusal.py +0 -0
  125. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_edit_type.py +0 -0
  126. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_environment_hardness.py +0 -0
  127. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_epic_completability_across_checkouts.py +0 -0
  128. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_epic_completable.py +0 -0
  129. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_eval_coverage.py +0 -0
  130. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_eval_files_changed.py +0 -0
  131. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_eval_fixture.py +0 -0
  132. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_eval_grading.py +0 -0
  133. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_eval_runner.py +0 -0
  134. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_falsification_rule.py +0 -0
  135. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_generate_hook.py +0 -0
  136. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_harness.py +0 -0
  137. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_inbox_title.py +0 -0
  138. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_init_configured_tree_path.py +0 -0
  139. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_initiative_node_qualified.py +0 -0
  140. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_interrupted_claim.py +0 -0
  141. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_item_tags_read.py +0 -0
  142. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_legacy_store_config.py +0 -0
  143. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_lifecycle_baseline.py +0 -0
  144. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_lifecycle_config_tags.py +0 -0
  145. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_lifecycle_hooks.py +0 -0
  146. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_lifecycle_inert.py +0 -0
  147. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_lifecycle_policy.py +0 -0
  148. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_lifecycle_validation.py +0 -0
  149. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_literal_store_names.py +0 -0
  150. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_local_qualified_blockers.py +0 -0
  151. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_multiproject.py +0 -0
  152. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_override_in_linked_worktree.py +0 -0
  153. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_override_letter_case.py +0 -0
  154. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_override_references.py +0 -0
  155. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_plugin_manifests.py +0 -0
  156. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_procedure_config.py +0 -0
  157. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_procedure_verb.py +0 -0
  158. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_project_graph_paths.py +0 -0
  159. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_project_overrides.py +0 -0
  160. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_project_registry.py +0 -0
  161. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_projection.py +0 -0
  162. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_qualified_ref.py +0 -0
  163. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_remote_session_setup.py +0 -0
  164. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_repo_lifecycle.py +0 -0
  165. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_resolve.py +0 -0
  166. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_resolve_procedure.py +0 -0
  167. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_routing_nodes.py +0 -0
  168. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_scaffold.py +0 -0
  169. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_serve.py +0 -0
  170. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_serve_descendants.py +0 -0
  171. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_serve_projection.py +0 -0
  172. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_serve_resolve.py +0 -0
  173. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_serve_runtime.py +0 -0
  174. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_serve_write.py +0 -0
  175. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_session_bootstrap.py +0 -0
  176. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_shipped_procedures.py +0 -0
  177. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_shipped_prompts.py +0 -0
  178. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_show_json.py +0 -0
  179. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_sidecar_paths_in_validate.py +0 -0
  180. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_skill_flow.py +0 -0
  181. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_skill_lifecycle_parity.py +0 -0
  182. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_skill_path_pointers.py +0 -0
  183. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_smoke.py +0 -0
  184. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_stage_validate.py +0 -0
  185. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_status_parity.py +0 -0
  186. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_stdin.py +0 -0
  187. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_stdin_cli.py +0 -0
  188. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_store_bounds.py +0 -0
  189. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_store_editor.py +0 -0
  190. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_store_nodes.py +0 -0
  191. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_store_provisioning.py +0 -0
  192. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_store_publication.py +0 -0
  193. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_subprocess_stdin.py +0 -0
  194. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_taxonomy.py +0 -0
  195. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_taxonomy_rm_gaps.py +0 -0
  196. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tombstone.py +0 -0
  197. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_absent.py +0 -0
  198. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_binding.py +0 -0
  199. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_catch_up_moves.py +0 -0
  200. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_claim.py +0 -0
  201. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_claimability.py +0 -0
  202. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_cli.py +0 -0
  203. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_client.py +0 -0
  204. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_config.py +0 -0
  205. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_create.py +0 -0
  206. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_gate.py +0 -0
  207. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_hardening.py +0 -0
  208. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_help.py +0 -0
  209. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_hold.py +0 -0
  210. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_import.py +0 -0
  211. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_import_returns.py +0 -0
  212. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_inheritance.py +0 -0
  213. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_link.py +0 -0
  214. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_message_tidy.py +0 -0
  215. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_ownership.py +0 -0
  216. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_pre_backlog.py +0 -0
  217. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_replay.py +0 -0
  218. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_strict_gate.py +0 -0
  219. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_surface.py +0 -0
  220. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_sync_config.py +0 -0
  221. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_tracker_validate.py +0 -0
  222. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_unattended_work_skill.py +0 -0
  223. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_unplanned_start.py +0 -0
  224. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_unpushed_version_script.py +0 -0
  225. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_unreadable_item_files.py +0 -0
  226. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_unreadable_state_gates.py +0 -0
  227. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_untag_invalid_tags.py +0 -0
  228. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_validate.py +0 -0
  229. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_validate_moved_stores.py +0 -0
  230. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_validate_target.py +0 -0
  231. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_work_start.py +0 -0
  232. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/tests/test_work_tags.py +0 -0
  233. {tcw_cli-2.7.0 → tcw_cli-2.8.1}/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.1
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; its worktree holds commits on no branch; 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; its worktree holds commits on no branch; 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.1"
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.1"
@@ -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
@@ -18,7 +19,9 @@ from tcw.store.fs import (
18
19
  FsWorkStore, declared_repository, find_node_root, git_root, init,
19
20
  provisioned_store_root,
20
21
  )
22
+ from tcw.store.base import MultipleMatch
21
23
  from tcw.store.project import FsProjectRegistry
24
+ from tcw.store.yaml_source import load as _load_named_yaml
22
25
  import yaml
23
26
  from tcw.taxonomy import cli as taxonomy_cli
24
27
  from tcw.work import cli as work_cli
@@ -55,7 +58,8 @@ def run_init(components: list[str], project_id: str | None = None,
55
58
  sentinel = root / SENTINEL
56
59
  if project_id is None:
57
60
  try:
58
- configured = yaml.safe_load(sentinel.read_text(encoding="utf-8")) if sentinel.exists() else {}
61
+ configured = (_load_named_yaml(sentinel.read_text(encoding="utf-8"), sentinel)
62
+ if sentinel.exists() else {})
59
63
  except yaml.YAMLError as error:
60
64
  print(f"tcw init: invalid {SENTINEL}: {error}", file=sys.stderr)
61
65
  return 1
@@ -479,7 +483,7 @@ def _cmd_serve(args: argparse.Namespace) -> int:
479
483
 
480
484
 
481
485
  def build_parser() -> argparse.ArgumentParser:
482
- parser = argparse.ArgumentParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
486
+ parser = SuggestingParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
483
487
  parser.add_argument("--version", action="version", version=f"tcw {__version__}")
484
488
  sub = parser.add_subparsers(dest="group", required=True)
485
489
 
@@ -528,6 +532,7 @@ def build_parser() -> argparse.ArgumentParser:
528
532
  p = sub.add_parser(name, help=f"{name} commands (not yet implemented)")
529
533
  p.set_defaults(func=_not_yet(name))
530
534
 
535
+ attach_index(parser)
531
536
  return parser
532
537
 
533
538
 
@@ -546,7 +551,10 @@ def main(argv: list[str] | None = None) -> int:
546
551
  args = build_parser().parse_args(_normalize(argv))
547
552
  try:
548
553
  return args.func(args)
549
- except ValueError as error:
554
+ except (ValueError, MultipleMatch) as error:
555
+ # `MultipleMatch` is not a `ValueError`, so a verb that does not catch it
556
+ # itself — `start`, `submit` — printed a traceback for a slug two folders
557
+ # hold. Its message names both folders; this is only the safety net.
550
558
  print(f"tcw: {error}", file=sys.stderr)
551
559
  return 1
552
560
  except yaml.YAMLError as error:
@@ -555,14 +563,14 @@ def main(argv: list[str] | None = None) -> int:
555
563
  # twenty-odd `load_yaml` calls that do not catch it: refusing is already
556
564
  # the right answer at every one of them — `init` must not overwrite a
557
565
  # config it cannot read — and only the presentation was wrong. The
558
- # loader's message names the file.
566
+ # loader's message names the file (`tcw.store.yaml_source.load`).
559
567
  print(f"tcw: {error}", file=sys.stderr)
560
568
  return 1
561
569
  except subprocess.CalledProcessError as error:
562
570
  # 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.
571
+ # failed", which is true of every component. The index-touching calls
572
+ # capture git's output (so a retried failure does not print once per
573
+ # try), and it is printed here; an uncaptured call has already shown it.
566
574
  # `cmd` is a sequence *or* a string (stdlib contract); joining a string
567
575
  # iterates its characters. No shipped raiser passes one, but a handler
568
576
  # whose whole justification is that it assumes nothing about its caller
@@ -571,6 +579,8 @@ def main(argv: list[str] | None = None) -> int:
571
579
  shlex.join(str(a) for a in error.cmd)
572
580
  print(f"tcw: git command failed (exit {error.returncode}): {cmd}",
573
581
  file=sys.stderr)
582
+ if isinstance(error.stderr, str) and error.stderr.strip():
583
+ print(error.stderr.rstrip("\n"), file=sys.stderr)
574
584
  return 1
575
585
 
576
586
 
@@ -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
@@ -20,7 +20,8 @@ from urllib.parse import unquote, urlparse
20
20
  from tcw.work.projection import work_item_json
21
21
  from tcw.store.base import (
22
22
  CAP_FIELDS, CAP_STATUSES, WORK_ARTIFACTS, WORK_SIDECARS, _UNSET,
23
- IllegalTransition, PublicationError, RefError, StaleRevision, TransitionCommitError,
23
+ IllegalTransition, MultipleMatch, PublicationError, RefError, StaleRevision,
24
+ TransitionCommitError,
24
25
  )
25
26
  from tcw.store.fs import (
26
27
  FsCapabilitiesStore, FsTaxonomyStore, FsWorkStore, descendant_nodes,
@@ -300,6 +301,9 @@ def _map_store_error(e: Exception) -> tuple[int, bytes]:
300
301
  # because strict-tracker and generated-sidecar refusals are 409 too and
301
302
  # must show their own message instead.
302
303
  return _err(HTTPStatus.CONFLICT, str(e), code="stale-revision")
304
+ if isinstance(e, MultipleMatch):
305
+ # Two folders hold the slug: the data needs fixing, not the request.
306
+ return _err(HTTPStatus.CONFLICT, str(e))
303
307
  if isinstance(e, IllegalTransition):
304
308
  return _err(HTTPStatus.UNPROCESSABLE_ENTITY, str(e))
305
309
  if isinstance(e, RefError):
@@ -551,8 +555,17 @@ class TcwHandler(BaseHTTPRequestHandler):
551
555
  for root, prefix in self._board_roots():
552
556
  work = FsWorkStore.open(root)
553
557
  for it in work.board():
554
- items.append(_item_payload(work, it.slug, it,
555
- f"{prefix}{it.slug}" if prefix else None))
558
+ try:
559
+ items.append(_item_payload(work, it.slug, it,
560
+ f"{prefix}{it.slug}" if prefix else None))
561
+ except MultipleMatch:
562
+ # Two folders hold the slug, so its artifacts cannot be
563
+ # read. Listed with none, so one duplicate does not take the
564
+ # board down; the item's own route says why.
565
+ data = work_item_json(it, [])
566
+ if prefix:
567
+ data["slug"] = f"{prefix}{it.slug}"
568
+ items.append(data)
556
569
  return items
557
570
 
558
571
  def _board_roots(self) -> list[tuple[Path, str]]:
@@ -580,6 +593,8 @@ class TcwHandler(BaseHTTPRequestHandler):
580
593
  return
581
594
  try:
582
595
  self._get()
596
+ except MultipleMatch as e:
597
+ self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
583
598
  except Exception as e:
584
599
  self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
585
600
 
@@ -588,6 +603,8 @@ class TcwHandler(BaseHTTPRequestHandler):
588
603
  return
589
604
  try:
590
605
  self._post()
606
+ except MultipleMatch as e:
607
+ self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
591
608
  except Exception as e:
592
609
  self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
593
610
 
@@ -596,6 +613,8 @@ class TcwHandler(BaseHTTPRequestHandler):
596
613
  return
597
614
  try:
598
615
  self._patch()
616
+ except MultipleMatch as e:
617
+ self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
599
618
  except Exception as e:
600
619
  self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
601
620
 
@@ -604,6 +623,8 @@ class TcwHandler(BaseHTTPRequestHandler):
604
623
  return
605
624
  try:
606
625
  self._put()
626
+ except MultipleMatch as e:
627
+ self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
607
628
  except Exception as e:
608
629
  self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
609
630
 
@@ -612,6 +633,8 @@ class TcwHandler(BaseHTTPRequestHandler):
612
633
  return
613
634
  try:
614
635
  self._delete()
636
+ except MultipleMatch as e:
637
+ self._send(HTTPStatus.CONFLICT, str(e).encode("utf-8"))
615
638
  except Exception as e:
616
639
  self._send(HTTPStatus.INTERNAL_SERVER_ERROR, str(e).encode("utf-8"))
617
640
 
@@ -967,6 +990,7 @@ class TcwHandler(BaseHTTPRequestHandler):
967
990
  initiative = body.get("initiative", "")
968
991
  type_val = body.get("type", "")
969
992
  tags = body.get("tags") or None
993
+ work.refresh_for_creation()
970
994
  detail = work.create_work(
971
995
  title=title,
972
996
  created=created,
@@ -987,6 +1011,14 @@ class TcwHandler(BaseHTTPRequestHandler):
987
1011
  # stale — which the next sidecar write from the page would
988
1012
  # be rejected on.
989
1013
  detail = work.get_detail(detail.item.slug) or detail
1014
+ # After the owed record, so it is in the same commit. A refusal
1015
+ # is logged, not returned: the item exists, and an error here
1016
+ # would invite the browser to create it a second time.
1017
+ folder = work.path(detail.item.slug)
1018
+ if folder is not None and (reason := work.commit_writes(
1019
+ f"tcw work: new {detail.item.slug}", folder)):
1020
+ print(f"tcw serve: created {detail.item.slug}, but {reason}",
1021
+ file=sys.stderr)
990
1022
  response = {
991
1023
  "item": _item_payload(work, detail.item.slug, detail.item),
992
1024
  "coreRevision": detail.core_revision,