tcw-cli 2.8.0__tar.gz → 2.8.2__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.2}/PKG-INFO +27 -6
  2. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/README.md +26 -5
  3. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/pyproject.toml +1 -1
  4. tcw_cli-2.8.2/tcw/__init__.py +1 -0
  5. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/cli.py +9 -3
  6. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/__init__.py +26 -3
  7. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/base.py +14 -3
  8. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/config_edit.py +4 -3
  9. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/fs.py +148 -24
  10. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/project.py +3 -1
  11. tcw_cli-2.8.2/tcw/store/yaml_source.py +25 -0
  12. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/validate.py +2 -1
  13. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/cli.py +53 -5
  14. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/PKG-INFO +27 -6
  15. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/SOURCES.txt +5 -0
  16. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_already_integrated_check.py +142 -0
  17. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_blocker_cycles_through_unreadable_items.py +1 -1
  18. tcw_cli-2.8.2/tests/test_damaged_state_on_edit.py +125 -0
  19. tcw_cli-2.8.2/tests/test_detached_worktree_teardown.py +145 -0
  20. tcw_cli-2.8.2/tests/test_duplicate_slug_refusals.py +139 -0
  21. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_retention.py +3 -1
  22. tcw_cli-2.8.2/tests/test_serve_duplicate_slug.py +47 -0
  23. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unreadable_capabilities_sidecar.py +2 -1
  24. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/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.2}/LICENSE +0 -0
  27. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/setup.cfg +0 -0
  28. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/capabilities/__init__.py +0 -0
  29. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/capabilities/cli.py +0 -0
  30. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/cli_suggest.py +0 -0
  31. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/harness.py +0 -0
  32. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/refs.py +0 -0
  33. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
  34. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
  35. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/client/index.html +0 -0
  36. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/client/theme-init.js +0 -0
  37. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/dist/server.cjs +0 -0
  38. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/serve/runtime.py +0 -0
  39. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/stdin.py +0 -0
  40. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/__init__.py +0 -0
  41. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/store/checkouts.py +0 -0
  42. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/taxonomy/__init__.py +0 -0
  43. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/taxonomy/cli.py +0 -0
  44. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/__init__.py +0 -0
  45. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/claim.py +0 -0
  46. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/create.py +0 -0
  47. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/intake.py +0 -0
  48. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/jira.py +0 -0
  49. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/ownership.py +0 -0
  50. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/progress.py +0 -0
  51. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/tracker/sync.py +0 -0
  52. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/__init__.py +0 -0
  53. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/generate.py +0 -0
  54. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/hooks.py +0 -0
  55. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/audit-backlog.md +0 -0
  56. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/consolidate-plans.md +0 -0
  57. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/create-work.md +0 -0
  58. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/decompose.md +0 -0
  59. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/delegation.md +0 -0
  60. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/documentation-sync.md +0 -0
  61. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/post-mortem.md +0 -0
  62. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/search.md +0 -0
  63. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/triage-issues.md +0 -0
  64. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/procedures/unattended-work.md +0 -0
  65. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/projection.py +0 -0
  66. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/implement.md +0 -0
  67. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/inbox.md +0 -0
  68. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/plan.md +0 -0
  69. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/postmortem.md +0 -0
  70. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/request.md +0 -0
  71. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/spec.md +0 -0
  72. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/prompts/verify.md +0 -0
  73. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/recursion.py +0 -0
  74. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/resolve.py +0 -0
  75. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw/work/templates.py +0 -0
  76. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/dependency_links.txt +0 -0
  77. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/entry_points.txt +0 -0
  78. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/requires.txt +0 -0
  79. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tcw_cli.egg-info/top_level.txt +0 -0
  80. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_boardless_delegate_and_slices.py +0 -0
  81. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_body_prompt.py +0 -0
  82. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities.py +0 -0
  83. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities_federation.py +0 -0
  84. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities_reset.py +0 -0
  85. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities_rm.py +0 -0
  86. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capabilities_sidecar.py +0 -0
  87. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capability_gate_children.py +0 -0
  88. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_capability_ref_wording.py +0 -0
  89. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_check_versions.py +0 -0
  90. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_child_status.py +0 -0
  91. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_cli_help_coverage.py +0 -0
  92. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_command_suggestions.py +0 -0
  93. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_committed_paths.py +0 -0
  94. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_config_edit.py +0 -0
  95. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_config_edit_writers.py +0 -0
  96. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_configuration_text_home.py +0 -0
  97. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_creation_commits.py +0 -0
  98. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_cross_node_blocker_cycles.py +0 -0
  99. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_cross_node_blockers.py +0 -0
  100. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_cut_version.py +0 -0
  101. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_documentation_config.py +0 -0
  102. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_documentation_prompt.py +0 -0
  103. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_documentation_sync_wiring.py +0 -0
  104. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_documented_cli_surface.py +0 -0
  105. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_dynamic_skill_marker.py +0 -0
  106. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_edit_refusal.py +0 -0
  107. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_edit_type.py +0 -0
  108. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_environment_hardness.py +0 -0
  109. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_epic_completability_across_checkouts.py +0 -0
  110. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_epic_completable.py +0 -0
  111. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_coverage.py +0 -0
  112. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_files_changed.py +0 -0
  113. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_fixture.py +0 -0
  114. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_grading.py +0 -0
  115. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_eval_runner.py +0 -0
  116. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_external_work_store.py +0 -0
  117. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_falsification_rule.py +0 -0
  118. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_generate_hook.py +0 -0
  119. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_harness.py +0 -0
  120. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_inbox_title.py +0 -0
  121. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_init_configured_tree_path.py +0 -0
  122. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_initiative_node_qualified.py +0 -0
  123. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_interrupted_claim.py +0 -0
  124. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_item_tags_read.py +0 -0
  125. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_legacy_store_config.py +0 -0
  126. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_baseline.py +0 -0
  127. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_config_tags.py +0 -0
  128. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_hooks.py +0 -0
  129. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_inert.py +0 -0
  130. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_policy.py +0 -0
  131. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_lifecycle_validation.py +0 -0
  132. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_literal_store_names.py +0 -0
  133. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_local_qualified_blockers.py +0 -0
  134. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_multiproject.py +0 -0
  135. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_non_git_writes.py +0 -0
  136. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_override_in_linked_worktree.py +0 -0
  137. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_override_letter_case.py +0 -0
  138. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_override_references.py +0 -0
  139. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_planning_gates_after_start.py +0 -0
  140. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_plugin_manifests.py +0 -0
  141. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_procedure_config.py +0 -0
  142. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_procedure_verb.py +0 -0
  143. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_project_graph_paths.py +0 -0
  144. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_project_overrides.py +0 -0
  145. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_project_registry.py +0 -0
  146. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_projection.py +0 -0
  147. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_prompt_fallback.py +0 -0
  148. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_qualified_ref.py +0 -0
  149. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_recursion.py +0 -0
  150. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_refs.py +0 -0
  151. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_remote_session_setup.py +0 -0
  152. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_rename_work_item.py +0 -0
  153. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_repo_lifecycle.py +0 -0
  154. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_resolve.py +0 -0
  155. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_resolve_procedure.py +0 -0
  156. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_routing_nodes.py +0 -0
  157. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_scaffold.py +0 -0
  158. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve.py +0 -0
  159. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_descendants.py +0 -0
  160. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_projection.py +0 -0
  161. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_resolve.py +0 -0
  162. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_runtime.py +0 -0
  163. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_serve_write.py +0 -0
  164. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_session_bootstrap.py +0 -0
  165. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_shipped_procedures.py +0 -0
  166. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_shipped_prompts.py +0 -0
  167. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_show_json.py +0 -0
  168. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_sidecar_paths_in_validate.py +0 -0
  169. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_skill_flow.py +0 -0
  170. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_skill_lifecycle_parity.py +0 -0
  171. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_skill_path_pointers.py +0 -0
  172. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_smoke.py +0 -0
  173. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stage_validate.py +0 -0
  174. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stage_verb.py +0 -0
  175. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stale_item_path.py +0 -0
  176. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_status_parity.py +0 -0
  177. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stdin.py +0 -0
  178. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_stdin_cli.py +0 -0
  179. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_bounds.py +0 -0
  180. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_editor.py +0 -0
  181. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_lock.py +0 -0
  182. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_nodes.py +0 -0
  183. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_provisioning.py +0 -0
  184. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_store_publication.py +0 -0
  185. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_subprocess_stdin.py +0 -0
  186. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_taxonomy.py +0 -0
  187. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_taxonomy_rm_gaps.py +0 -0
  188. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tombstone.py +0 -0
  189. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_absent.py +0 -0
  190. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_binding.py +0 -0
  191. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_catch_up_moves.py +0 -0
  192. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_claim.py +0 -0
  193. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_claimability.py +0 -0
  194. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_cli.py +0 -0
  195. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_client.py +0 -0
  196. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_comment.py +0 -0
  197. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_config.py +0 -0
  198. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_create.py +0 -0
  199. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_gate.py +0 -0
  200. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_hardening.py +0 -0
  201. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_help.py +0 -0
  202. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_hold.py +0 -0
  203. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_import.py +0 -0
  204. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_import_returns.py +0 -0
  205. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_inheritance.py +0 -0
  206. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_link.py +0 -0
  207. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_message_tidy.py +0 -0
  208. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_ownership.py +0 -0
  209. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_pre_backlog.py +0 -0
  210. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_replay.py +0 -0
  211. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_strict.py +0 -0
  212. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_strict_gate.py +0 -0
  213. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_surface.py +0 -0
  214. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_sync.py +0 -0
  215. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_sync_config.py +0 -0
  216. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_tracker_validate.py +0 -0
  217. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_transition_hints.py +0 -0
  218. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unattended_work_skill.py +0 -0
  219. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unplanned_start.py +0 -0
  220. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unpushed_version_script.py +0 -0
  221. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unreadable_item_files.py +0 -0
  222. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_unreadable_state_gates.py +0 -0
  223. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_untag_invalid_tags.py +0 -0
  224. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_upstream_projects.py +0 -0
  225. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_validate.py +0 -0
  226. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_validate_moved_stores.py +0 -0
  227. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_validate_target.py +0 -0
  228. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work_autocommit.py +0 -0
  229. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work_review.py +0 -0
  230. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work_start.py +0 -0
  231. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_work_tags.py +0 -0
  232. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/tests/test_worktree_completion.py +0 -0
  233. {tcw_cli-2.8.0 → tcw_cli-2.8.2}/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.2
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
@@ -218,8 +218,8 @@ Dynamic: license-file
218
218
 
219
219
  TCW keeps three things about a software project inside the repository, next to
220
220
  the code: what the project deals with, what a user can do with it, and what is
221
- being changed. One command-line tool, `tcw`, and an agent plugin for Claude Code
222
- and Codex keep all three in step with the code.
221
+ being changed. One command-line tool, `tcw`, and an agent plugin for Claude Code,
222
+ Codex, and Pi keep all three in step with the code.
223
223
 
224
224
  | Component | Answers |
225
225
  | ---------------- | ------------------------------------------------------- |
@@ -309,7 +309,28 @@ skill — it runs the same install script Claude runs automatically. Every tcw
309
309
  skill also asks the agent to run the plugin's version check once per session,
310
310
  which warns when the CLI and the skills come from different releases.
311
311
 
312
- The plugin ships the skills and read-only review agents described in
312
+ In **Pi**:
313
+
314
+ ```bash
315
+ pi install git:github.com/brocef/TCW
316
+ ```
317
+
318
+ Add `-l` to install for the current project instead of your personal settings.
319
+ For a development checkout, use `pi install /absolute/path/to/TCW`.
320
+ Start a new Pi session, then run `/skill:setup` to install or check the `tcw`
321
+ CLI. You can also install it yourself with `pipx install tcw-cli`.
322
+ Invoke skills with `/skill:work`, `/skill:capabilities`, or `/skill:taxonomy`.
323
+
324
+ Pi loads the same skills through the official `pi` declaration in `package.json`.
325
+ It does not run TCW's Claude session-start hook or shell injections; the skills
326
+ provide explicit commands for setup, version checks, and lifecycle instructions.
327
+ Claude's custom review agents are not installed as Pi agents. When delegation
328
+ is unavailable, run ordinary lifecycle stages in the main session; autonomous
329
+ work still requires the advisors named by that workflow.
330
+ Git installation also installs the repository's Node package dependencies,
331
+ which support the TCW web app; local-path installation uses the checkout directly.
332
+
333
+ The plugin ships the skills and Claude Code read-only review agents described in
313
334
  [Skills and Agents](#skills-and-agents).
314
335
 
315
336
  ### CLI
@@ -702,7 +723,7 @@ The transitions, and what each one checks before it moves an item:
702
723
  | `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
703
724
  | `submit` | `tcw work submit <slug>` | active → review | never |
704
725
  | `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` |
726
+ | `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
727
  | discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
707
728
  | `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
708
729
  | 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 |
@@ -940,7 +961,7 @@ resolve, what has to be true before an item completes. What a command cannot
940
961
  decide (what a request really asks for, whether a spec is good enough, whether
941
962
  the work is finished) is judgment, and the plugin's skills guide an agent through
942
963
  it. Skills name `tcw` commands and never reimplement them. Every entry point is a
943
- skill, so they work the same way under Claude Code and Codex.
964
+ skill, with the same CLI-backed lifecycle under Claude Code, Codex, and Pi.
944
965
 
945
966
  The skills for a single axis are listed in that axis's section above. These cut
946
967
  across the axes.
@@ -2,8 +2,8 @@
2
2
 
3
3
  TCW keeps three things about a software project inside the repository, next to
4
4
  the code: what the project deals with, what a user can do with it, and what is
5
- being changed. One command-line tool, `tcw`, and an agent plugin for Claude Code
6
- and Codex keep all three in step with the code.
5
+ being changed. One command-line tool, `tcw`, and an agent plugin for Claude Code,
6
+ Codex, and Pi keep all three in step with the code.
7
7
 
8
8
  | Component | Answers |
9
9
  | ---------------- | ------------------------------------------------------- |
@@ -93,7 +93,28 @@ skill — it runs the same install script Claude runs automatically. Every tcw
93
93
  skill also asks the agent to run the plugin's version check once per session,
94
94
  which warns when the CLI and the skills come from different releases.
95
95
 
96
- The plugin ships the skills and read-only review agents described in
96
+ In **Pi**:
97
+
98
+ ```bash
99
+ pi install git:github.com/brocef/TCW
100
+ ```
101
+
102
+ Add `-l` to install for the current project instead of your personal settings.
103
+ For a development checkout, use `pi install /absolute/path/to/TCW`.
104
+ Start a new Pi session, then run `/skill:setup` to install or check the `tcw`
105
+ CLI. You can also install it yourself with `pipx install tcw-cli`.
106
+ Invoke skills with `/skill:work`, `/skill:capabilities`, or `/skill:taxonomy`.
107
+
108
+ Pi loads the same skills through the official `pi` declaration in `package.json`.
109
+ It does not run TCW's Claude session-start hook or shell injections; the skills
110
+ provide explicit commands for setup, version checks, and lifecycle instructions.
111
+ Claude's custom review agents are not installed as Pi agents. When delegation
112
+ is unavailable, run ordinary lifecycle stages in the main session; autonomous
113
+ work still requires the advisors named by that workflow.
114
+ Git installation also installs the repository's Node package dependencies,
115
+ which support the TCW web app; local-path installation uses the checkout directly.
116
+
117
+ The plugin ships the skills and Claude Code read-only review agents described in
97
118
  [Skills and Agents](#skills-and-agents).
98
119
 
99
120
  ### CLI
@@ -486,7 +507,7 @@ The transitions, and what each one checks before it moves an item:
486
507
  | `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
487
508
  | `submit` | `tcw work submit <slug>` | active → review | never |
488
509
  | `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` |
510
+ | `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
511
  | discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
491
512
  | `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
492
513
  | 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 |
@@ -724,7 +745,7 @@ resolve, what has to be true before an item completes. What a command cannot
724
745
  decide (what a request really asks for, whether a spec is good enough, whether
725
746
  the work is finished) is judgment, and the plugin's skills guide an agent through
726
747
  it. Skills name `tcw` commands and never reimplement them. Every entry point is a
727
- skill, so they work the same way under Claude Code and Codex.
748
+ skill, with the same CLI-backed lifecycle under Claude Code, Codex, and Pi.
728
749
 
729
750
  The skills for a single axis are listed in that axis's section above. These cut
730
751
  across the axes.
@@ -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.2"
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.2"
@@ -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