tcw-cli 2.8.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.8.0 → tcw_cli-2.8.1}/PKG-INFO +2 -2
  2. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/README.md +1 -1
  3. {tcw_cli-2.8.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.8.0 → tcw_cli-2.8.1}/tcw/cli.py +9 -3
  6. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/serve/__init__.py +26 -3
  7. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/store/base.py +14 -3
  8. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/store/config_edit.py +4 -3
  9. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/store/fs.py +148 -24
  10. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/store/project.py +3 -1
  11. tcw_cli-2.8.1/tcw/store/yaml_source.py +25 -0
  12. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/validate.py +2 -1
  13. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/cli.py +53 -5
  14. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/PKG-INFO +2 -2
  15. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/SOURCES.txt +5 -0
  16. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_already_integrated_check.py +142 -0
  17. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_blocker_cycles_through_unreadable_items.py +1 -1
  18. tcw_cli-2.8.1/tests/test_damaged_state_on_edit.py +125 -0
  19. tcw_cli-2.8.1/tests/test_detached_worktree_teardown.py +145 -0
  20. tcw_cli-2.8.1/tests/test_duplicate_slug_refusals.py +139 -0
  21. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_retention.py +3 -1
  22. tcw_cli-2.8.1/tests/test_serve_duplicate_slug.py +47 -0
  23. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_unreadable_capabilities_sidecar.py +2 -1
  24. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_work.py +3 -1
  25. tcw_cli-2.8.0/tcw/__init__.py +0 -1
  26. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/LICENSE +0 -0
  27. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/setup.cfg +0 -0
  28. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/capabilities/__init__.py +0 -0
  29. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/capabilities/cli.py +0 -0
  30. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/cli_suggest.py +0 -0
  31. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/harness.py +0 -0
  32. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/refs.py +0 -0
  33. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
  34. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
  35. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/serve/dist/client/index.html +0 -0
  36. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/serve/dist/client/theme-init.js +0 -0
  37. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/serve/dist/server.cjs +0 -0
  38. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/serve/runtime.py +0 -0
  39. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/stdin.py +0 -0
  40. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/store/__init__.py +0 -0
  41. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/store/checkouts.py +0 -0
  42. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/taxonomy/__init__.py +0 -0
  43. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/taxonomy/cli.py +0 -0
  44. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/tracker/__init__.py +0 -0
  45. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/tracker/claim.py +0 -0
  46. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/tracker/create.py +0 -0
  47. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/tracker/intake.py +0 -0
  48. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/tracker/jira.py +0 -0
  49. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/tracker/ownership.py +0 -0
  50. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/tracker/progress.py +0 -0
  51. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/tracker/sync.py +0 -0
  52. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/__init__.py +0 -0
  53. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/generate.py +0 -0
  54. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/hooks.py +0 -0
  55. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/audit-backlog.md +0 -0
  56. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/consolidate-plans.md +0 -0
  57. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/create-work.md +0 -0
  58. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/decompose.md +0 -0
  59. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/delegation.md +0 -0
  60. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/documentation-sync.md +0 -0
  61. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/post-mortem.md +0 -0
  62. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/search.md +0 -0
  63. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/triage-issues.md +0 -0
  64. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/procedures/unattended-work.md +0 -0
  65. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/projection.py +0 -0
  66. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/prompts/implement.md +0 -0
  67. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/prompts/inbox.md +0 -0
  68. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/prompts/plan.md +0 -0
  69. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/prompts/postmortem.md +0 -0
  70. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/prompts/request.md +0 -0
  71. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/prompts/spec.md +0 -0
  72. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/prompts/verify.md +0 -0
  73. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/recursion.py +0 -0
  74. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/resolve.py +0 -0
  75. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw/work/templates.py +0 -0
  76. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/dependency_links.txt +0 -0
  77. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/entry_points.txt +0 -0
  78. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/requires.txt +0 -0
  79. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tcw_cli.egg-info/top_level.txt +0 -0
  80. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_boardless_delegate_and_slices.py +0 -0
  81. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_body_prompt.py +0 -0
  82. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_capabilities.py +0 -0
  83. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_capabilities_federation.py +0 -0
  84. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_capabilities_reset.py +0 -0
  85. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_capabilities_rm.py +0 -0
  86. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_capabilities_sidecar.py +0 -0
  87. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_capability_gate_children.py +0 -0
  88. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_capability_ref_wording.py +0 -0
  89. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_check_versions.py +0 -0
  90. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_child_status.py +0 -0
  91. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_cli_help_coverage.py +0 -0
  92. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_command_suggestions.py +0 -0
  93. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_committed_paths.py +0 -0
  94. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_config_edit.py +0 -0
  95. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_config_edit_writers.py +0 -0
  96. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_configuration_text_home.py +0 -0
  97. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_creation_commits.py +0 -0
  98. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_cross_node_blocker_cycles.py +0 -0
  99. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_cross_node_blockers.py +0 -0
  100. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_cut_version.py +0 -0
  101. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_documentation_config.py +0 -0
  102. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_documentation_prompt.py +0 -0
  103. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_documentation_sync_wiring.py +0 -0
  104. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_documented_cli_surface.py +0 -0
  105. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_dynamic_skill_marker.py +0 -0
  106. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_edit_refusal.py +0 -0
  107. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_edit_type.py +0 -0
  108. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_environment_hardness.py +0 -0
  109. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_epic_completability_across_checkouts.py +0 -0
  110. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_epic_completable.py +0 -0
  111. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_eval_coverage.py +0 -0
  112. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_eval_files_changed.py +0 -0
  113. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_eval_fixture.py +0 -0
  114. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_eval_grading.py +0 -0
  115. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_eval_runner.py +0 -0
  116. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_external_work_store.py +0 -0
  117. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_falsification_rule.py +0 -0
  118. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_generate_hook.py +0 -0
  119. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_harness.py +0 -0
  120. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_inbox_title.py +0 -0
  121. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_init_configured_tree_path.py +0 -0
  122. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_initiative_node_qualified.py +0 -0
  123. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_interrupted_claim.py +0 -0
  124. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_item_tags_read.py +0 -0
  125. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_legacy_store_config.py +0 -0
  126. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_lifecycle_baseline.py +0 -0
  127. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_lifecycle_config_tags.py +0 -0
  128. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_lifecycle_hooks.py +0 -0
  129. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_lifecycle_inert.py +0 -0
  130. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_lifecycle_policy.py +0 -0
  131. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_lifecycle_validation.py +0 -0
  132. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_literal_store_names.py +0 -0
  133. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_local_qualified_blockers.py +0 -0
  134. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_multiproject.py +0 -0
  135. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_non_git_writes.py +0 -0
  136. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_override_in_linked_worktree.py +0 -0
  137. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_override_letter_case.py +0 -0
  138. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_override_references.py +0 -0
  139. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_planning_gates_after_start.py +0 -0
  140. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_plugin_manifests.py +0 -0
  141. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_procedure_config.py +0 -0
  142. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_procedure_verb.py +0 -0
  143. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_project_graph_paths.py +0 -0
  144. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_project_overrides.py +0 -0
  145. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_project_registry.py +0 -0
  146. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_projection.py +0 -0
  147. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_prompt_fallback.py +0 -0
  148. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_qualified_ref.py +0 -0
  149. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_recursion.py +0 -0
  150. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_refs.py +0 -0
  151. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_remote_session_setup.py +0 -0
  152. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_rename_work_item.py +0 -0
  153. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_repo_lifecycle.py +0 -0
  154. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_resolve.py +0 -0
  155. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_resolve_procedure.py +0 -0
  156. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_routing_nodes.py +0 -0
  157. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_scaffold.py +0 -0
  158. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_serve.py +0 -0
  159. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_serve_descendants.py +0 -0
  160. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_serve_projection.py +0 -0
  161. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_serve_resolve.py +0 -0
  162. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_serve_runtime.py +0 -0
  163. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_serve_write.py +0 -0
  164. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_session_bootstrap.py +0 -0
  165. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_shipped_procedures.py +0 -0
  166. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_shipped_prompts.py +0 -0
  167. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_show_json.py +0 -0
  168. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_sidecar_paths_in_validate.py +0 -0
  169. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_skill_flow.py +0 -0
  170. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_skill_lifecycle_parity.py +0 -0
  171. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_skill_path_pointers.py +0 -0
  172. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_smoke.py +0 -0
  173. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_stage_validate.py +0 -0
  174. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_stage_verb.py +0 -0
  175. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_stale_item_path.py +0 -0
  176. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_status_parity.py +0 -0
  177. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_stdin.py +0 -0
  178. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_stdin_cli.py +0 -0
  179. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_store_bounds.py +0 -0
  180. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_store_editor.py +0 -0
  181. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_store_lock.py +0 -0
  182. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_store_nodes.py +0 -0
  183. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_store_provisioning.py +0 -0
  184. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_store_publication.py +0 -0
  185. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_subprocess_stdin.py +0 -0
  186. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_taxonomy.py +0 -0
  187. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_taxonomy_rm_gaps.py +0 -0
  188. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tombstone.py +0 -0
  189. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_absent.py +0 -0
  190. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_binding.py +0 -0
  191. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_catch_up_moves.py +0 -0
  192. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_claim.py +0 -0
  193. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_claimability.py +0 -0
  194. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_cli.py +0 -0
  195. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_client.py +0 -0
  196. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_comment.py +0 -0
  197. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_config.py +0 -0
  198. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_create.py +0 -0
  199. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_gate.py +0 -0
  200. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_hardening.py +0 -0
  201. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_help.py +0 -0
  202. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_hold.py +0 -0
  203. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_import.py +0 -0
  204. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_import_returns.py +0 -0
  205. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_inheritance.py +0 -0
  206. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_link.py +0 -0
  207. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_message_tidy.py +0 -0
  208. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_ownership.py +0 -0
  209. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_pre_backlog.py +0 -0
  210. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_replay.py +0 -0
  211. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_strict.py +0 -0
  212. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_strict_gate.py +0 -0
  213. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_surface.py +0 -0
  214. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_sync.py +0 -0
  215. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_sync_config.py +0 -0
  216. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_tracker_validate.py +0 -0
  217. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_transition_hints.py +0 -0
  218. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_unattended_work_skill.py +0 -0
  219. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_unplanned_start.py +0 -0
  220. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_unpushed_version_script.py +0 -0
  221. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_unreadable_item_files.py +0 -0
  222. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_unreadable_state_gates.py +0 -0
  223. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_untag_invalid_tags.py +0 -0
  224. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_upstream_projects.py +0 -0
  225. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_validate.py +0 -0
  226. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_validate_moved_stores.py +0 -0
  227. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_validate_target.py +0 -0
  228. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_work_autocommit.py +0 -0
  229. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_work_review.py +0 -0
  230. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_work_start.py +0 -0
  231. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_work_tags.py +0 -0
  232. {tcw_cli-2.8.0 → tcw_cli-2.8.1}/tests/test_worktree_completion.py +0 -0
  233. {tcw_cli-2.8.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.8.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
@@ -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; with `--already-integrated`, the branch is not merged into `HEAD`; 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 |
@@ -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; with `--already-integrated`, the branch is not merged into `HEAD`; 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.8.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"
@@ -19,7 +19,9 @@ from tcw.store.fs import (
19
19
  FsWorkStore, declared_repository, find_node_root, git_root, init,
20
20
  provisioned_store_root,
21
21
  )
22
+ from tcw.store.base import MultipleMatch
22
23
  from tcw.store.project import FsProjectRegistry
24
+ from tcw.store.yaml_source import load as _load_named_yaml
23
25
  import yaml
24
26
  from tcw.taxonomy import cli as taxonomy_cli
25
27
  from tcw.work import cli as work_cli
@@ -56,7 +58,8 @@ def run_init(components: list[str], project_id: str | None = None,
56
58
  sentinel = root / SENTINEL
57
59
  if project_id is None:
58
60
  try:
59
- 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 {})
60
63
  except yaml.YAMLError as error:
61
64
  print(f"tcw init: invalid {SENTINEL}: {error}", file=sys.stderr)
62
65
  return 1
@@ -548,7 +551,10 @@ def main(argv: list[str] | None = None) -> int:
548
551
  args = build_parser().parse_args(_normalize(argv))
549
552
  try:
550
553
  return args.func(args)
551
- 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.
552
558
  print(f"tcw: {error}", file=sys.stderr)
553
559
  return 1
554
560
  except yaml.YAMLError as error:
@@ -557,7 +563,7 @@ def main(argv: list[str] | None = None) -> int:
557
563
  # twenty-odd `load_yaml` calls that do not catch it: refusing is already
558
564
  # the right answer at every one of them — `init` must not overwrite a
559
565
  # config it cannot read — and only the presentation was wrong. The
560
- # loader's message names the file.
566
+ # loader's message names the file (`tcw.store.yaml_source.load`).
561
567
  print(f"tcw: {error}", file=sys.stderr)
562
568
  return 1
563
569
  except subprocess.CalledProcessError as error:
@@ -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
 
@@ -4339,7 +4339,9 @@ class WorkStore(ABC):
4339
4339
  remedies.append("Fix or replace each damaged state.yaml "
4340
4340
  "(`tcw validate` lists them)")
4341
4341
  if any(reason == _HELD_TWICE for _, reason in found):
4342
- remedies.append("remove the extra folder of each slug held twice")
4342
+ remedies.append("keep one folder of each slug held twice, merge any "
4343
+ "files it lacks from the others, and remove them "
4344
+ "(`tcw validate` names both folders)")
4343
4345
  remedy = "; ".join(remedies)
4344
4346
  many = len(blockers) > 1
4345
4347
  raise self._unreadable_refusal(
@@ -4449,8 +4451,12 @@ class WorkStore(ABC):
4449
4451
  holds its epic open). Built on `initiative_children` (cross-node in
4450
4452
  adapters that override it), so the "all resolved" signal and the
4451
4453
  `complete` gate share one source of truth. An empty epic is not
4452
- completable (nothing resolved)."""
4453
- if not self.epic_children_all_resolved(item):
4454
+ completable (nothing resolved). Nor is one whose slug two folders hold:
4455
+ which of them would close cannot be known."""
4456
+ try:
4457
+ if not self.epic_children_all_resolved(item):
4458
+ return False
4459
+ except MultipleMatch:
4454
4460
  return False
4455
4461
  # `complete` refuses while anything beneath the epic by `parent` is open,
4456
4462
  # so calling it ready then would promise a close that cannot happen —
@@ -4557,6 +4563,11 @@ class WorkStore(ABC):
4557
4563
  # reference the rename could not rewrite would unblock.
4558
4564
  if blocker is None and (renamed := self.renamed(b["slug"])):
4559
4565
  blocker = self.get(renamed)
4566
+ except MultipleMatch:
4567
+ # Still a blocker, and said why, since nothing else in this
4568
+ # item's refusal would explain it.
4569
+ out.append(f"{b['slug']} (held by more than one folder)")
4570
+ continue
4560
4571
  except ValueError:
4561
4572
  # An adapter can refuse to settle a blocker — a claim on it
4562
4573
  # was abandoned. That is still a blocker, and reporting it as
@@ -40,6 +40,7 @@ from dataclasses import dataclass
40
40
  from pathlib import Path
41
41
 
42
42
  import yaml
43
+ from tcw.store.yaml_source import load as _load_named_yaml
43
44
 
44
45
 
45
46
  class ConfigEditRefused(ValueError):
@@ -131,7 +132,7 @@ def edit_text(path: Path, text: str | None, edits: list[Edit]) -> str | None:
131
132
  if not target:
132
133
  return None
133
134
  return yaml.safe_dump(target, sort_keys=False, allow_unicode=True)
134
- mapping = _load(text)
135
+ mapping = _load(text, path)
135
136
  if not isinstance(mapping, dict):
136
137
  mapping = {}
137
138
  target = intended(mapping, edits)
@@ -167,9 +168,9 @@ def edit_text(path: Path, text: str | None, edits: list[Edit]) -> str | None:
167
168
 
168
169
  # ── reading ─────────────────────────────────────────────────────────────────
169
170
 
170
- def _load(text: str):
171
+ def _load(text: str, path: Path | str = "the edited text"):
171
172
  from tcw.store.fs import _UniqueKeyLoader # fs.py imports this module
172
- return yaml.load(text, Loader=_UniqueKeyLoader)
173
+ return _load_named_yaml(text, path, _UniqueKeyLoader)
173
174
 
174
175
 
175
176
  def _assemble(text: str, replacements: list[_Replacement]) -> str:
@@ -37,6 +37,7 @@ except ImportError: # not POSIX
37
37
 
38
38
  import yaml
39
39
 
40
+ from tcw.store.yaml_source import load as _load_named_yaml
40
41
  from tcw.store.base import (
41
42
  _HELD_TWICE, BODY_ORDER, CAP_FIELDS, CAP_LIFECYCLES, CAP_PRIORITIES, CAP_STATUSES,
42
43
  DEFAULT_DOD, InboxEntryNotFound,
@@ -1134,7 +1135,21 @@ def branch_integration(node_root: Path, branch: str) -> str | None:
1134
1135
  Fails closed. A `git` without `merge-tree --write-tree` (older than 2.38)
1135
1136
  exits non-zero, which reads as "not shown to be merged", the same as a
1136
1137
  conflict does. Never run against the store's repository: the branch belongs
1137
- to the code the node's checkout holds."""
1138
+ to the code the node's checkout holds.
1139
+
1140
+ **A repository's own merge drivers do not get a say.** `merge-tree` runs
1141
+ them, and one configured to keep `HEAD`'s side (`merge.<name>.driver=true`
1142
+ under a `merge=<name>` attribute, from any attributes file) hides every
1143
+ change the branch made to a file both sides touched — the trial then
1144
+ produces `HEAD`'s tree and a branch that was never merged would be deleted.
1145
+ Every configured driver is replaced by `false` for the trial, so any file one
1146
+ would decide becomes a conflict. That refusal rests on the **exit code**, not
1147
+ the tree: git keeps `HEAD`'s side when the driver fails, so the tree still
1148
+ matches — comparing trees alone would reopen this. The cost: a squash merge
1149
+ touching such a file on both sides is not confirmed, and the way out is the
1150
+ one every refusal names. Passed with `-c`, last on the command line, because
1151
+ that beats an inherited `GIT_CONFIG_PARAMETERS` — which git exports to hooks
1152
+ run under `git -c` — where the `GIT_CONFIG_COUNT` variables do not."""
1138
1153
  if git_root(node_root) is None:
1139
1154
  return (f"the primary checkout at {node_root} is not in a git repository, "
1140
1155
  f"so {branch} cannot be checked")
@@ -1159,7 +1174,16 @@ def branch_integration(node_root: Path, branch: str) -> str | None:
1159
1174
  ref = f"refs/heads/{branch}"
1160
1175
  if run("merge-base", "--is-ancestor", ref, "HEAD").returncode == 0:
1161
1176
  return None
1162
- merged = run("merge-tree", "--write-tree", "HEAD", ref)
1177
+ drivers, unusable = _merge_drivers(node_root)
1178
+ if unusable:
1179
+ return (f"{branch} cannot be checked against HEAD {head} ({current}): "
1180
+ f"{unusable}. Pull the merge into this checkout, or merge {branch}, "
1181
+ f"then re-run")
1182
+ # `union` too, configured or not: the built-in keeps both sides' lines, so a
1183
+ # line the branch deleted and `HEAD` changed comes back as `HEAD`'s.
1184
+ overrides = [a for name in dict.fromkeys([*drivers, "union"])
1185
+ for a in ("-c", f"merge.{name}.driver=false")]
1186
+ merged = run(*overrides, "merge-tree", "--write-tree", "HEAD", ref)
1163
1187
  tree = run("rev-parse", "HEAD^{tree}").stdout.strip()
1164
1188
  if merged.returncode == 0 and merged.stdout.split("\n", 1)[0].strip() == tree:
1165
1189
  return None
@@ -1169,17 +1193,105 @@ def branch_integration(node_root: Path, branch: str) -> str | None:
1169
1193
  detail = (merged.stderr.strip().splitlines() or ["no output"])[0]
1170
1194
  return (f"git could not check whether {branch} is merged into HEAD {head} "
1171
1195
  f"({detail}); `git merge-tree --write-tree` needs git 2.38 or newer")
1196
+ because = (f" This repository defines its own merge drivers "
1197
+ f"({', '.join(drivers)}), which this check does not let decide a "
1198
+ f"file (nor git's `union`), so a squash merge touching a file one "
1199
+ f"governs is not confirmed." if drivers else "")
1172
1200
  return (f"{branch} is not merged into this checkout's HEAD {head} ({current}): "
1173
- f"merging it would still change files. Pull the merge into this "
1174
- f"checkout, or merge {branch}, then re-run")
1201
+ f"merging it would still change files.{because} Pull the merge into "
1202
+ f"this checkout, or merge {branch}, then re-run")
1203
+
1204
+
1205
+ def _merge_drivers(node_root: Path) -> tuple[list[str], str | None]:
1206
+ """The names of the custom merge drivers configured for the node's
1207
+ repository — every scope, includes and the environment's too — and a reason
1208
+ the trial merge cannot be trusted, or None.
1209
+
1210
+ A name holding `=` cannot be written as `-c merge.<name>.driver=false`, and
1211
+ git failing to list them at all (a malformed config) leaves unknown what
1212
+ would run; both fail closed."""
1213
+ r = _git(["git", "-C", str(node_root), "config", "--null", "--name-only",
1214
+ "--get-regexp", r"^merge\..+\.driver$"], capture_output=True, text=True)
1215
+ if r.returncode == 1:
1216
+ return [], None # none configured
1217
+ if r.returncode != 0:
1218
+ detail = (r.stderr.strip().splitlines() or ["no output"])[0]
1219
+ return [], f"git could not list the repository's merge drivers ({detail})"
1220
+ names = list(dict.fromkeys(key[len("merge."):-len(".driver")]
1221
+ for key in r.stdout.split("\0") if key))
1222
+ if odd := [n for n in names if "=" in n]:
1223
+ return names, (f"it configures a merge driver whose name holds '=' "
1224
+ f"({', '.join(odd)}), which this check cannot switch off")
1225
+ return names, None
1226
+
1227
+
1228
+ UNBRANCHED_SHOWN = 10
1229
+
1230
+
1231
+ def unbranched_commits(worktree: Path) -> "list[str] | str":
1232
+ """Short hashes of the commits removing `worktree` would lose — reachable
1233
+ from its `HEAD` and from no branch, tag or remote-tracking branch — or a
1234
+ reason when git cannot say. `[]` when there is no such folder.
1235
+
1236
+ Only a detached `HEAD` can hold them: a worktree on a branch is covered by
1237
+ `--branches`. `--all` would not do, since it counts every worktree's `HEAD`,
1238
+ this one's included. A stash is deliberately not counted as keeping a commit:
1239
+ it is a scratch area people clear without thinking of it as a save.
1240
+
1241
+ `git worktree remove` also deletes the worktree's own reflog, so these
1242
+ commits then survive only as unreachable objects until garbage collection.
1243
+
1244
+ Checked to be the worktree's own top first: `.worktrees/` sits inside the
1245
+ primary checkout, so `git -C` on a plain folder there answers silently for
1246
+ the *primary checkout* — naming its commits as this folder's."""
1247
+ if not worktree.is_dir():
1248
+ return []
1249
+ run = lambda *a: _git(["git", "-C", str(worktree), *a], # noqa: E731
1250
+ capture_output=True, text=True)
1251
+ top = run("rev-parse", "--show-toplevel")
1252
+ # By folder identity, not text: git spells the path as the disk does, and a
1253
+ # project reached through an override in other letter case spells it its way.
1254
+ if top.returncode != 0 or not top.stdout.strip() or \
1255
+ not _same_folder(Path(top.stdout.strip()), worktree):
1256
+ return f"git could not check {worktree}: it is not a git worktree of its own"
1257
+ r = run("rev-list", "--abbrev-commit", "HEAD",
1258
+ "--not", "--branches", "--tags", "--remotes")
1259
+ if r.returncode != 0:
1260
+ detail = (r.stderr.strip().splitlines() or ["no output"])[0]
1261
+ return f"git could not check {worktree} ({detail})"
1262
+ return r.stdout.split()
1263
+
1264
+
1265
+ def unbranched_summary(commits: list[str]) -> str:
1266
+ """`commits` as a refusal names them: at most `UNBRANCHED_SHOWN`."""
1267
+ shown = ", ".join(commits[:UNBRANCHED_SHOWN])
1268
+ more = len(commits) - UNBRANCHED_SHOWN
1269
+ return shown + (f" and {more} more" if more > 0 else "")
1270
+
1271
+
1272
+ def unbranched_problem(worktree: Path) -> str | None:
1273
+ """Why `worktree` must not be removed, or None when nothing would be lost."""
1274
+ found = unbranched_commits(worktree)
1275
+ if isinstance(found, str):
1276
+ return f"{found}, so it was not removed"
1277
+ if not found:
1278
+ return None
1279
+ return (f"the worktree at {worktree} has commits on no branch — "
1280
+ f"{unbranched_summary(found)} — and removing it would lose them. Save "
1281
+ f"them with `git -C {worktree} branch <name>`, then remove it with "
1282
+ f"`git worktree remove {worktree}`")
1175
1283
 
1176
1284
 
1177
1285
  def remove_worktree(node_root: Path, slug: str, branch: str | None = None) -> list[str]:
1178
1286
  """Best-effort teardown (Spec 2 §3.4): `git worktree remove` refuses on a
1179
- dirty worktree — the safety net against losing uncommitted work. Returns
1180
- warnings (empty == clean)."""
1287
+ dirty worktree — the safety net against losing uncommitted work — and this
1288
+ refuses one whose detached `HEAD` holds commits no ref does, which git
1289
+ would remove. Returns warnings (empty == clean); a refusal keeps the branch
1290
+ as well as the worktree."""
1181
1291
  warns: list[str] = []
1182
1292
  wt = node_root / WORKTREES_DIR / slug
1293
+ if problem := unbranched_problem(wt):
1294
+ return [problem]
1183
1295
  r = _git(["git", "-C", str(node_root), "worktree", "remove", str(wt)],
1184
1296
  capture_output=True, text=True)
1185
1297
  if r.returncode != 0:
@@ -1547,7 +1659,8 @@ def load_yaml(path: Path, unique: bool = False) -> dict:
1547
1659
  if not path.exists():
1548
1660
  return {}
1549
1661
  text = path.read_text(encoding="utf-8")
1550
- data = yaml.load(text, Loader=_UniqueKeyLoader if unique else yaml.SafeLoader)
1662
+ data = _load_named_yaml(text, path,
1663
+ _UniqueKeyLoader if unique else yaml.SafeLoader)
1551
1664
  if data is None: # absent content: empty file, or `null`
1552
1665
  return {}
1553
1666
  if not isinstance(data, dict):
@@ -4946,7 +5059,11 @@ class FsWorkStore(FsTreeStore, WorkStore):
4946
5059
  matches = [d for d in self._item_dirs() if d.name == slug]
4947
5060
  if len(matches) <= 1:
4948
5061
  return matches[0] if matches else None
4949
- raise MultipleMatch(f"slug resolves to {len(matches)} items: {slug}")
5062
+ raise MultipleMatch(
5063
+ f"{slug} is held by {len(matches)} folders — "
5064
+ f"{', '.join(self._shown_path(d) for d in sorted(matches))}. Keep the one "
5065
+ f"whose state.yaml is right, merge any files it lacks from the others, "
5066
+ f"and remove them; `tcw validate` lists every slug held twice")
4950
5067
 
4951
5068
  def _require_dir(self, slug: str) -> Path:
4952
5069
  d = self._find(slug)
@@ -5260,21 +5377,25 @@ class FsWorkStore(FsTreeStore, WorkStore):
5260
5377
  return "it is not a regular file"
5261
5378
  return None
5262
5379
 
5263
- @staticmethod
5264
- def _require_readable_state(d: Path, slug: str) -> None:
5265
- """Refuse to move an item whose `state.yaml` cannot be read.
5380
+ def _require_readable_state(self, d: Path, slug: str) -> None:
5381
+ """Refuse to move or edit an item whose `state.yaml` cannot be read.
5266
5382
 
5267
5383
  The board reads it through `_safe_yaml`, which degrades a damaged file
5268
5384
  to defaults — no blockers, no owner, no type — so every gate a move
5269
5385
  checks would pass on values the item does not hold, and the strict read
5270
5386
  that follows the move would fail with the item already moved. Asked
5271
5387
  before anything moves, so the refusal means nothing happened. A folder
5272
- gone meanwhile is the caller's lost race to report, not damage."""
5273
- reason = FsWorkStore._state_damage(d / "state.yaml")
5388
+ gone meanwhile is the caller's lost race to report, not damage.
5389
+
5390
+ An edit asks too, before its own strict read: that read refuses as well,
5391
+ but with only the parser's text, which names neither item nor file."""
5392
+ state = d / "state.yaml"
5393
+ reason = FsWorkStore._state_damage(state)
5274
5394
  if reason is None:
5275
5395
  return
5276
- raise ValueError(f"{slug}: state.yaml cannot be read ({reason}); "
5277
- f"fix or replace it before changing the item")
5396
+ raise ValueError(f"{slug}: {self._shown_path(state)} cannot be read "
5397
+ f"({reason}); fix or replace it before changing the item "
5398
+ f"— `tcw validate` lists every item it cannot read")
5278
5399
 
5279
5400
  def _item_from_dir(self, d: Path) -> WorkItem | None:
5280
5401
  """`None` when the folder went away mid-read — a concurrent claim moved
@@ -5301,27 +5422,28 @@ class FsWorkStore(FsTreeStore, WorkStore):
5301
5422
  return None
5302
5423
  return item if (d / "state.yaml").exists() else None
5303
5424
 
5304
- @staticmethod
5305
- def _read_capabilities_sidecar(caps: Path) -> object:
5425
+ def _read_capabilities_sidecar(self, caps: Path) -> object:
5306
5426
  """The parsed sidecar, `None` if it went away, or the `_tcw_parse_error`
5307
5427
  value for anything that cannot be read: one item's bad file must list as
5308
5428
  that item's problem, never take down the board or hang a projection.
5309
5429
  `is_file()` comes first because no exception rescues a read that blocks
5310
- on a named pipe."""
5430
+ on a named pipe. Problems name the file from the project root, so a
5431
+ parse error says which item's sidecar it is."""
5432
+ shown = self._shown_path(caps)
5311
5433
  problem = None
5312
5434
  try:
5313
5435
  if not caps.is_file():
5314
- return ({"_tcw_parse_error": f"{caps.name} is not a regular file"}
5436
+ return ({"_tcw_parse_error": f"{shown} is not a regular file"}
5315
5437
  if caps.exists() else None)
5316
5438
  if caps.stat().st_size > SIDECAR_MAX_BYTES:
5317
- problem = f"{caps.name} is larger than {SIDECAR_MAX_BYTES} bytes"
5439
+ problem = f"{shown} is larger than {SIDECAR_MAX_BYTES} bytes"
5318
5440
  else:
5319
- parsed = yaml.safe_load(caps.read_text(encoding="utf-8"))
5441
+ parsed = _load_named_yaml(caps.read_text(encoding="utf-8"), shown)
5320
5442
  problem = sidecar_value_problem(parsed)
5321
5443
  except FileNotFoundError:
5322
5444
  return None
5323
5445
  except UnicodeDecodeError:
5324
- problem = f"{caps.name} is not valid UTF-8"
5446
+ problem = f"{shown} is not valid UTF-8"
5325
5447
  except (OSError, ValueError, yaml.YAMLError, RecursionError) as e:
5326
5448
  problem = str(e)
5327
5449
  if problem is not None:
@@ -6977,7 +7099,7 @@ class FsWorkStore(FsTreeStore, WorkStore):
6977
7099
  def dod_checklist(self) -> list[str]:
6978
7100
  p = self.root / "dod.yaml"
6979
7101
  if p.exists():
6980
- data = yaml.safe_load(p.read_text(encoding="utf-8"))
7102
+ data = _load_named_yaml(p.read_text(encoding="utf-8"), self._shown_path(p))
6981
7103
  if isinstance(data, list):
6982
7104
  return [str(x) for x in data]
6983
7105
  if isinstance(data, dict) and isinstance(data.get("checklist"), list):
@@ -8154,6 +8276,7 @@ class FsWorkStore(FsTreeStore, WorkStore):
8154
8276
  locations by a move landing between them.
8155
8277
  """
8156
8278
  self._require_repository()
8279
+ self._require_readable_state(d, d.name)
8157
8280
  state = load_yaml(d / "state.yaml")
8158
8281
  state.update(fields)
8159
8282
  self._write_staged([(d / "state.yaml",
@@ -8514,6 +8637,7 @@ class FsWorkStore(FsTreeStore, WorkStore):
8514
8637
  # Read current state. A body write always targets the request, never the
8515
8638
  # read fallback: following it would either mutate raw intake or quietly
8516
8639
  # satisfy the `request` stage with text the author meant as an edit.
8640
+ self._require_readable_state(d, slug)
8517
8641
  state = load_yaml(d / "state.yaml")
8518
8642
  body_path = d / "initial-request.md"
8519
8643
  had_request = self._present(body_path)
@@ -8791,7 +8915,7 @@ class FsWorkStore(FsTreeStore, WorkStore):
8791
8915
  validation = sc_info.get("validation")
8792
8916
  if validation == "yaml_mapping":
8793
8917
  try:
8794
- parsed = yaml.safe_load(content)
8918
+ parsed = _load_named_yaml(content, self._shown_path(p))
8795
8919
  if parsed is not None and not isinstance(parsed, dict):
8796
8920
  raise ValueError(
8797
8921
  f"sidecar '{name}' must be a YAML mapping, "
@@ -17,6 +17,7 @@ from tcw.store.base import (
17
17
  WORK_STATUSES, parse_connected_entry,
18
18
  )
19
19
  from tcw.store.checkouts import normalized_url, provisioned_root
20
+ from tcw.store.yaml_source import load as _load_named_yaml
20
21
 
21
22
  SENTINEL = "tcw-config.yaml"
22
23
  PROJECT_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
@@ -568,7 +569,8 @@ class FsProjectRegistry(ProjectRegistry):
568
569
  declaration)
569
570
  return None
570
571
  try:
571
- raw = yaml.load(path.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader) or {}
572
+ raw = _load_named_yaml(path.read_text(encoding="utf-8"), path,
573
+ _UniqueKeyLoader) or {}
572
574
  except (OSError, yaml.YAMLError) as error:
573
575
  self._problem(path, f"invalid YAML: {error}")
574
576
  return None
@@ -0,0 +1,25 @@
1
+ """Parse YAML text that came from a file, naming that file in any error."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+
10
+ def load(text: str, path: Path | str, loader: type = yaml.SafeLoader):
11
+ """`yaml.load(text, Loader=loader)`, with `path` as the name in its errors.
12
+
13
+ Handed a plain string, PyYAML calls it `"<unicode string>"` in every error
14
+ position, so a syntax error names no file. Handing it a named stream instead
15
+ fixes the name but drops the excerpt of the offending line and its `^` caret,
16
+ which PyYAML keeps only for a string. Naming the loader built from the string
17
+ keeps both. Two errors still name no file: a character YAML forbids is
18
+ refused while the loader is built, before it has a name, and a duplicate
19
+ key (`_UniqueKeyLoader`) is raised with no position at all."""
20
+ parser = loader(text)
21
+ parser.name = str(path)
22
+ try:
23
+ return parser.get_single_data()
24
+ finally:
25
+ parser.dispose()
@@ -26,6 +26,7 @@ from typing import Literal
26
26
  import yaml
27
27
 
28
28
  from tcw.refs import resolve_tcw_ref
29
+ from tcw.store.yaml_source import load as _load_named_yaml
29
30
  from tcw.store.base import StoreLocationUnusable
30
31
  from tcw.store.fs import (
31
32
  OWNED_YAML_NAMES, STORE_CLASSES, FsCapabilitiesStore, FsTaxonomyStore,
@@ -404,7 +405,7 @@ def validate(node_root: Path, path: Path | None = None, *,
404
405
  yaml_syntax_error = True
405
406
  continue
406
407
  try:
407
- data = yaml.load(text, Loader=_UniqueKeyLoader)
408
+ data = _load_named_yaml(text, f, _UniqueKeyLoader)
408
409
  except yaml.YAMLError as e:
409
410
  problems.append(f"{_rel(f, node_root)}: {e}")
410
411
  if isinstance(e, yaml.MarkedYAMLError): # real syntax error, not dup-key