tcw-cli 2.6.5__tar.gz → 2.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/PKG-INFO +6 -3
  2. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/README.md +5 -2
  3. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/pyproject.toml +1 -1
  4. tcw_cli-2.7.0/tcw/__init__.py +1 -0
  5. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/cli.py +21 -11
  6. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/__init__.py +33 -1
  7. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/base.py +243 -30
  8. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/fs.py +155 -23
  9. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/project.py +186 -11
  10. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/sync.py +41 -3
  11. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/cli.py +111 -48
  12. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/recursion.py +3 -0
  13. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/PKG-INFO +6 -3
  14. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/SOURCES.txt +6 -0
  15. tcw_cli-2.7.0/tests/test_blocker_cycles_through_unreadable_items.py +194 -0
  16. tcw_cli-2.7.0/tests/test_cross_node_blocker_cycles.py +239 -0
  17. tcw_cli-2.7.0/tests/test_override_letter_case.py +125 -0
  18. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_stage_verb.py +102 -22
  19. tcw_cli-2.7.0/tests/test_tracker_catch_up_moves.py +183 -0
  20. tcw_cli-2.7.0/tests/test_transition_hints.py +210 -0
  21. tcw_cli-2.7.0/tests/test_upstream_projects.py +739 -0
  22. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work.py +9 -6
  23. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work_review.py +2 -2
  24. tcw_cli-2.6.5/tcw/__init__.py +0 -1
  25. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/LICENSE +0 -0
  26. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/setup.cfg +0 -0
  27. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/capabilities/__init__.py +0 -0
  28. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/capabilities/cli.py +0 -0
  29. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/harness.py +0 -0
  30. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/refs.py +0 -0
  31. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
  32. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
  33. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/client/index.html +0 -0
  34. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/client/theme-init.js +0 -0
  35. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/dist/server.cjs +0 -0
  36. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/serve/runtime.py +0 -0
  37. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/stdin.py +0 -0
  38. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/__init__.py +0 -0
  39. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/checkouts.py +0 -0
  40. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/store/config_edit.py +0 -0
  41. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/taxonomy/__init__.py +0 -0
  42. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/taxonomy/cli.py +0 -0
  43. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/__init__.py +0 -0
  44. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/claim.py +0 -0
  45. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/create.py +0 -0
  46. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/intake.py +0 -0
  47. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/jira.py +0 -0
  48. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/ownership.py +0 -0
  49. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/tracker/progress.py +0 -0
  50. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/validate.py +0 -0
  51. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/__init__.py +0 -0
  52. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/generate.py +0 -0
  53. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/hooks.py +0 -0
  54. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/audit-backlog.md +0 -0
  55. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/consolidate-plans.md +0 -0
  56. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/create-work.md +0 -0
  57. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/decompose.md +0 -0
  58. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/delegation.md +0 -0
  59. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/documentation-sync.md +0 -0
  60. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/post-mortem.md +0 -0
  61. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/search.md +0 -0
  62. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/triage-issues.md +0 -0
  63. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/procedures/unattended-work.md +0 -0
  64. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/projection.py +0 -0
  65. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/implement.md +0 -0
  66. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/inbox.md +0 -0
  67. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/plan.md +0 -0
  68. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/postmortem.md +0 -0
  69. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/request.md +0 -0
  70. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/spec.md +0 -0
  71. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/prompts/verify.md +0 -0
  72. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/resolve.py +0 -0
  73. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw/work/templates.py +0 -0
  74. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
  75. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/entry_points.txt +0 -0
  76. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/requires.txt +0 -0
  77. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tcw_cli.egg-info/top_level.txt +0 -0
  78. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_boardless_delegate_and_slices.py +0 -0
  79. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_body_prompt.py +0 -0
  80. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities.py +0 -0
  81. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities_federation.py +0 -0
  82. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities_reset.py +0 -0
  83. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities_rm.py +0 -0
  84. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capabilities_sidecar.py +0 -0
  85. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capability_gate_children.py +0 -0
  86. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_capability_ref_wording.py +0 -0
  87. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_check_versions.py +0 -0
  88. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_child_status.py +0 -0
  89. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_cli_help_coverage.py +0 -0
  90. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_committed_paths.py +0 -0
  91. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_config_edit.py +0 -0
  92. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_config_edit_writers.py +0 -0
  93. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_configuration_text_home.py +0 -0
  94. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_cross_node_blockers.py +0 -0
  95. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_cut_version.py +0 -0
  96. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_documentation_config.py +0 -0
  97. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_documentation_prompt.py +0 -0
  98. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_documentation_sync_wiring.py +0 -0
  99. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_documented_cli_surface.py +0 -0
  100. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_dynamic_skill_marker.py +0 -0
  101. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_edit_refusal.py +0 -0
  102. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_edit_type.py +0 -0
  103. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_environment_hardness.py +0 -0
  104. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_epic_completability_across_checkouts.py +0 -0
  105. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_epic_completable.py +0 -0
  106. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_coverage.py +0 -0
  107. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_files_changed.py +0 -0
  108. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_fixture.py +0 -0
  109. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_grading.py +0 -0
  110. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_eval_runner.py +0 -0
  111. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_external_work_store.py +0 -0
  112. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_falsification_rule.py +0 -0
  113. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_generate_hook.py +0 -0
  114. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_harness.py +0 -0
  115. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_inbox_title.py +0 -0
  116. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_init_configured_tree_path.py +0 -0
  117. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_initiative_node_qualified.py +0 -0
  118. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_interrupted_claim.py +0 -0
  119. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_item_tags_read.py +0 -0
  120. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_legacy_store_config.py +0 -0
  121. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_baseline.py +0 -0
  122. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_config_tags.py +0 -0
  123. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_hooks.py +0 -0
  124. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_inert.py +0 -0
  125. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_policy.py +0 -0
  126. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_lifecycle_validation.py +0 -0
  127. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_literal_store_names.py +0 -0
  128. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_local_qualified_blockers.py +0 -0
  129. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_multiproject.py +0 -0
  130. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_non_git_writes.py +0 -0
  131. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_override_in_linked_worktree.py +0 -0
  132. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_override_references.py +0 -0
  133. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_plugin_manifests.py +0 -0
  134. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_procedure_config.py +0 -0
  135. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_procedure_verb.py +0 -0
  136. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_project_graph_paths.py +0 -0
  137. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_project_overrides.py +0 -0
  138. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_project_registry.py +0 -0
  139. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_projection.py +0 -0
  140. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_prompt_fallback.py +0 -0
  141. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_qualified_ref.py +0 -0
  142. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_recursion.py +0 -0
  143. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_refs.py +0 -0
  144. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_remote_session_setup.py +0 -0
  145. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_repo_lifecycle.py +0 -0
  146. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_resolve.py +0 -0
  147. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_resolve_procedure.py +0 -0
  148. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_retention.py +0 -0
  149. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_routing_nodes.py +0 -0
  150. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_scaffold.py +0 -0
  151. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve.py +0 -0
  152. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_descendants.py +0 -0
  153. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_projection.py +0 -0
  154. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_resolve.py +0 -0
  155. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_runtime.py +0 -0
  156. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_serve_write.py +0 -0
  157. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_session_bootstrap.py +0 -0
  158. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_shipped_procedures.py +0 -0
  159. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_shipped_prompts.py +0 -0
  160. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_show_json.py +0 -0
  161. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_sidecar_paths_in_validate.py +0 -0
  162. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_skill_flow.py +0 -0
  163. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_skill_lifecycle_parity.py +0 -0
  164. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_skill_path_pointers.py +0 -0
  165. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_smoke.py +0 -0
  166. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_stage_validate.py +0 -0
  167. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_status_parity.py +0 -0
  168. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_stdin.py +0 -0
  169. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_stdin_cli.py +0 -0
  170. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_bounds.py +0 -0
  171. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_editor.py +0 -0
  172. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_nodes.py +0 -0
  173. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_provisioning.py +0 -0
  174. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_store_publication.py +0 -0
  175. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_subprocess_stdin.py +0 -0
  176. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_taxonomy.py +0 -0
  177. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_taxonomy_rm_gaps.py +0 -0
  178. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tombstone.py +0 -0
  179. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_absent.py +0 -0
  180. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_binding.py +0 -0
  181. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_claim.py +0 -0
  182. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_claimability.py +0 -0
  183. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_cli.py +0 -0
  184. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_client.py +0 -0
  185. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_comment.py +0 -0
  186. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_config.py +0 -0
  187. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_create.py +0 -0
  188. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_gate.py +0 -0
  189. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_hardening.py +0 -0
  190. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_help.py +0 -0
  191. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_hold.py +0 -0
  192. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_import.py +0 -0
  193. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_import_returns.py +0 -0
  194. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_inheritance.py +0 -0
  195. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_link.py +0 -0
  196. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_message_tidy.py +0 -0
  197. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_ownership.py +0 -0
  198. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_pre_backlog.py +0 -0
  199. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_replay.py +0 -0
  200. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_strict.py +0 -0
  201. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_strict_gate.py +0 -0
  202. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_surface.py +0 -0
  203. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_sync.py +0 -0
  204. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_sync_config.py +0 -0
  205. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_tracker_validate.py +0 -0
  206. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unattended_work_skill.py +0 -0
  207. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unplanned_start.py +0 -0
  208. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unpushed_version_script.py +0 -0
  209. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unreadable_capabilities_sidecar.py +0 -0
  210. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unreadable_item_files.py +0 -0
  211. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_unreadable_state_gates.py +0 -0
  212. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_untag_invalid_tags.py +0 -0
  213. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_validate.py +0 -0
  214. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_validate_moved_stores.py +0 -0
  215. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_validate_target.py +0 -0
  216. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work_autocommit.py +0 -0
  217. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work_start.py +0 -0
  218. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_work_tags.py +0 -0
  219. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_worktree_completion.py +0 -0
  220. {tcw_cli-2.6.5 → tcw_cli-2.7.0}/tests/test_worktree_sibling_nodes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tcw-cli
3
- Version: 2.6.5
3
+ Version: 2.7.0
4
4
  Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -476,7 +476,10 @@ agent through. See [Skills and Agents](#skills-and-agents).
476
476
 
477
477
  **Many repositories, one model.** Projects are identified by ID, never by path.
478
478
  A project can connect to others, inherit their taxonomy and capabilities, and
479
- address their work items as `<project-id>/<slug>`. A checkout holding only some of
479
+ address their work items as `<project-id>/<slug>`. A connection is normally
480
+ declared by both projects; an `upstream` one is declared only by the reader and
481
+ is read-only from its side, so a private project can read a public one that never
482
+ names it. A checkout holding only some of
480
483
  the connected repositories still works: the absent ones drop out rather than
481
484
  breaking your commands. See [Working across repositories](docs/guide/multi-repo.md).
482
485
 
@@ -907,7 +910,7 @@ driving it to completion, verifying it, processing the inbox) described in
907
910
  | Command | What it does |
908
911
  | -------------------- | ----------------------------------------------------------------- |
909
912
  | `tcw work inbox` | `inbox list`, `inbox show` and `inbox accept` raw requests, and Jira tickets awaiting triage |
910
- | `tcw work nodes` | lists this project's parent and child projects |
913
+ | `tcw work nodes` | lists this project's parent, child and upstream projects |
911
914
  | `tcw work delegate` | writes a request into the nearest child project with a board |
912
915
  | `tcw work escalate` | writes a request into the parent project's inbox |
913
916
  | `tcw work reconcile` | reads every project below with a board; writes the epic's roll-up |
@@ -260,7 +260,10 @@ agent through. See [Skills and Agents](#skills-and-agents).
260
260
 
261
261
  **Many repositories, one model.** Projects are identified by ID, never by path.
262
262
  A project can connect to others, inherit their taxonomy and capabilities, and
263
- address their work items as `<project-id>/<slug>`. A checkout holding only some of
263
+ address their work items as `<project-id>/<slug>`. A connection is normally
264
+ declared by both projects; an `upstream` one is declared only by the reader and
265
+ is read-only from its side, so a private project can read a public one that never
266
+ names it. A checkout holding only some of
264
267
  the connected repositories still works: the absent ones drop out rather than
265
268
  breaking your commands. See [Working across repositories](docs/guide/multi-repo.md).
266
269
 
@@ -691,7 +694,7 @@ driving it to completion, verifying it, processing the inbox) described in
691
694
  | Command | What it does |
692
695
  | -------------------- | ----------------------------------------------------------------- |
693
696
  | `tcw work inbox` | `inbox list`, `inbox show` and `inbox accept` raw requests, and Jira tickets awaiting triage |
694
- | `tcw work nodes` | lists this project's parent and child projects |
697
+ | `tcw work nodes` | lists this project's parent, child and upstream projects |
695
698
  | `tcw work delegate` | writes a request into the nearest child project with a board |
696
699
  | `tcw work escalate` | writes a request into the parent project's inbox |
697
700
  | `tcw work reconcile` | reads every project below with a board; writes the epic's roll-up |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tcw-cli"
7
- version = "2.6.5"
7
+ version = "2.7.0"
8
8
  description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "2.7.0"
@@ -210,12 +210,14 @@ def _declared_nodes_in_graph(
210
210
  # Every project the registry holds, not `ancestors + descendants`. That
211
211
  # triple omits a sibling and a sibling of an ancestor — the shapes a
212
212
  # workspace is actually made of — so a declaration living on one was
213
- # never read, and the repository it named never obtained.
214
- roots += [Path(project.locator) for project in
215
- FsProjectRegistry.open(root).projects()]
213
+ # never read, and the repository it named never obtained. Except an
214
+ # upstream: what it connects to is its own to obtain, not its readers'.
215
+ registry = FsProjectRegistry.open(root)
216
+ roots += [Path(project.locator) for project in registry.projects()
217
+ if registry.read_only_reason(project.id) is None]
216
218
  except Exception:
217
219
  pass
218
- found: list[tuple[Path, str, object]] = []
220
+ found: list[tuple[Path, str, object, str]] = []
219
221
  problems: list[str] = []
220
222
  for candidate in roots:
221
223
  candidate = candidate.resolve()
@@ -224,7 +226,7 @@ def _declared_nodes_in_graph(
224
226
  seen.add(candidate)
225
227
  declared, candidate_problems = declared_connected_projects(candidate)
226
228
  problems += [f"{candidate / SENTINEL}: {p}" for p in candidate_problems]
227
- found += [(candidate, pid, d) for pid, d in declared]
229
+ found += [(candidate, pid, d, relation) for pid, d, relation in declared]
228
230
  return found, problems
229
231
 
230
232
 
@@ -250,7 +252,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
250
252
 
251
253
  failed = False
252
254
  seen: set[Path] = set()
253
- queue: list[tuple[Path, str, object]] = []
255
+ queue: list[tuple[Path, str, object, str]] = []
254
256
  # "A project that is already here always wins" is the rule the whole feature
255
257
  # rests on, and the walk is the one place it was not applied: a declaration
256
258
  # is written by whichever node knows about that edge, so an ancestor
@@ -298,8 +300,14 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
298
300
  return None
299
301
  return located if located != target.resolve() else None
300
302
 
301
- def enqueue(root: Path) -> None:
303
+ def enqueue(root: Path, relation: str = "children") -> None:
302
304
  nonlocal failed
305
+ if relation == "upstream":
306
+ # Obtained so it can be read; its own connections are not followed,
307
+ # as the registry does not follow them. A reader must never fetch a
308
+ # project named only by something it reads — a public upstream can
309
+ # still name the private repository it is migrating away from.
310
+ return
303
311
  found, problems = _declared_nodes_in_graph(root, enqueued)
304
312
  for problem in problems:
305
313
  print(f"tcw provision: {problem}", file=sys.stderr)
@@ -314,7 +322,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
314
322
  enqueued: set[Path] = set()
315
323
  enqueue(node_root)
316
324
  while queue:
317
- source, project_id, declaration = queue.pop(0)
325
+ source, project_id, declaration, relation = queue.pop(0)
318
326
  target = provisioned_store_root(source, declaration)
319
327
  if target in seen:
320
328
  continue
@@ -335,13 +343,13 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
335
343
  # provisioning precisely when an earlier one was already in place.
336
344
  print(f" {project_id}: already available")
337
345
  have.add(project_id)
338
- enqueue(present)
346
+ enqueue(present, relation)
339
347
  continue
340
348
  provisioner = FsStoreProvisioner(source, NODE_TARGET, declaration)
341
349
  if provisioner.is_available() and not refresh:
342
350
  print(f" {project_id}: already available at {target}")
343
351
  have.add(project_id)
344
- enqueue(target)
352
+ enqueue(target, relation)
345
353
  continue
346
354
  print(f"→ {project_id}: {provisioner.describe().split(': ', 1)[1]}")
347
355
  try:
@@ -363,7 +371,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
363
371
  # routine when a parent and a grandparent both name it — report
364
372
  # "already available" in a run that fetched nothing.
365
373
  have.add(project_id)
366
- enqueue(target)
374
+ enqueue(target, relation)
367
375
  return failed
368
376
 
369
377
 
@@ -437,6 +445,8 @@ def _cmd_validate(args: argparse.Namespace) -> int:
437
445
  print(f"{entry.declared_in}: connected project '{entry.id}' is declared "
438
446
  f"at {entry.locator}, which is not in this checkout; it was found "
439
447
  f"at {registry.get(entry.id).locator}", file=sys.stderr)
448
+ for warning in registry.warnings():
449
+ print(f"warning: {warning}", file=sys.stderr)
440
450
  from tcw.validate import validate
441
451
  recurse = args.path is None and not args.no_recurse
442
452
  projects = [registry.current, *registry.descendants()] if recurse else [registry.current]
@@ -24,7 +24,8 @@ from tcw.store.base import (
24
24
  )
25
25
  from tcw.store.fs import (
26
26
  FsCapabilitiesStore, FsTaxonomyStore, FsWorkStore, descendant_nodes,
27
- find_node_root, heading_slug, registered_project_id, resolve_qualified_work_ref,
27
+ find_node_root, heading_slug, registered_project_id, qualified_work_ref_read_only,
28
+ resolve_qualified_work_ref,
28
29
  )
29
30
  from tcw.refs import resolve_tcw_ref
30
31
  from tcw.validate import ValidationTarget, validate
@@ -507,6 +508,21 @@ class TcwHandler(BaseHTTPRequestHandler):
507
508
  return resolve_qualified_work_ref(self.server.node_root, slug)
508
509
  return FsWorkStore.open(self.server.node_root), slug
509
510
 
511
+ def _refused_read_only(self, slug: str) -> bool:
512
+ """Answer 403 and return True when `slug` names an item in a project this
513
+ node reads but may not write — an upstream project, or one reached only
514
+ through one. Every route that changes an item or runs its project's
515
+ scripts asks this first; reads do not, so the board can still show it."""
516
+ if not self.server.include_descendants:
517
+ return False
518
+ reason = qualified_work_ref_read_only(self.server.node_root, slug)
519
+ if reason is None:
520
+ return False
521
+ self._send_err(HTTPStatus.FORBIDDEN,
522
+ f"{reason}; change it from that project itself",
523
+ code="read-only-project")
524
+ return True
525
+
510
526
  def _hosted_projects(self) -> set[str]:
511
527
  """Project IDs whose items this server actually serves — the descendants it
512
528
  aggregates, or nothing when not aggregating.
@@ -993,6 +1009,8 @@ class TcwHandler(BaseHTTPRequestHandler):
993
1009
  slug = _decode_path_param(m.group(1))
994
1010
  action = _decode_path_param(m.group(2))
995
1011
  qslug = slug # preserve the addressed (qualified) slug
1012
+ if self._refused_read_only(slug):
1013
+ return
996
1014
  resolved = self._resolve_work(slug)
997
1015
  if resolved is None:
998
1016
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1216,6 +1234,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1216
1234
  if m:
1217
1235
  slug = _decode_path_param(m.group(1))
1218
1236
  qslug = slug # preserve the addressed (qualified) slug
1237
+ if self._refused_read_only(slug):
1238
+ return
1219
1239
  resolved = self._resolve_work(slug)
1220
1240
  if resolved is None:
1221
1241
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1370,6 +1390,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1370
1390
  if name not in WORK_ARTIFACTS:
1371
1391
  self._send(HTTPStatus.BAD_REQUEST, b"unknown artifact")
1372
1392
  return
1393
+ if self._refused_read_only(slug):
1394
+ return
1373
1395
  resolved = self._resolve_work(slug)
1374
1396
  if resolved is None:
1375
1397
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1408,6 +1430,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1408
1430
  if m:
1409
1431
  slug = _decode_path_param(m.group(1))
1410
1432
  stage_id = _decode_path_param(m.group(2))
1433
+ if self._refused_read_only(slug):
1434
+ return
1411
1435
  resolved = self._resolve_work(slug)
1412
1436
  if resolved is None:
1413
1437
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1443,6 +1467,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1443
1467
  f"{name} is written by `{owner}`, not edited; "
1444
1468
  f"run that command instead.")
1445
1469
  return
1470
+ if self._refused_read_only(slug):
1471
+ return
1446
1472
  resolved = self._resolve_work(slug)
1447
1473
  if resolved is None:
1448
1474
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1498,6 +1524,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1498
1524
  if m:
1499
1525
  slug = _decode_path_param(m.group(1))
1500
1526
  stage_id = _decode_path_param(m.group(2))
1527
+ if self._refused_read_only(slug):
1528
+ return
1501
1529
  resolved = self._resolve_work(slug)
1502
1530
  if resolved is None:
1503
1531
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1515,6 +1543,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1515
1543
  m = re.match(r"^/api/work/([^/]+)$", path)
1516
1544
  if m:
1517
1545
  slug = _decode_path_param(m.group(1))
1546
+ if self._refused_read_only(slug):
1547
+ return
1518
1548
  resolved = self._resolve_work(slug)
1519
1549
  if resolved is None:
1520
1550
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1584,6 +1614,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1584
1614
  slug_q, _, stage_q = middle.partition("/plan-stages/")
1585
1615
  slug = _decode_path_param(slug_q)
1586
1616
  stage_id = _decode_path_param(stage_q)
1617
+ if self._refused_read_only(slug):
1618
+ return
1587
1619
  resolved = self._resolve_work(slug)
1588
1620
  if resolved is None:
1589
1621
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -21,7 +21,7 @@ from abc import ABC, abstractmethod
21
21
  from dataclasses import dataclass, field
22
22
  from datetime import date, datetime, timezone
23
23
  from pathlib import PurePosixPath
24
- from typing import Any
24
+ from typing import Any, Collection
25
25
  from urllib.parse import quote
26
26
 
27
27
 
@@ -216,6 +216,35 @@ class ProjectRegistry(ABC):
216
216
  `declared_parent_id`."""
217
217
  return [child.id for child in self.children(project_id)]
218
218
 
219
+ def declared_upstream_ids(self, project_id: str | None = None) -> list[str]:
220
+ """The ids of the declared upstream projects, reachable or not.
221
+
222
+ An upstream is a project this one reads from — its taxonomy, its
223
+ capabilities, its work items by reference — and never writes to, and
224
+ which does not name this project back. It is neither parent nor child,
225
+ so no walk over those relations reaches it. A registry that keeps no
226
+ such relation has none."""
227
+ return []
228
+
229
+ def read_only_reason(self, project_id: str,
230
+ from_id: str | None = None) -> str | None:
231
+ """Why `from_id` (default: the current project) may not write to
232
+ `project_id`, or None when it may.
233
+
234
+ **The one place the write rule lives.** A project is writable from a
235
+ node only when the node reaches it by `parent` and `children`
236
+ connections alone, each followed as declared by the project it starts
237
+ from. Anything else the graph holds was reached across an upstream
238
+ connection and is read-only there — however many nodes declare it, and
239
+ from whichever side of the family it is named. A registry with no
240
+ upstream relation has nothing read-only."""
241
+ return None
242
+
243
+ def warnings(self) -> list[str]:
244
+ """Graph facts worth saying that are neither defects nor unreachable
245
+ projects — printed by `tcw validate`, never fatal."""
246
+ return []
247
+
219
248
  def overrides(self) -> list["ProjectOverride"]:
220
249
  """The locators the caller supplied for this graph, in place of declared ones.
221
250
 
@@ -2314,6 +2343,67 @@ STAGE_NEXT_STEPS: dict[str, str] = {
2314
2343
  }
2315
2344
 
2316
2345
 
2346
+ # What a reader does after a transition, keyed by the command that printed it.
2347
+ # The transition-side sibling of `STAGE_NEXT_STEPS`, and navigation text for the
2348
+ # same reason: it names a stage, never a status edge, so no store answers for it.
2349
+ #
2350
+ # These drifted while they were four literals in the CLI with nothing reading
2351
+ # them: `start` sent readers to `tcw work complete`, skipping implement and
2352
+ # verify, and `submit` told them to delete a `refined-outcome.md` that verify had
2353
+ # not written yet.
2354
+ #
2355
+ # `start` has one entry per stage it can choose (`start_next_stage`), rather than
2356
+ # one template, so every entry is a complete sentence the tests can check. No
2357
+ # entry names `spec.md` or `plan.md`: `start`'s warning names those files when
2358
+ # they are missing, and its tests assert they are absent when they are not.
2359
+ TRANSITION_NEXT_STEPS: dict[str, str] = {
2360
+ # `inbox accept` of a raw entry prints this too: both leave a backlog item
2361
+ # holding at most its intake.
2362
+ "new": "run `tcw work stage gate request <slug>`",
2363
+ "start:spec": "run `tcw work stage gate spec <slug>`",
2364
+ "start:plan": "run `tcw work stage gate plan <slug>`",
2365
+ "start:implement": "run `tcw work stage gate implement <slug>`",
2366
+ "start:verify": "run `tcw work stage gate verify <slug>`",
2367
+ # Both endings, and no command for either: the verify stage's own footer
2368
+ # names `complete` and `rework`, and repeating them here is how this line
2369
+ # went stale before.
2370
+ "submit": ("run `tcw work stage gate verify <slug>`; it ends in "
2371
+ "refined-outcome.md to accept the work or rework.md to send it back"),
2372
+ "rework": "address rework.md: run `tcw work stage gate implement <slug>`",
2373
+ }
2374
+
2375
+ # The status an item is in when each hint prints — what the tests check a named
2376
+ # stage against. Nothing else reads it.
2377
+ TRANSITION_LANDS_IN: dict[str, str] = {
2378
+ "new": "backlog",
2379
+ "start:spec": "active",
2380
+ "start:plan": "active",
2381
+ "start:implement": "active",
2382
+ "start:verify": "active",
2383
+ "submit": "review",
2384
+ "rework": "active",
2385
+ }
2386
+
2387
+
2388
+ def start_next_stage(present: Collection[str]) -> str:
2389
+ """The stage to run after `start`, from the artifacts the item holds.
2390
+
2391
+ Not always `implement`. An item can be started before it is specified or
2392
+ planned, and `start` also takes an item already `active` — with
2393
+ `--take-over`, or when nobody holds it — which may already have an outcome.
2394
+ The order is the `work` skill's "Finding your place", restricted to stages
2395
+ legal in `active` (so never `request`), with a reworked item sent back to
2396
+ `implement` as `rework`'s own hint does.
2397
+ """
2398
+ if "spec" not in present:
2399
+ return "spec"
2400
+ if "plan" not in present:
2401
+ return "plan"
2402
+ if "outcome" not in present or "rework" in present:
2403
+ return "implement"
2404
+ return "verify"
2405
+
2406
+
2317
2407
  def _parse_condition(raw: Any, where: str, problems: list[str]) -> "Condition | None":
2318
2408
  """Parse a `when:` mapping. Every shape is validated, not just `type`'s value.
2319
2409
 
@@ -3058,6 +3148,9 @@ class PublicationError(TransitionCommitError):
3058
3148
 
3059
3149
 
3060
3150
 
3151
+ _HELD_TWICE = "more than one folder holds this slug"
3152
+
3153
+
3061
3154
  class MultipleMatch(Exception):
3062
3155
  """A slug resolves to more than one item folder (slug integrity broken)."""
3063
3156
 
@@ -3900,12 +3993,14 @@ class WorkStore(ABC):
3900
3993
 
3901
3994
  @staticmethod
3902
3995
  def _unreadable_refusal(verb: str, slug: str, what: str,
3903
- found: list[tuple[str, str]]) -> ValueError:
3996
+ found: list[tuple[str, str]], *,
3997
+ noun: str = "open items",
3998
+ remedy: str = "Fix or replace each state.yaml "
3999
+ "(`tcw validate` lists them)") -> ValueError:
3904
4000
  named = ", ".join(f"{label} ({reason})" for label, reason in found)
3905
- return ValueError(f"Cannot {verb} {slug}: the state of these open items "
4001
+ return ValueError(f"Cannot {verb} {slug}: the state of these {noun} "
3906
4002
  f"cannot be read, so whether they are {what} is "
3907
- f"unknown: {named}. Fix or replace each state.yaml "
3908
- f"(`tcw validate` lists them) and retry.")
4003
+ f"unknown: {named}. {remedy} and retry.")
3909
4004
 
3910
4005
  def independent_descendants(self, slug: str) -> list[WorkItem]:
3911
4006
  """Every item beneath `slug` — the whole subtree, not only direct
@@ -3998,36 +4093,153 @@ class WorkStore(ABC):
3998
4093
  return self._same_entry(entry, e) or (
3999
4094
  "slug" in entry and "external" in e and self._entry_for(e["external"]) == entry)
4000
4095
 
4001
- def _reaches(self, start: str, target: str, *,
4002
- settled: frozenset[str] = frozenset()) -> bool:
4003
- """True if `start` (transitively, via blocked_by slugs) depends on `target`.
4096
+ def unreadable_reason(self, slug: str) -> str | None:
4097
+ """Why this store holds item `slug` but cannot read its recorded fields,
4098
+ or `None` — when they can be read, or there is no such item. A store that
4099
+ never holds a record it cannot read has none."""
4100
+ return None
4004
4101
 
4005
- `settled` items are not expanded: a caller that has already started the
4006
- walk from an item's *proposed* blockers passes it, so its stored ones —
4007
- which the edit is replacing — cannot be followed back in."""
4008
- seen: set[str] = set(settled)
4102
+ def _item_label(self, store: "WorkStore", slug: str) -> str:
4103
+ """How a message from this store names `store`'s item `slug`. The slug
4104
+ here; a store that can address other projects qualifies theirs."""
4105
+ return slug
4106
+
4107
+ def _store_key(self) -> object:
4108
+ """What makes two opens of this store one store, for walks that cross
4109
+ stores. The object itself here; an adapter that can open the same store
4110
+ twice says what the two opens share."""
4111
+ return id(self)
4112
+
4113
+ def _blocker_target(self, entry: dict) -> "tuple[WorkStore, str] | None":
4114
+ """Where a stored blocker entry points, if at a work item — the same
4115
+ entries `external_blocker_state` settles against one, and no others.
4116
+
4117
+ Here: a slug, and an `external` text shaped like a slug, which settles
4118
+ against this store's item of that name — including one not created yet,
4119
+ since a slug is predictable and creating it would close any cycle the
4120
+ entry is part of. A missing item is a dead end for the walk, not an
4121
+ error. A store that can address other projects extends this for
4122
+ `<project-id>/<slug>`."""
4123
+ if not isinstance(entry, dict): # hand-edited data: not followed
4124
+ return None
4125
+ if "slug" in entry:
4126
+ return self, entry["slug"]
4127
+ text = str(entry.get("external") or "").strip()
4128
+ if re.fullmatch(r"[a-z0-9][a-z0-9-]*", text):
4129
+ return self, text
4130
+ return None
4131
+
4132
+ def _reaches(self, start: "tuple[WorkStore, str]", target: str, *,
4133
+ settled: frozenset[str] = frozenset(),
4134
+ unreadable: "list[tuple[str, str]] | None" = None) -> bool:
4135
+ """True if the item at `start` — a (store, slug) pair — depends,
4136
+ transitively through its blockers, on this store's item `target`.
4137
+
4138
+ The walk follows every blocker `_blocker_target` resolves, into other
4139
+ stores too, each item through its own store: a qualified blocker means
4140
+ what it meant to the node that stored it. An item that cannot be read —
4141
+ an interrupted claim, an ambiguous slug, in this store or another — is
4142
+ not followed: the state of an item the edit does not name never fails
4143
+ the edit by raising. Those whose blockers are unknown — a slug more than
4144
+ one folder holds, or recorded fields the store cannot read — are
4145
+ appended to `unreadable` as `(label, reason)`, for the caller to decide
4146
+ on; an interrupted claim is not one of them.
4147
+
4148
+ `settled` items (slugs of this store) are not expanded: a caller that has
4149
+ already started the walk from an item's *proposed* blockers passes it,
4150
+ so its stored ones — which the edit is replacing — cannot be followed
4151
+ back in."""
4152
+ stores: dict[object, WorkStore] = {self._store_key(): self}
4153
+ goal = (self._store_key(), target)
4154
+ seen: set[tuple[object, str]] = {(self._store_key(), s) for s in settled}
4009
4155
  stack = [start]
4010
4156
  while stack:
4011
- cur = stack.pop()
4012
- if cur == target:
4157
+ store, slug = stack.pop()
4158
+ key = store._store_key()
4159
+ store = stores.setdefault(key, store) # first open of a store wins
4160
+ node = (key, slug)
4161
+ if node == goal:
4013
4162
  return True
4014
- if cur in seen:
4163
+ if node in seen:
4164
+ continue
4165
+ seen.add(node)
4166
+ try:
4167
+ item = store.get(slug)
4168
+ except MultipleMatch:
4169
+ if unreadable is not None:
4170
+ unreadable.append((self._item_label(store, slug), _HELD_TWICE))
4171
+ continue
4172
+ except Exception:
4015
4173
  continue
4016
- seen.add(cur)
4017
- item = self.get(cur)
4018
4174
  if item is None:
4019
4175
  continue
4020
- stack += [b["slug"] for b in item.blocked_by if "slug" in b]
4176
+ if not item.blocked_by and unreadable is not None:
4177
+ # Damage always reads as no blockers, so only these need asking.
4178
+ try:
4179
+ reason = store.unreadable_reason(slug)
4180
+ except Exception:
4181
+ reason = None
4182
+ if reason:
4183
+ unreadable.append((self._item_label(store, slug), reason))
4184
+ for entry in item.blocked_by:
4185
+ try:
4186
+ found = store._blocker_target(entry)
4187
+ except Exception:
4188
+ found = None
4189
+ if found is not None:
4190
+ stack.append(found)
4021
4191
  return False
4022
4192
 
4193
+ def _refuse_blocker_walks(self, walks: "list[tuple[tuple[WorkStore, str], str]]",
4194
+ target: str, *, settled: frozenset[str] = frozenset(),
4195
+ blockers: list[str]) -> None:
4196
+ """Walk from each `(start, cycle message)` of `walks` towards this
4197
+ store's `target`, then decide once: a cycle in any walk refuses with its
4198
+ message; otherwise an item on any path whose blockers could not be read
4199
+ refuses, naming each, because whether the edit closes a cycle is
4200
+ unknown; otherwise accept. Deciding after every walk is what stops a
4201
+ damaged path hiding a cycle through another."""
4202
+ unreadable: list[tuple[str, str]] = []
4203
+ for start, cycle in walks:
4204
+ if self._reaches(start, target, settled=settled, unreadable=unreadable):
4205
+ raise ValueError(cycle)
4206
+ if not unreadable:
4207
+ return
4208
+ found = list(dict.fromkeys(unreadable))
4209
+ remedies = []
4210
+ if any(reason != _HELD_TWICE for _, reason in found):
4211
+ remedies.append("Fix or replace each damaged state.yaml "
4212
+ "(`tcw validate` lists them)")
4213
+ if any(reason == _HELD_TWICE for _, reason in found):
4214
+ remedies.append("remove the extra folder of each slug held twice")
4215
+ remedy = "; ".join(remedies)
4216
+ many = len(blockers) > 1
4217
+ raise self._unreadable_refusal(
4218
+ f"add {', '.join(blockers)} as {'blockers' if many else 'a blocker'} of",
4219
+ target, "part of a blocking cycle with this edit", found,
4220
+ noun="items", remedy=remedy[0].upper() + remedy[1:])
4221
+
4023
4222
  def _check_new_blocker(self, slug: str, entry: dict, ref: str) -> None:
4024
- """Refuse `entry` as a new blocker of `slug`: a self-block, or a cycle
4025
- through the blockers as stored. The one rule every blocker write uses."""
4026
- if "slug" in entry:
4027
- if entry["slug"] == slug:
4223
+ self._check_new_blockers(slug, [(ref, entry)])
4224
+
4225
+ def _check_new_blockers(self, slug: str, new: "list[tuple[str, dict]]") -> None:
4226
+ """Refuse `(ref, entry)` pairs as new blockers of `slug`, together: a
4227
+ self-block, or a cycle through the blockers as stored — across stores
4228
+ when an entry names an item in another one — or walks that cannot see
4229
+ through an item on the way. The one rule every blocker write uses;
4230
+ decided once for all of them, so one blocker's damaged path cannot
4231
+ hide another's cycle."""
4232
+ walks, refs = [], []
4233
+ for ref, entry in new:
4234
+ found = self._blocker_target(entry)
4235
+ if found is None:
4236
+ continue
4237
+ store, target = found
4238
+ if store._store_key() == self._store_key() and target == slug:
4028
4239
  raise ValueError("an item cannot block itself")
4029
- if self._reaches(entry["slug"], slug):
4030
- raise ValueError(f"{ref} → {slug} would create a blocking cycle")
4240
+ walks.append((found, f"{ref} → {slug} would create a blocking cycle"))
4241
+ refs.append(ref)
4242
+ self._refuse_blocker_walks(walks, slug, blockers=refs)
4031
4243
 
4032
4244
  def add_blocker(self, slug: str, ref: str) -> None:
4033
4245
  item = self._require(slug)
@@ -4054,18 +4266,19 @@ class WorkStore(ABC):
4054
4266
  proposed = list(item.blocked_by)
4055
4267
  for ref in remove:
4056
4268
  proposed = self._without(proposed, slug, ref)
4057
- for ref in add:
4058
- entry = self._entry_for(ref)
4059
- self._check_new_blocker(slug, entry, ref)
4269
+ adding = [(ref, self._entry_for(ref)) for ref in add]
4270
+ self._check_new_blockers(slug, adding)
4271
+ for ref, entry in adding:
4060
4272
  if not any(self._same_entry(entry, e) for e in proposed):
4061
4273
  proposed = [e for e in proposed if not self._same_item(entry, e)] + [entry]
4062
4274
  for ref in blocks:
4063
4275
  target = self._require(ref).slug
4064
4276
  if target == slug:
4065
4277
  raise ValueError("an item cannot block itself")
4066
- if any(self._reaches(e["slug"], target, settled=frozenset({slug}))
4067
- for e in proposed if "slug" in e):
4068
- raise ValueError(f"{slug} → {ref} would create a blocking cycle")
4278
+ cycle = f"{slug} → {ref} would create a blocking cycle"
4279
+ self._refuse_blocker_walks(
4280
+ [(f, cycle) for f in map(self._blocker_target, proposed) if f is not None],
4281
+ target, settled=frozenset({slug}), blockers=[slug])
4069
4282
 
4070
4283
  def remove_blocker(self, slug: str, ref: str) -> None:
4071
4284
  """Remove one blocker. Fails closed on a ref that matches nothing.