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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/PKG-INFO +10 -7
  2. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/README.md +9 -6
  3. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/pyproject.toml +1 -1
  4. tcw_cli-2.8.0/tcw/__init__.py +1 -0
  5. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/cli.py +29 -15
  6. tcw_cli-2.8.0/tcw/cli_suggest.py +199 -0
  7. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/refs.py +4 -0
  8. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/__init__.py +42 -1
  9. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/base.py +400 -38
  10. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/fs.py +1052 -162
  11. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/project.py +186 -11
  12. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/sync.py +41 -3
  13. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/validate.py +5 -1
  14. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/cli.py +392 -108
  15. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/create-work.md +9 -4
  16. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/implement.md +2 -1
  17. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/inbox.md +2 -1
  18. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/request.md +2 -0
  19. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/verify.md +3 -0
  20. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/recursion.py +14 -3
  21. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/PKG-INFO +10 -7
  22. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/SOURCES.txt +14 -0
  23. tcw_cli-2.8.0/tests/test_already_integrated_check.py +223 -0
  24. tcw_cli-2.8.0/tests/test_blocker_cycles_through_unreadable_items.py +194 -0
  25. tcw_cli-2.8.0/tests/test_command_suggestions.py +154 -0
  26. tcw_cli-2.8.0/tests/test_creation_commits.py +262 -0
  27. tcw_cli-2.8.0/tests/test_cross_node_blocker_cycles.py +239 -0
  28. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_external_work_store.py +7 -2
  29. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_non_git_writes.py +24 -4
  30. tcw_cli-2.8.0/tests/test_override_letter_case.py +125 -0
  31. tcw_cli-2.8.0/tests/test_planning_gates_after_start.py +144 -0
  32. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_prompt_fallback.py +5 -0
  33. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_recursion.py +4 -0
  34. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_refs.py +8 -4
  35. tcw_cli-2.8.0/tests/test_rename_work_item.py +453 -0
  36. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_retention.py +1 -0
  37. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_stage_verb.py +104 -23
  38. tcw_cli-2.8.0/tests/test_stale_item_path.py +283 -0
  39. tcw_cli-2.8.0/tests/test_store_lock.py +287 -0
  40. tcw_cli-2.8.0/tests/test_tracker_catch_up_moves.py +183 -0
  41. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_comment.py +6 -2
  42. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_strict.py +17 -2
  43. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_sync.py +20 -1
  44. tcw_cli-2.8.0/tests/test_transition_hints.py +214 -0
  45. tcw_cli-2.8.0/tests/test_upstream_projects.py +741 -0
  46. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work.py +11 -6
  47. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work_autocommit.py +19 -8
  48. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work_review.py +6 -2
  49. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_worktree_completion.py +12 -0
  50. tcw_cli-2.6.5/tcw/__init__.py +0 -1
  51. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/LICENSE +0 -0
  52. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/setup.cfg +0 -0
  53. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/capabilities/__init__.py +0 -0
  54. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/capabilities/cli.py +0 -0
  55. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/harness.py +0 -0
  56. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/client/assets/index-C5_pngMt.css +0 -0
  57. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/client/assets/index-Dss8_ZG_.js +0 -0
  58. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/client/index.html +0 -0
  59. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/client/theme-init.js +0 -0
  60. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/dist/server.cjs +0 -0
  61. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/serve/runtime.py +0 -0
  62. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/stdin.py +0 -0
  63. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/__init__.py +0 -0
  64. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/checkouts.py +0 -0
  65. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/store/config_edit.py +0 -0
  66. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/taxonomy/__init__.py +0 -0
  67. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/taxonomy/cli.py +0 -0
  68. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/__init__.py +0 -0
  69. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/claim.py +0 -0
  70. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/create.py +0 -0
  71. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/intake.py +0 -0
  72. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/jira.py +0 -0
  73. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/ownership.py +0 -0
  74. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/tracker/progress.py +0 -0
  75. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/__init__.py +0 -0
  76. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/generate.py +0 -0
  77. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/hooks.py +0 -0
  78. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/audit-backlog.md +0 -0
  79. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/consolidate-plans.md +0 -0
  80. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/decompose.md +0 -0
  81. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/delegation.md +0 -0
  82. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/documentation-sync.md +0 -0
  83. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/post-mortem.md +0 -0
  84. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/search.md +0 -0
  85. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/triage-issues.md +0 -0
  86. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/procedures/unattended-work.md +0 -0
  87. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/projection.py +0 -0
  88. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/plan.md +0 -0
  89. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/postmortem.md +0 -0
  90. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/prompts/spec.md +0 -0
  91. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/resolve.py +0 -0
  92. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw/work/templates.py +0 -0
  93. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
  94. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/entry_points.txt +0 -0
  95. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/requires.txt +0 -0
  96. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tcw_cli.egg-info/top_level.txt +0 -0
  97. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_boardless_delegate_and_slices.py +0 -0
  98. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_body_prompt.py +0 -0
  99. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities.py +0 -0
  100. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities_federation.py +0 -0
  101. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities_reset.py +0 -0
  102. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities_rm.py +0 -0
  103. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capabilities_sidecar.py +0 -0
  104. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capability_gate_children.py +0 -0
  105. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_capability_ref_wording.py +0 -0
  106. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_check_versions.py +0 -0
  107. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_child_status.py +0 -0
  108. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_cli_help_coverage.py +0 -0
  109. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_committed_paths.py +0 -0
  110. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_config_edit.py +0 -0
  111. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_config_edit_writers.py +0 -0
  112. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_configuration_text_home.py +0 -0
  113. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_cross_node_blockers.py +0 -0
  114. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_cut_version.py +0 -0
  115. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_documentation_config.py +0 -0
  116. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_documentation_prompt.py +0 -0
  117. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_documentation_sync_wiring.py +0 -0
  118. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_documented_cli_surface.py +0 -0
  119. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_dynamic_skill_marker.py +0 -0
  120. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_edit_refusal.py +0 -0
  121. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_edit_type.py +0 -0
  122. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_environment_hardness.py +0 -0
  123. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_epic_completability_across_checkouts.py +0 -0
  124. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_epic_completable.py +0 -0
  125. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_coverage.py +0 -0
  126. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_files_changed.py +0 -0
  127. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_fixture.py +0 -0
  128. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_grading.py +0 -0
  129. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_eval_runner.py +0 -0
  130. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_falsification_rule.py +0 -0
  131. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_generate_hook.py +0 -0
  132. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_harness.py +0 -0
  133. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_inbox_title.py +0 -0
  134. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_init_configured_tree_path.py +0 -0
  135. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_initiative_node_qualified.py +0 -0
  136. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_interrupted_claim.py +0 -0
  137. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_item_tags_read.py +0 -0
  138. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_legacy_store_config.py +0 -0
  139. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_baseline.py +0 -0
  140. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_config_tags.py +0 -0
  141. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_hooks.py +0 -0
  142. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_inert.py +0 -0
  143. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_policy.py +0 -0
  144. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_lifecycle_validation.py +0 -0
  145. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_literal_store_names.py +0 -0
  146. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_local_qualified_blockers.py +0 -0
  147. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_multiproject.py +0 -0
  148. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_override_in_linked_worktree.py +0 -0
  149. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_override_references.py +0 -0
  150. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_plugin_manifests.py +0 -0
  151. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_procedure_config.py +0 -0
  152. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_procedure_verb.py +0 -0
  153. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_project_graph_paths.py +0 -0
  154. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_project_overrides.py +0 -0
  155. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_project_registry.py +0 -0
  156. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_projection.py +0 -0
  157. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_qualified_ref.py +0 -0
  158. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_remote_session_setup.py +0 -0
  159. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_repo_lifecycle.py +0 -0
  160. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_resolve.py +0 -0
  161. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_resolve_procedure.py +0 -0
  162. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_routing_nodes.py +0 -0
  163. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_scaffold.py +0 -0
  164. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve.py +0 -0
  165. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_descendants.py +0 -0
  166. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_projection.py +0 -0
  167. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_resolve.py +0 -0
  168. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_runtime.py +0 -0
  169. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_serve_write.py +0 -0
  170. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_session_bootstrap.py +0 -0
  171. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_shipped_procedures.py +0 -0
  172. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_shipped_prompts.py +0 -0
  173. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_show_json.py +0 -0
  174. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_sidecar_paths_in_validate.py +0 -0
  175. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_skill_flow.py +0 -0
  176. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_skill_lifecycle_parity.py +0 -0
  177. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_skill_path_pointers.py +0 -0
  178. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_smoke.py +0 -0
  179. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_stage_validate.py +0 -0
  180. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_status_parity.py +0 -0
  181. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_stdin.py +0 -0
  182. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_stdin_cli.py +0 -0
  183. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_bounds.py +0 -0
  184. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_editor.py +0 -0
  185. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_nodes.py +0 -0
  186. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_provisioning.py +0 -0
  187. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_store_publication.py +0 -0
  188. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_subprocess_stdin.py +0 -0
  189. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_taxonomy.py +0 -0
  190. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_taxonomy_rm_gaps.py +0 -0
  191. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tombstone.py +0 -0
  192. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_absent.py +0 -0
  193. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_binding.py +0 -0
  194. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_claim.py +0 -0
  195. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_claimability.py +0 -0
  196. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_cli.py +0 -0
  197. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_client.py +0 -0
  198. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_config.py +0 -0
  199. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_create.py +0 -0
  200. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_gate.py +0 -0
  201. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_hardening.py +0 -0
  202. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_help.py +0 -0
  203. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_hold.py +0 -0
  204. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_import.py +0 -0
  205. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_import_returns.py +0 -0
  206. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_inheritance.py +0 -0
  207. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_link.py +0 -0
  208. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_message_tidy.py +0 -0
  209. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_ownership.py +0 -0
  210. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_pre_backlog.py +0 -0
  211. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_replay.py +0 -0
  212. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_strict_gate.py +0 -0
  213. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_surface.py +0 -0
  214. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_sync_config.py +0 -0
  215. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_tracker_validate.py +0 -0
  216. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unattended_work_skill.py +0 -0
  217. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unplanned_start.py +0 -0
  218. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unpushed_version_script.py +0 -0
  219. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unreadable_capabilities_sidecar.py +0 -0
  220. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unreadable_item_files.py +0 -0
  221. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_unreadable_state_gates.py +0 -0
  222. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_untag_invalid_tags.py +0 -0
  223. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_validate.py +0 -0
  224. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_validate_moved_stores.py +0 -0
  225. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_validate_target.py +0 -0
  226. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work_start.py +0 -0
  227. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_work_tags.py +0 -0
  228. {tcw_cli-2.6.5 → tcw_cli-2.8.0}/tests/test_worktree_sibling_nodes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tcw-cli
3
- Version: 2.6.5
3
+ Version: 2.8.0
4
4
  Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -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
 
@@ -685,9 +688,9 @@ The stages, and the document each one writes:
685
688
  | Stage | Runs while the item is | Writes |
686
689
  | ------------ | ---------------------- | ----------------------------------------------------------------- |
687
690
  | `inbox` | not yet an item | nothing; it creates the item, keeping the raw text as `intake.md` |
688
- | `request` | backlog | `initial-request.md`: what is asked for, and why |
689
- | `spec` | backlog | `spec.md`: what to build, with acceptance criteria |
690
- | `plan` | backlog | `plan.md`: how to build it, as ordered tasks |
691
+ | `request` | backlog (or active) | `initial-request.md`: what is asked for, and why |
692
+ | `spec` | backlog (or active) | `spec.md`: what to build, with acceptance criteria |
693
+ | `plan` | backlog (or active) | `plan.md`: how to build it, as ordered tasks |
691
694
  | `implement` | active | `outcome.md`: what was built, and what the plan got wrong |
692
695
  | `verify` | review (or active) | `refined-outcome.md` if accepted, `rework.md` if not |
693
696
  | `postmortem` | review or completed | `post-mortem.md`: which stage could first have caught a problem |
@@ -699,7 +702,7 @@ The transitions, and what each one checks before it moves an item:
699
702
  | `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
700
703
  | `submit` | `tcw work submit <slug>` | active → review | never |
701
704
  | `rework` | `tcw work rework <slug>` | review → active | `refined-outcome.md` is present (it says the work was accepted) |
702
- | `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; the item's folder in its worktree has uncommitted changes; no `--confirm` |
705
+ | `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; with `--already-integrated`, the branch is not merged into `HEAD`; the item's folder in its worktree has uncommitted changes; no `--confirm` |
703
706
  | discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
704
707
  | `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
705
708
  | not retained | happens during `complete` or discard | completed or discarded → removed | only happens when `work.retain` says that status is not kept; `tcw work delete` finishes one that was interrupted |
@@ -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
 
@@ -469,9 +472,9 @@ The stages, and the document each one writes:
469
472
  | Stage | Runs while the item is | Writes |
470
473
  | ------------ | ---------------------- | ----------------------------------------------------------------- |
471
474
  | `inbox` | not yet an item | nothing; it creates the item, keeping the raw text as `intake.md` |
472
- | `request` | backlog | `initial-request.md`: what is asked for, and why |
473
- | `spec` | backlog | `spec.md`: what to build, with acceptance criteria |
474
- | `plan` | backlog | `plan.md`: how to build it, as ordered tasks |
475
+ | `request` | backlog (or active) | `initial-request.md`: what is asked for, and why |
476
+ | `spec` | backlog (or active) | `spec.md`: what to build, with acceptance criteria |
477
+ | `plan` | backlog (or active) | `plan.md`: how to build it, as ordered tasks |
475
478
  | `implement` | active | `outcome.md`: what was built, and what the plan got wrong |
476
479
  | `verify` | review (or active) | `refined-outcome.md` if accepted, `rework.md` if not |
477
480
  | `postmortem` | review or completed | `post-mortem.md`: which stage could first have caught a problem |
@@ -483,7 +486,7 @@ The transitions, and what each one checks before it moves an item:
483
486
  | `start` | `tcw work start <slug>` | backlog → active | a blocker is unresolved; the item belongs to an epic that is not active |
484
487
  | `submit` | `tcw work submit <slug>` | active → review | never |
485
488
  | `rework` | `tcw work rework <slug>` | review → active | `refined-outcome.md` is present (it says the work was accepted) |
486
- | `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; the item's folder in its worktree has uncommitted changes; no `--confirm` |
489
+ | `complete` | `tcw work complete <slug> --resolution done --confirm` | review or active → completed | a blocker is unresolved; an epic has open children; declared capabilities are not reconciled; a worktree merge-back fails; with `--already-integrated`, the branch is not merged into `HEAD`; the item's folder in its worktree has uncommitted changes; no `--confirm` |
487
490
  | discard | `tcw work complete <slug> --resolution wontfix\|duplicate\|superseded --confirm` | backlog, active or review → discarded | no `--confirm` |
488
491
  | `drop` | `tcw work drop <slug> --confirm` | backlog → removed | the item is not in backlog; no `--confirm` |
489
492
  | not retained | happens during `complete` or discard | completed or discarded → removed | only happens when `work.retain` says that status is not kept; `tcw work delete` finishes one that was interrupted |
@@ -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.8.0"
8
8
  description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "2.8.0"
@@ -10,6 +10,7 @@ import subprocess
10
10
  import sys
11
11
  from pathlib import Path
12
12
 
13
+ from tcw.cli_suggest import SuggestingParser, attach_index
13
14
  from tcw import __version__
14
15
  from tcw.capabilities import cli as capabilities_cli
15
16
  from tcw.serve import DEFAULT_PORT, serve
@@ -210,12 +211,14 @@ def _declared_nodes_in_graph(
210
211
  # Every project the registry holds, not `ancestors + descendants`. That
211
212
  # triple omits a sibling and a sibling of an ancestor — the shapes a
212
213
  # 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()]
214
+ # never read, and the repository it named never obtained. Except an
215
+ # upstream: what it connects to is its own to obtain, not its readers'.
216
+ registry = FsProjectRegistry.open(root)
217
+ roots += [Path(project.locator) for project in registry.projects()
218
+ if registry.read_only_reason(project.id) is None]
216
219
  except Exception:
217
220
  pass
218
- found: list[tuple[Path, str, object]] = []
221
+ found: list[tuple[Path, str, object, str]] = []
219
222
  problems: list[str] = []
220
223
  for candidate in roots:
221
224
  candidate = candidate.resolve()
@@ -224,7 +227,7 @@ def _declared_nodes_in_graph(
224
227
  seen.add(candidate)
225
228
  declared, candidate_problems = declared_connected_projects(candidate)
226
229
  problems += [f"{candidate / SENTINEL}: {p}" for p in candidate_problems]
227
- found += [(candidate, pid, d) for pid, d in declared]
230
+ found += [(candidate, pid, d, relation) for pid, d, relation in declared]
228
231
  return found, problems
229
232
 
230
233
 
@@ -250,7 +253,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
250
253
 
251
254
  failed = False
252
255
  seen: set[Path] = set()
253
- queue: list[tuple[Path, str, object]] = []
256
+ queue: list[tuple[Path, str, object, str]] = []
254
257
  # "A project that is already here always wins" is the rule the whole feature
255
258
  # rests on, and the walk is the one place it was not applied: a declaration
256
259
  # is written by whichever node knows about that edge, so an ancestor
@@ -298,8 +301,14 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
298
301
  return None
299
302
  return located if located != target.resolve() else None
300
303
 
301
- def enqueue(root: Path) -> None:
304
+ def enqueue(root: Path, relation: str = "children") -> None:
302
305
  nonlocal failed
306
+ if relation == "upstream":
307
+ # Obtained so it can be read; its own connections are not followed,
308
+ # as the registry does not follow them. A reader must never fetch a
309
+ # project named only by something it reads — a public upstream can
310
+ # still name the private repository it is migrating away from.
311
+ return
303
312
  found, problems = _declared_nodes_in_graph(root, enqueued)
304
313
  for problem in problems:
305
314
  print(f"tcw provision: {problem}", file=sys.stderr)
@@ -314,7 +323,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
314
323
  enqueued: set[Path] = set()
315
324
  enqueue(node_root)
316
325
  while queue:
317
- source, project_id, declaration = queue.pop(0)
326
+ source, project_id, declaration, relation = queue.pop(0)
318
327
  target = provisioned_store_root(source, declaration)
319
328
  if target in seen:
320
329
  continue
@@ -335,13 +344,13 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
335
344
  # provisioning precisely when an earlier one was already in place.
336
345
  print(f" {project_id}: already available")
337
346
  have.add(project_id)
338
- enqueue(present)
347
+ enqueue(present, relation)
339
348
  continue
340
349
  provisioner = FsStoreProvisioner(source, NODE_TARGET, declaration)
341
350
  if provisioner.is_available() and not refresh:
342
351
  print(f" {project_id}: already available at {target}")
343
352
  have.add(project_id)
344
- enqueue(target)
353
+ enqueue(target, relation)
345
354
  continue
346
355
  print(f"→ {project_id}: {provisioner.describe().split(': ', 1)[1]}")
347
356
  try:
@@ -363,7 +372,7 @@ def _provision_nodes(node_root: Path, *, refresh: bool, dry_run: bool) -> bool:
363
372
  # routine when a parent and a grandparent both name it — report
364
373
  # "already available" in a run that fetched nothing.
365
374
  have.add(project_id)
366
- enqueue(target)
375
+ enqueue(target, relation)
367
376
  return failed
368
377
 
369
378
 
@@ -437,6 +446,8 @@ def _cmd_validate(args: argparse.Namespace) -> int:
437
446
  print(f"{entry.declared_in}: connected project '{entry.id}' is declared "
438
447
  f"at {entry.locator}, which is not in this checkout; it was found "
439
448
  f"at {registry.get(entry.id).locator}", file=sys.stderr)
449
+ for warning in registry.warnings():
450
+ print(f"warning: {warning}", file=sys.stderr)
440
451
  from tcw.validate import validate
441
452
  recurse = args.path is None and not args.no_recurse
442
453
  projects = [registry.current, *registry.descendants()] if recurse else [registry.current]
@@ -469,7 +480,7 @@ def _cmd_serve(args: argparse.Namespace) -> int:
469
480
 
470
481
 
471
482
  def build_parser() -> argparse.ArgumentParser:
472
- parser = argparse.ArgumentParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
483
+ parser = SuggestingParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
473
484
  parser.add_argument("--version", action="version", version=f"tcw {__version__}")
474
485
  sub = parser.add_subparsers(dest="group", required=True)
475
486
 
@@ -518,6 +529,7 @@ def build_parser() -> argparse.ArgumentParser:
518
529
  p = sub.add_parser(name, help=f"{name} commands (not yet implemented)")
519
530
  p.set_defaults(func=_not_yet(name))
520
531
 
532
+ attach_index(parser)
521
533
  return parser
522
534
 
523
535
 
@@ -550,9 +562,9 @@ def main(argv: list[str] | None = None) -> int:
550
562
  return 1
551
563
  except subprocess.CalledProcessError as error:
552
564
  # Deliberately generic: the only policy here is "a git subprocess
553
- # failed", which is true of every component. git's own diagnostic has
554
- # already reached the terminal — no `check=True` call in the filesystem
555
- # adapter captures output — so re-printing `error.stderr` would double it.
565
+ # failed", which is true of every component. The index-touching calls
566
+ # capture git's output (so a retried failure does not print once per
567
+ # try), and it is printed here; an uncaptured call has already shown it.
556
568
  # `cmd` is a sequence *or* a string (stdlib contract); joining a string
557
569
  # iterates its characters. No shipped raiser passes one, but a handler
558
570
  # whose whole justification is that it assumes nothing about its caller
@@ -561,6 +573,8 @@ def main(argv: list[str] | None = None) -> int:
561
573
  shlex.join(str(a) for a in error.cmd)
562
574
  print(f"tcw: git command failed (exit {error.returncode}): {cmd}",
563
575
  file=sys.stderr)
576
+ if isinstance(error.stderr, str) and error.stderr.strip():
577
+ print(error.stderr.rstrip("\n"), file=sys.stderr)
564
578
  return 1
565
579
 
566
580
 
@@ -0,0 +1,199 @@
1
+ """Answer a mistyped subcommand with the command that was probably meant.
2
+
3
+ argparse reports a wrong subcommand by listing every valid choice at that level,
4
+ which says nothing when the word exists one level down (`tcw tracker` for
5
+ `tcw work tracker`) or is a synonym (`status` for `show`). `SuggestingParser`
6
+ keeps argparse's message word for word and adds one line after it.
7
+
8
+ Three rules, first match wins:
9
+
10
+ 1. The word is a subcommand somewhere else in the tree: name each full command,
11
+ and complete it with the word that followed when that is wrong there too —
12
+ `tcw tracker status` → `tcw work tracker show`.
13
+ 2. A small table of synonyms, offered only where their target is valid.
14
+ 3. Close spellings among the choices valid at that level.
15
+
16
+ Only subcommands that `--help` lists are ever offered. That is what keeps the
17
+ removed `tcw work stage <id>` spellings — registered without `help=` so that the
18
+ old form can print its replacement — out of every suggestion, without naming
19
+ them here.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import difflib
26
+
27
+ # One clear target each. Words for removal are left out on purpose: `drop`,
28
+ # `delete` and `rm` destroy things, and pointing at one is worse than no hint.
29
+ SYNONYMS: dict[str, tuple[str, ...]] = {
30
+ "status": ("show", "list"),
31
+ "info": ("show",),
32
+ "view": ("show",),
33
+ "get": ("show",),
34
+ "cat": ("show",),
35
+ "ls": ("list",),
36
+ }
37
+
38
+ # Never offered by any rule, and never answered either: pointing somebody at a
39
+ # command that destroys things, when they typed something else, is worse than
40
+ # no hint — and so is guessing what a mistyped removal was meant to remove
41
+ # (`tcw work tracker delete` is not `tcw work delete`, which deletes an item).
42
+ REMOVAL = frozenset({"rm", "drop", "delete"})
43
+
44
+ _MAX = 3
45
+
46
+
47
+ def _subparsers(parser: argparse.ArgumentParser) -> argparse._SubParsersAction | None:
48
+ for action in parser._actions:
49
+ if isinstance(action, argparse._SubParsersAction):
50
+ return action
51
+ return None
52
+
53
+
54
+ def visible_choices(action: argparse._SubParsersAction) -> list[str]:
55
+ """The subcommands this action lists in its help, in its own order."""
56
+ listed = {choice.dest for choice in action._choices_actions}
57
+ return [name for name in action.choices if name in listed]
58
+
59
+
60
+ def _offered(word: str, choices: list[str]) -> list[str]:
61
+ """Rules 2 and 3 for one level: synonyms valid here, else close spellings."""
62
+ safe = [c for c in choices if c not in REMOVAL]
63
+ synonyms = [s for s in SYNONYMS.get(word, ()) if s in safe]
64
+ if synonyms:
65
+ return synonyms
66
+ return difflib.get_close_matches(word, safe, n=_MAX, cutoff=0.6)
67
+
68
+
69
+ def _command(path: tuple[str, ...]) -> str:
70
+ return "`" + " ".join(path) + "`"
71
+
72
+
73
+ def _phrase(commands: list[str]) -> str:
74
+ if len(commands) == 1:
75
+ return f"did you mean {commands[0]}?"
76
+ if len(commands) == 2:
77
+ return f"did you mean {commands[0]} or {commands[1]}?"
78
+ return f"did you mean one of {', '.join(commands)}?"
79
+
80
+
81
+ class SuggestingParser(argparse.ArgumentParser):
82
+ """An `ArgumentParser` whose wrong-subcommand error suggests what was meant.
83
+
84
+ Every subparser gets this class too, since `add_subparsers` defaults to the
85
+ parent's. `attach_index` gives the whole tree what the hint needs."""
86
+
87
+ def __init__(self, *args, **kwargs):
88
+ super().__init__(*args, **kwargs)
89
+ # Python 3.14 has argparse's own close-spelling hint. Off, explicitly,
90
+ # so a later default cannot print a second, different suggestion.
91
+ self.suggest_on_error = False
92
+
93
+ def parse_known_args(self, args=None, namespace=None):
94
+ # The root keeps what it was asked to parse: a nested parser reports its
95
+ # own error and needs the word after the one it rejected.
96
+ if getattr(self, "_tcw_root", None) is self:
97
+ self._tcw_argv = list(args) if args is not None else None
98
+ return super().parse_known_args(args, namespace)
99
+
100
+ def _check_value(self, action, value):
101
+ if not isinstance(action, argparse._SubParsersAction) or value in action.choices:
102
+ return super()._check_value(action, value)
103
+ choices = visible_choices(action)
104
+ hint = self._hint(str(value), choices)
105
+ if len(choices) == len(action.choices):
106
+ # Nothing hidden: argparse's own message, exactly as this Python
107
+ # version words it (3.12 and 3.13 leave the choices unquoted).
108
+ try:
109
+ super()._check_value(action, value)
110
+ except argparse.ArgumentError as error:
111
+ if not hint:
112
+ raise
113
+ raise argparse.ArgumentError(action, f"{error.message}\n{hint}") from None
114
+ message = (f"invalid choice: {value!r} (choose from "
115
+ f"{', '.join(repr(c) for c in choices)})")
116
+ if hint:
117
+ message += f"\n{hint}"
118
+ raise argparse.ArgumentError(action, message)
119
+
120
+ def _hint(self, word: str, choices: list[str]) -> str:
121
+ root = getattr(self, "_tcw_root", None)
122
+ here = getattr(self, "_tcw_path", None)
123
+ if root is None or here is None or word in REMOVAL:
124
+ return ""
125
+ # Same component first — `tcw work tags show` means a `tcw work …` show,
126
+ # not `tcw taxonomy show` — then shallowest: `tcw show` means
127
+ # `tcw work show` before `tcw work inbox show`. `sorted` is stable, so
128
+ # ties keep tree order.
129
+ elsewhere = sorted(
130
+ (p for p in root._tcw_index.get(word, ()) if p[:-1] != here),
131
+ key=lambda p: (-_shared(p, here), len(p)))
132
+ if elsewhere:
133
+ following = _following(root, here, word)
134
+ commands = []
135
+ for path in elsewhere[:_MAX]:
136
+ commands += [_command(path + tail) for tail in _completion(root, path, following)]
137
+ return _phrase(commands[:_MAX])
138
+ offered = _offered(word, choices)
139
+ return _phrase([_command(here + (o,)) for o in offered]) if offered else ""
140
+
141
+
142
+ def _shared(path: tuple[str, ...], here: tuple[str, ...]) -> int:
143
+ """How many leading words `path` shares with `here`."""
144
+ n = 0
145
+ for a, b in zip(path, here):
146
+ if a != b:
147
+ break
148
+ n += 1
149
+ return n
150
+
151
+
152
+ def _following(root: SuggestingParser, here: tuple[str, ...], word: str) -> str:
153
+ """The word after `word` on the command line, if it is not an option."""
154
+ argv = getattr(root, "_tcw_argv", None) or []
155
+ start = len(here) - 1 # `here` includes "tcw"
156
+ for index in range(start, len(argv)):
157
+ if argv[index] == word:
158
+ after = argv[index + 1] if index + 1 < len(argv) else ""
159
+ return "" if after.startswith("-") else after
160
+ return ""
161
+
162
+
163
+ def _completion(root: SuggestingParser, path: tuple[str, ...],
164
+ following: str) -> list[tuple[str, ...]]:
165
+ """How to finish `path` with the word typed after it: kept when it is valid
166
+ there or there is nothing to finish, else what that word was likely meant as."""
167
+ parser = root._tcw_parsers.get(path)
168
+ action = _subparsers(parser) if parser is not None else None
169
+ if not following or action is None:
170
+ return [()]
171
+ choices = visible_choices(action)
172
+ if following in choices:
173
+ return [(following,)]
174
+ offered = _offered(following, choices)
175
+ return [(o,) for o in offered] or [()]
176
+
177
+
178
+ def attach_index(root: SuggestingParser) -> None:
179
+ """Walk the finished tree once: every parser learns its own path and the
180
+ root, and the root learns every listed subcommand's full paths."""
181
+ index: dict[str, list[tuple[str, ...]]] = {}
182
+ parsers: dict[tuple[str, ...], argparse.ArgumentParser] = {}
183
+
184
+ def walk(parser: argparse.ArgumentParser, path: tuple[str, ...]) -> None:
185
+ parser._tcw_root = root
186
+ parser._tcw_path = path
187
+ parsers[path] = parser
188
+ action = _subparsers(parser)
189
+ if action is None:
190
+ return
191
+ listed = set(visible_choices(action))
192
+ for name, child in action.choices.items():
193
+ if name in listed:
194
+ index.setdefault(name, []).append(path + (name,))
195
+ walk(child, path + (name,))
196
+
197
+ walk(root, (root.prog,))
198
+ root._tcw_index = index
199
+ root._tcw_parsers = parsers
@@ -135,6 +135,10 @@ def resolve_tcw_ref(node_root: Path | None, uri: str) -> ResolveResult:
135
135
  return ResolveResult(
136
136
  False, "W", None, qualified_work_ref_problem(node_root, ns_ref))
137
137
  store, bare = resolved
138
+ # A renamed item answers to its old slug too: a link is exactly the kind
139
+ # of reference a rename cannot rewrite.
140
+ if store.get(bare) is None and (renamed := store.renamed(bare)):
141
+ bare = renamed
138
142
  # Locating the store that would hold the item is not the item existing.
139
143
  # `resolve_qualified_work_ref` answers "which store addresses this ref",
140
144
  # which is what `tcw serve`'s routing wants; `ok` answers "does this
@@ -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.
@@ -951,6 +967,7 @@ class TcwHandler(BaseHTTPRequestHandler):
951
967
  initiative = body.get("initiative", "")
952
968
  type_val = body.get("type", "")
953
969
  tags = body.get("tags") or None
970
+ work.refresh_for_creation()
954
971
  detail = work.create_work(
955
972
  title=title,
956
973
  created=created,
@@ -971,6 +988,14 @@ class TcwHandler(BaseHTTPRequestHandler):
971
988
  # stale — which the next sidecar write from the page would
972
989
  # be rejected on.
973
990
  detail = work.get_detail(detail.item.slug) or detail
991
+ # After the owed record, so it is in the same commit. A refusal
992
+ # is logged, not returned: the item exists, and an error here
993
+ # would invite the browser to create it a second time.
994
+ folder = work.path(detail.item.slug)
995
+ if folder is not None and (reason := work.commit_writes(
996
+ f"tcw work: new {detail.item.slug}", folder)):
997
+ print(f"tcw serve: created {detail.item.slug}, but {reason}",
998
+ file=sys.stderr)
974
999
  response = {
975
1000
  "item": _item_payload(work, detail.item.slug, detail.item),
976
1001
  "coreRevision": detail.core_revision,
@@ -993,6 +1018,8 @@ class TcwHandler(BaseHTTPRequestHandler):
993
1018
  slug = _decode_path_param(m.group(1))
994
1019
  action = _decode_path_param(m.group(2))
995
1020
  qslug = slug # preserve the addressed (qualified) slug
1021
+ if self._refused_read_only(slug):
1022
+ return
996
1023
  resolved = self._resolve_work(slug)
997
1024
  if resolved is None:
998
1025
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1216,6 +1243,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1216
1243
  if m:
1217
1244
  slug = _decode_path_param(m.group(1))
1218
1245
  qslug = slug # preserve the addressed (qualified) slug
1246
+ if self._refused_read_only(slug):
1247
+ return
1219
1248
  resolved = self._resolve_work(slug)
1220
1249
  if resolved is None:
1221
1250
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1370,6 +1399,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1370
1399
  if name not in WORK_ARTIFACTS:
1371
1400
  self._send(HTTPStatus.BAD_REQUEST, b"unknown artifact")
1372
1401
  return
1402
+ if self._refused_read_only(slug):
1403
+ return
1373
1404
  resolved = self._resolve_work(slug)
1374
1405
  if resolved is None:
1375
1406
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1408,6 +1439,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1408
1439
  if m:
1409
1440
  slug = _decode_path_param(m.group(1))
1410
1441
  stage_id = _decode_path_param(m.group(2))
1442
+ if self._refused_read_only(slug):
1443
+ return
1411
1444
  resolved = self._resolve_work(slug)
1412
1445
  if resolved is None:
1413
1446
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1443,6 +1476,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1443
1476
  f"{name} is written by `{owner}`, not edited; "
1444
1477
  f"run that command instead.")
1445
1478
  return
1479
+ if self._refused_read_only(slug):
1480
+ return
1446
1481
  resolved = self._resolve_work(slug)
1447
1482
  if resolved is None:
1448
1483
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1498,6 +1533,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1498
1533
  if m:
1499
1534
  slug = _decode_path_param(m.group(1))
1500
1535
  stage_id = _decode_path_param(m.group(2))
1536
+ if self._refused_read_only(slug):
1537
+ return
1501
1538
  resolved = self._resolve_work(slug)
1502
1539
  if resolved is None:
1503
1540
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1515,6 +1552,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1515
1552
  m = re.match(r"^/api/work/([^/]+)$", path)
1516
1553
  if m:
1517
1554
  slug = _decode_path_param(m.group(1))
1555
+ if self._refused_read_only(slug):
1556
+ return
1518
1557
  resolved = self._resolve_work(slug)
1519
1558
  if resolved is None:
1520
1559
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")
@@ -1584,6 +1623,8 @@ class TcwHandler(BaseHTTPRequestHandler):
1584
1623
  slug_q, _, stage_q = middle.partition("/plan-stages/")
1585
1624
  slug = _decode_path_param(slug_q)
1586
1625
  stage_id = _decode_path_param(stage_q)
1626
+ if self._refused_read_only(slug):
1627
+ return
1587
1628
  resolved = self._resolve_work(slug)
1588
1629
  if resolved is None:
1589
1630
  self._send(HTTPStatus.NOT_FOUND, b"no such work item")