tcw-cli 2.5.1__tar.gz → 2.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. {tcw_cli-2.5.1/tcw_cli.egg-info → tcw_cli-2.6.1}/PKG-INFO +19 -10
  2. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/README.md +18 -9
  3. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/pyproject.toml +1 -1
  4. tcw_cli-2.6.1/tcw/__init__.py +1 -0
  5. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/store/base.py +60 -23
  6. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/store/fs.py +5 -2
  7. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/tracker/claim.py +18 -0
  8. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/tracker/intake.py +70 -8
  9. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/tracker/ownership.py +51 -14
  10. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/tracker/progress.py +2 -2
  11. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/tracker/sync.py +453 -181
  12. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/cli.py +327 -84
  13. {tcw_cli-2.5.1 → tcw_cli-2.6.1/tcw_cli.egg-info}/PKG-INFO +19 -10
  14. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw_cli.egg-info/SOURCES.txt +3 -0
  15. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_claim.py +10 -0
  16. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_claimability.py +18 -1
  17. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_cli.py +28 -2
  18. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_comment.py +14 -1
  19. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_config.py +42 -19
  20. tcw_cli-2.6.1/tests/test_tracker_gate.py +125 -0
  21. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_help.py +22 -5
  22. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_import.py +67 -3
  23. tcw_cli-2.6.1/tests/test_tracker_import_returns.py +162 -0
  24. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_inheritance.py +10 -12
  25. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_ownership.py +74 -0
  26. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_pre_backlog.py +105 -15
  27. tcw_cli-2.6.1/tests/test_tracker_strict.py +1687 -0
  28. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_sync.py +955 -176
  29. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_validate.py +14 -2
  30. tcw_cli-2.6.1/tests/test_work_start.py +48 -0
  31. tcw_cli-2.5.1/tcw/__init__.py +0 -1
  32. tcw_cli-2.5.1/tests/test_tracker_strict.py +0 -834
  33. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/LICENSE +0 -0
  34. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/setup.cfg +0 -0
  35. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/capabilities/__init__.py +0 -0
  36. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/capabilities/cli.py +0 -0
  37. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/cli.py +0 -0
  38. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/harness.py +0 -0
  39. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/refs.py +0 -0
  40. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/serve/__init__.py +0 -0
  41. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/serve/dist/client/assets/index-CNxlcI8A.js +0 -0
  42. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  43. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/serve/dist/client/index.html +0 -0
  44. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/serve/dist/client/theme-init.js +0 -0
  45. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/serve/dist/server.cjs +0 -0
  46. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/serve/runtime.py +0 -0
  47. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/stdin.py +0 -0
  48. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/store/__init__.py +0 -0
  49. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/store/checkouts.py +0 -0
  50. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/store/config_edit.py +0 -0
  51. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/store/project.py +0 -0
  52. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/taxonomy/__init__.py +0 -0
  53. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/taxonomy/cli.py +0 -0
  54. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/tracker/__init__.py +0 -0
  55. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/tracker/create.py +0 -0
  56. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/tracker/jira.py +0 -0
  57. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/validate.py +0 -0
  58. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/__init__.py +0 -0
  59. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/generate.py +0 -0
  60. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/hooks.py +0 -0
  61. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/audit-backlog.md +0 -0
  62. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/consolidate-plans.md +0 -0
  63. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/create-work.md +0 -0
  64. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/decompose.md +0 -0
  65. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/delegation.md +0 -0
  66. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/documentation-sync.md +0 -0
  67. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/post-mortem.md +0 -0
  68. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/search.md +0 -0
  69. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/triage-issues.md +0 -0
  70. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/procedures/unattended-work.md +0 -0
  71. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/projection.py +0 -0
  72. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/prompts/implement.md +0 -0
  73. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/prompts/inbox.md +0 -0
  74. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/prompts/plan.md +0 -0
  75. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/prompts/postmortem.md +0 -0
  76. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/prompts/request.md +0 -0
  77. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/prompts/spec.md +0 -0
  78. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/prompts/verify.md +0 -0
  79. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/recursion.py +0 -0
  80. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/resolve.py +0 -0
  81. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw/work/templates.py +0 -0
  82. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw_cli.egg-info/dependency_links.txt +0 -0
  83. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw_cli.egg-info/entry_points.txt +0 -0
  84. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw_cli.egg-info/requires.txt +0 -0
  85. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tcw_cli.egg-info/top_level.txt +0 -0
  86. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_body_prompt.py +0 -0
  87. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_capabilities.py +0 -0
  88. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_capabilities_federation.py +0 -0
  89. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_capabilities_reset.py +0 -0
  90. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_capabilities_rm.py +0 -0
  91. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_capabilities_sidecar.py +0 -0
  92. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_capability_gate_children.py +0 -0
  93. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_capability_ref_wording.py +0 -0
  94. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_check_versions.py +0 -0
  95. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_child_status.py +0 -0
  96. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_cli_help_coverage.py +0 -0
  97. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_committed_paths.py +0 -0
  98. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_config_edit.py +0 -0
  99. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_config_edit_writers.py +0 -0
  100. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_configuration_text_home.py +0 -0
  101. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_cut_version.py +0 -0
  102. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_documentation_config.py +0 -0
  103. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_documentation_prompt.py +0 -0
  104. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_documentation_sync_wiring.py +0 -0
  105. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_documented_cli_surface.py +0 -0
  106. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_dynamic_skill_marker.py +0 -0
  107. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_edit_type.py +0 -0
  108. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_environment_hardness.py +0 -0
  109. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_epic_completable.py +0 -0
  110. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_eval_coverage.py +0 -0
  111. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_eval_files_changed.py +0 -0
  112. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_eval_fixture.py +0 -0
  113. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_eval_grading.py +0 -0
  114. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_eval_runner.py +0 -0
  115. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_external_work_store.py +0 -0
  116. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_falsification_rule.py +0 -0
  117. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_generate_hook.py +0 -0
  118. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_harness.py +0 -0
  119. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_inbox_title.py +0 -0
  120. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_legacy_store_config.py +0 -0
  121. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_lifecycle_baseline.py +0 -0
  122. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_lifecycle_hooks.py +0 -0
  123. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_lifecycle_inert.py +0 -0
  124. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_lifecycle_policy.py +0 -0
  125. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_lifecycle_validation.py +0 -0
  126. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_multiproject.py +0 -0
  127. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_non_git_writes.py +0 -0
  128. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_plugin_manifests.py +0 -0
  129. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_procedure_config.py +0 -0
  130. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_procedure_verb.py +0 -0
  131. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_project_overrides.py +0 -0
  132. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_project_registry.py +0 -0
  133. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_projection.py +0 -0
  134. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_prompt_fallback.py +0 -0
  135. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_qualified_ref.py +0 -0
  136. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_recursion.py +0 -0
  137. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_refs.py +0 -0
  138. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_remote_session_setup.py +0 -0
  139. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_repo_lifecycle.py +0 -0
  140. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_resolve.py +0 -0
  141. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_resolve_procedure.py +0 -0
  142. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_retention.py +0 -0
  143. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_scaffold.py +0 -0
  144. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_serve.py +0 -0
  145. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_serve_descendants.py +0 -0
  146. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_serve_projection.py +0 -0
  147. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_serve_resolve.py +0 -0
  148. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_serve_runtime.py +0 -0
  149. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_serve_write.py +0 -0
  150. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_session_bootstrap.py +0 -0
  151. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_shipped_procedures.py +0 -0
  152. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_shipped_prompts.py +0 -0
  153. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_show_json.py +0 -0
  154. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_skill_flow.py +0 -0
  155. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_skill_lifecycle_parity.py +0 -0
  156. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_skill_path_pointers.py +0 -0
  157. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_smoke.py +0 -0
  158. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_stage_validate.py +0 -0
  159. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_stage_verb.py +0 -0
  160. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_status_parity.py +0 -0
  161. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_stdin.py +0 -0
  162. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_stdin_cli.py +0 -0
  163. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_store_bounds.py +0 -0
  164. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_store_editor.py +0 -0
  165. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_store_nodes.py +0 -0
  166. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_store_provisioning.py +0 -0
  167. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_store_publication.py +0 -0
  168. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_subprocess_stdin.py +0 -0
  169. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_taxonomy.py +0 -0
  170. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tombstone.py +0 -0
  171. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_absent.py +0 -0
  172. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_binding.py +0 -0
  173. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_client.py +0 -0
  174. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_create.py +0 -0
  175. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_hold.py +0 -0
  176. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_link.py +0 -0
  177. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_replay.py +0 -0
  178. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_surface.py +0 -0
  179. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_tracker_sync_config.py +0 -0
  180. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_unattended_work_skill.py +0 -0
  181. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_unpushed_version_script.py +0 -0
  182. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_validate.py +0 -0
  183. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_validate_target.py +0 -0
  184. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_work.py +0 -0
  185. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_work_autocommit.py +0 -0
  186. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_work_review.py +0 -0
  187. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_work_tags.py +0 -0
  188. {tcw_cli-2.5.1 → tcw_cli-2.6.1}/tests/test_worktree_completion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tcw-cli
3
- Version: 2.5.1
3
+ Version: 2.6.1
4
4
  Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -727,14 +727,15 @@ to the transitions.
727
727
 
728
728
  | Lifecycle step | Without Jira | With Jira configured |
729
729
  | --------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
730
- | an item is created | `tcw work inbox accept`, `new` | `tcw work tracker import <ticket>` claims the ticket (starts it and assigns it to you), then creates a backlog item whose intake is the ticket. With `inbox-query` set, `tcw work inbox accept <ticket>` does the same |
730
+ | an item is created | `tcw work inbox accept`, `new` | `tcw work tracker import <ticket>` claims the ticket (starts it and assigns it to you), then creates a backlog item whose intake is the ticket and puts the ticket back where it was, still assigned to you. With `inbox-query` set, `tcw work inbox accept <ticket>` does the same |
731
731
  | `request`, `spec`, `plan` stages | unchanged | unchanged |
732
732
  | at any time | — | `tcw work tracker link` / `unlink` records or removes the link between an existing item and a ticket; Jira itself is not touched |
733
733
  | an item has no ticket | — | `tcw work tracker create <slug>` makes one for it and binds it; `--all` sweeps every open item without one. Needs `work.tracker.create` and `statuses.backlog`. With `create.on-new`, filing an item makes its ticket |
734
- | `start` | moves the item | also claims a linked ticket, and posts a comment if `comments: true` |
735
- | `submit`, `rework`, `complete`, discard | moves the item | also moves a linked ticket to the Jira status mapped under `statuses`, and posts a comment if `comments: true` |
734
+ | `start` | moves the item | also claims a linked ticket (assigns it to you) and moves it to the status mapped for `active`; a ticket already past that stays where it is. Posts a comment if `comments: true` |
735
+ | `submit`, `rework` | moves the item | refused before the item moves unless a linked ticket is assigned to you (`tcw work tracker claim` takes it). Then moves the ticket to the Jira status mapped under `statuses`, and posts a comment if `comments: true` |
736
+ | `complete`, discard | moves the item | moves a linked ticket to its mapped status whoever holds it — finishing work needs no claim — and posts a comment if `comments: true` |
736
737
  | the ticket could not follow | — | the item still moves; the command exits 1 and records the ticket as pending or conflicting; `tcw work tracker sync` retries |
737
- | the ticket is behind its item | — | with `tcw work tracker link --sync-status`, a ticket linked to work already under way is claimed and moved forward to where the item is |
738
+ | the ticket is behind its item | — | for a ticket linked to work already under way: `tcw work tracker claim <slug>`, then `tcw work tracker sync <slug>` |
738
739
  | the ticket does not match its item | — | `tcw work tracker sync <slug>` puts it where the item's status says, whether the ticket is ahead of the item or behind it; a backwards move says so |
739
740
 
740
741
  Setting `strict: true` changes several steps, so that no work happens without a
@@ -744,9 +745,17 @@ claimed ticket:
744
745
  and point you at `tcw work tracker import`. Where `inbox-query` is set, `inbox accept`
745
746
  of a ticket is allowed, because it claims the ticket.
746
747
  - `start` refuses an item with no ticket. For a linked item it claims the ticket
747
- first, and moves the item only if the claim worked.
748
- - `submit`, `rework` and `complete --resolution done` read the ticket first, and
749
- refuse unless it is assigned to you and where the item's last move left it.
748
+ first, through the transition `exclusive-claim-transition` names, and moves the
749
+ item only if the claim worked and the workflow would refuse a second person —
750
+ that is, it does not offer that transition again once the ticket has it. Every
751
+ strict claim checks this, `tracker claim` and `tracker import` included.
752
+ - Strict mode needs `exclusive-claim-transition` and `transitions.start` set, and
753
+ `tcw validate` reports either one missing: `tracker import`, the way work is
754
+ created, claims through `transitions.start`.
755
+ - `submit` and `rework` read the ticket first, and refuse unless it is assigned to
756
+ you and where the item's last move left it. `complete --resolution done` refuses
757
+ only if the ticket is not where the item's last move left it: who holds it does
758
+ not matter for finishing work.
750
759
  - Discarding is always allowed. `drop` refuses an item that was ever linked, so
751
760
  the record stays.
752
761
  - While Jira cannot be reached, those commands refuse, and `tcw serve` refuses
@@ -788,9 +797,9 @@ e-mail address or token themselves.
788
797
 
789
798
  ```sh
790
799
  tcw work tracker list # TCW: lists tickets the query selects. Jira: unchanged
791
- tcw work tracker import ENG-482 # TCW: creates a backlog item. Jira: ENG-482 → In Progress, assigned to you
800
+ tcw work tracker import ENG-482 # TCW: creates a backlog item. Jira: ENG-482 stays in To Do, assigned to you
792
801
  # …the request, spec and plan stages run as usual…
793
- tcw work start <slug> # TCW: backlog → active. Jira: confirms the claim import made
802
+ tcw work start <slug> # TCW: backlog → active. Jira: ENG-482 → In Progress
794
803
  tcw work submit <slug> # TCW: active → review. Jira: ENG-482 → In Review
795
804
  tcw work complete <slug> --resolution done --confirm
796
805
  # TCW: review → completed. Jira: ENG-482 → Done
@@ -511,14 +511,15 @@ to the transitions.
511
511
 
512
512
  | Lifecycle step | Without Jira | With Jira configured |
513
513
  | --------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
514
- | an item is created | `tcw work inbox accept`, `new` | `tcw work tracker import <ticket>` claims the ticket (starts it and assigns it to you), then creates a backlog item whose intake is the ticket. With `inbox-query` set, `tcw work inbox accept <ticket>` does the same |
514
+ | an item is created | `tcw work inbox accept`, `new` | `tcw work tracker import <ticket>` claims the ticket (starts it and assigns it to you), then creates a backlog item whose intake is the ticket and puts the ticket back where it was, still assigned to you. With `inbox-query` set, `tcw work inbox accept <ticket>` does the same |
515
515
  | `request`, `spec`, `plan` stages | unchanged | unchanged |
516
516
  | at any time | — | `tcw work tracker link` / `unlink` records or removes the link between an existing item and a ticket; Jira itself is not touched |
517
517
  | an item has no ticket | — | `tcw work tracker create <slug>` makes one for it and binds it; `--all` sweeps every open item without one. Needs `work.tracker.create` and `statuses.backlog`. With `create.on-new`, filing an item makes its ticket |
518
- | `start` | moves the item | also claims a linked ticket, and posts a comment if `comments: true` |
519
- | `submit`, `rework`, `complete`, discard | moves the item | also moves a linked ticket to the Jira status mapped under `statuses`, and posts a comment if `comments: true` |
518
+ | `start` | moves the item | also claims a linked ticket (assigns it to you) and moves it to the status mapped for `active`; a ticket already past that stays where it is. Posts a comment if `comments: true` |
519
+ | `submit`, `rework` | moves the item | refused before the item moves unless a linked ticket is assigned to you (`tcw work tracker claim` takes it). Then moves the ticket to the Jira status mapped under `statuses`, and posts a comment if `comments: true` |
520
+ | `complete`, discard | moves the item | moves a linked ticket to its mapped status whoever holds it — finishing work needs no claim — and posts a comment if `comments: true` |
520
521
  | the ticket could not follow | — | the item still moves; the command exits 1 and records the ticket as pending or conflicting; `tcw work tracker sync` retries |
521
- | the ticket is behind its item | — | with `tcw work tracker link --sync-status`, a ticket linked to work already under way is claimed and moved forward to where the item is |
522
+ | the ticket is behind its item | — | for a ticket linked to work already under way: `tcw work tracker claim <slug>`, then `tcw work tracker sync <slug>` |
522
523
  | the ticket does not match its item | — | `tcw work tracker sync <slug>` puts it where the item's status says, whether the ticket is ahead of the item or behind it; a backwards move says so |
523
524
 
524
525
  Setting `strict: true` changes several steps, so that no work happens without a
@@ -528,9 +529,17 @@ claimed ticket:
528
529
  and point you at `tcw work tracker import`. Where `inbox-query` is set, `inbox accept`
529
530
  of a ticket is allowed, because it claims the ticket.
530
531
  - `start` refuses an item with no ticket. For a linked item it claims the ticket
531
- first, and moves the item only if the claim worked.
532
- - `submit`, `rework` and `complete --resolution done` read the ticket first, and
533
- refuse unless it is assigned to you and where the item's last move left it.
532
+ first, through the transition `exclusive-claim-transition` names, and moves the
533
+ item only if the claim worked and the workflow would refuse a second person —
534
+ that is, it does not offer that transition again once the ticket has it. Every
535
+ strict claim checks this, `tracker claim` and `tracker import` included.
536
+ - Strict mode needs `exclusive-claim-transition` and `transitions.start` set, and
537
+ `tcw validate` reports either one missing: `tracker import`, the way work is
538
+ created, claims through `transitions.start`.
539
+ - `submit` and `rework` read the ticket first, and refuse unless it is assigned to
540
+ you and where the item's last move left it. `complete --resolution done` refuses
541
+ only if the ticket is not where the item's last move left it: who holds it does
542
+ not matter for finishing work.
534
543
  - Discarding is always allowed. `drop` refuses an item that was ever linked, so
535
544
  the record stays.
536
545
  - While Jira cannot be reached, those commands refuse, and `tcw serve` refuses
@@ -572,9 +581,9 @@ e-mail address or token themselves.
572
581
 
573
582
  ```sh
574
583
  tcw work tracker list # TCW: lists tickets the query selects. Jira: unchanged
575
- tcw work tracker import ENG-482 # TCW: creates a backlog item. Jira: ENG-482 → In Progress, assigned to you
584
+ tcw work tracker import ENG-482 # TCW: creates a backlog item. Jira: ENG-482 stays in To Do, assigned to you
576
585
  # …the request, spec and plan stages run as usual…
577
- tcw work start <slug> # TCW: backlog → active. Jira: confirms the claim import made
586
+ tcw work start <slug> # TCW: backlog → active. Jira: ENG-482 → In Progress
578
587
  tcw work submit <slug> # TCW: active → review. Jira: ENG-482 → In Review
579
588
  tcw work complete <slug> --resolution done --confirm
580
589
  # TCW: review → completed. Jira: ENG-482 → Done
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tcw-cli"
7
- version = "2.5.1"
7
+ version = "2.6.1"
8
8
  description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "2.6.1"
@@ -1259,11 +1259,11 @@ TRACKER_CREDENTIAL_KEYS = frozenset({"email-env", "token-env"})
1259
1259
  # These name *how* it gets there, and are needed only where the status cannot say: a
1260
1260
  # workflow offering two transitions into one status — a "finished" and an "abandoned"
1261
1261
  # route both landing in `Done` is the common shape — is otherwise unreachable, since
1262
- # TCW will not guess which. A move with no name here keeps the status-derived rule.
1263
- # `start` is the exception and is required: a start applies its transition through the
1264
- # claim rather than through `assess_move`, so it has no status-derived rule to fall
1265
- # back to. An unknown key is reported, because silently ignoring a key someone set is
1266
- # silently not doing what they asked.
1262
+ # TCW will not guess which. A move with no name here keeps the status-derived rule, and
1263
+ # that now includes `start`: a start goes through `assess_move` like the other four, so
1264
+ # it derives its transition from the status it is heading for when nobody names one. An
1265
+ # unknown key is reported, because silently ignoring a key someone set is silently not
1266
+ # doing what they asked.
1267
1267
  TRACKER_TRANSITION_KEYS = frozenset({"start", "submit", "rework", "complete", "discard"})
1268
1268
  TRACKER_MOVE_TRANSITION_KEYS = ("start", "submit", "rework", "complete", "discard")
1269
1269
  # A key that used to exist, with what replaced it, so a config written for an older
@@ -1275,6 +1275,23 @@ TRACKER_RENAMED_KEYS = {
1275
1275
  "renamed to work.tracker.transitions.start, the transition the start move "
1276
1276
  "applies, alongside submit, rework, complete and discard",
1277
1277
  }
1278
+ # Strict mode promises that only one person can take a ticket. A claim that applies
1279
+ # this transition first keeps that promise, because a workflow that will not apply
1280
+ # it to a ticket already taken stops the second person there.
1281
+ STRICT_NEEDS_EXCLUSIVE_CLAIM = (
1282
+ "work.tracker.exclusive-claim-transition: required when strict is true. Strict "
1283
+ "mode promises that only one person can take a ticket, and this transition, "
1284
+ "which your workflow will not apply to a ticket that has already been taken, "
1285
+ "is what stops a second person. Name the transition that takes a ticket into "
1286
+ "work.")
1287
+ # Strict mode creates work only from a ticket, and the two commands that do it claim
1288
+ # through this transition, so a strict project without it can create no work at all.
1289
+ STRICT_NEEDS_START_TRANSITION = (
1290
+ "work.tracker.transitions.start: required when strict is true. Strict mode "
1291
+ "creates work only from a ticket, and the two commands that do that — `tcw work "
1292
+ "tracker import` and `tcw work inbox accept` — claim the ticket through this "
1293
+ "transition. Name the transition that takes a ticket from the backlog status "
1294
+ "into work.")
1278
1295
  # `backlog` first: it is where an item begins, and where creation puts its ticket.
1279
1296
  # The other four are the statuses a *bound* ticket is moved through afterwards.
1280
1297
  TRACKER_STATUS_KEYS = ("backlog", "active", "review", "completed", "discarded")
@@ -1431,7 +1448,13 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
1431
1448
  return value
1432
1449
 
1433
1450
  credentials = nested("credentials", TRACKER_CREDENTIAL_KEYS)
1434
- transitions = nested("transitions", TRACKER_TRANSITION_KEYS)
1451
+ # Optional — every move derives its transition from the status when nobody names
1452
+ # one — so a lone `null` is a wrong value rather than a missing required one, the
1453
+ # shape `inbox-query` above and `exclusive-claim-transition` below both use.
1454
+ if "transitions" in raw and raw["transitions"] is None:
1455
+ problems.append("work.tracker.transitions: expected a mapping, got NoneType")
1456
+ transitions = (nested("transitions", TRACKER_TRANSITION_KEYS)
1457
+ if raw.get("transitions") is not None else {})
1435
1458
 
1436
1459
  def nested_str(block: dict, key: str, path: str) -> str:
1437
1460
  value = block.get(key)
@@ -1453,8 +1476,10 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
1453
1476
  f"got {type(strict).__name__}")
1454
1477
  strict = False
1455
1478
  if strict:
1456
- # Strict mode gates completing and discarding against these, so a missing
1457
- # one would be a gate that can never pass or never check.
1479
+ # Strict mode gates completing and discarding against these statuses, so a
1480
+ # missing one would be a gate that can never pass or never check. It also
1481
+ # needs `exclusive-claim-transition`, the only thing that makes a claim
1482
+ # exclusive; that check is after these.
1458
1483
  for key in ("active", "completed"):
1459
1484
  if not statuses.get(key):
1460
1485
  problems.append(f"work.tracker.statuses.{key}: required when "
@@ -1466,6 +1491,10 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
1466
1491
  problems.append(f"work.tracker.statuses.discarded: required when strict "
1467
1492
  f"is true, as one status or one for each of "
1468
1493
  f"{', '.join(sorted(resolutions))}")
1494
+ # Absent only: a key written as `null` or blank already has its own problem
1495
+ # below, and one problem per key is enough.
1496
+ if "exclusive-claim-transition" not in raw:
1497
+ problems.append(STRICT_NEEDS_EXCLUSIVE_CLAIM)
1469
1498
 
1470
1499
  comments = raw.get("comments", False)
1471
1500
  if not isinstance(comments, bool):
@@ -1476,21 +1505,20 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
1476
1505
 
1477
1506
  email_env = nested_str(credentials, "email-env", "credentials.email-env")
1478
1507
  token_env = nested_str(credentials, "token-env", "credentials.token-env")
1479
- # Optional, so a lone `null` is a wrong value rather than a missing required one —
1480
- # the same shape `inbox-query` uses above.
1508
+ # Optional unless strict (required above, only when the key is absent), so a lone
1509
+ # `null` is a wrong value rather than a missing required one — the same shape
1510
+ # `inbox-query` uses above.
1481
1511
  if "exclusive-claim-transition" in raw and raw["exclusive-claim-transition"] is None:
1482
1512
  problems.append("work.tracker.exclusive-claim-transition: expected a "
1483
1513
  "non-empty string, got NoneType")
1484
1514
  exclusive_claim = (required_str("exclusive-claim-transition")
1485
1515
  if raw.get("exclusive-claim-transition") is not None else "")
1486
1516
  move_transitions = _parse_tracker_transitions(transitions, problems)
1487
- # `start` is the one move whose transition is required: it is the only move with no
1488
- # status-derived fallback, because a start applies its transition through the claim
1489
- # rather than through `assess_move`. A wrong *value* is reported by the loop above,
1490
- # like its four siblings; an absent *key* is reported here.
1491
- if "start" not in transitions:
1492
- problems.append("work.tracker.transitions.start: required")
1493
1517
  start = move_transitions.get("start", "")
1518
+ # After `transitions` is parsed, and absent only — the same rule as
1519
+ # `exclusive-claim-transition`: `null` or blank already has its own problem.
1520
+ if strict and "start" not in transitions:
1521
+ problems.append(STRICT_NEEDS_START_TRANSITION)
1494
1522
 
1495
1523
  timeout: Any = raw.get("timeout-seconds", TRACKER_DEFAULT_TIMEOUT)
1496
1524
  # `bool` before `int`, because a bool *is* an int and `timeout-seconds: true`
@@ -1674,9 +1702,8 @@ def _parse_tracker_pre_backlog(raw: Any, statuses: dict, problems: list[str]) ->
1674
1702
  def _parse_tracker_transitions(raw: dict, problems: list[str]) -> dict:
1675
1703
  """The per-move keys of `work.tracker.transitions`, appending a problem per defect.
1676
1704
 
1677
- All five keys are parsed here, uniformly. That `start` is required is the
1678
- caller's check, not this one's: absent is `{}` here, which leaves every move on
1679
- the status-derived rule.
1705
+ All five keys are parsed here, uniformly. Absent is `{}`, which leaves every
1706
+ move on the status-derived rule; no key here is required.
1680
1707
  """
1681
1708
  where = "work.tracker.transitions"
1682
1709
 
@@ -1842,7 +1869,7 @@ def attribute_tracker_problems(problems: list[str], record: dict[TrackerKeyPath,
1842
1869
  """Prefix each parser problem with the label of the file that caused it.
1843
1870
 
1844
1871
  Matched on the problem's exact key path, never the nearest enclosing
1845
- mapping: `transitions.start: required` under a `transitions` an ancestor
1872
+ mapping: `credentials.token-env: required` under a `credentials` an ancestor
1846
1873
  supplied is about a key nobody set, and goes to `own_label`. The parser
1847
1874
  writes a path as its keys joined with `.` followed by `: `, so the longest
1848
1875
  recorded path whose spelling prefixes the problem that way is the one it is
@@ -2925,13 +2952,21 @@ class MultipleMatch(Exception):
2925
2952
 
2926
2953
 
2927
2954
  class AlreadyClaimed(IllegalTransition):
2928
- """A single-winner start lost to an existing active claim."""
2955
+ """A single-winner start lost to an existing active claim.
2956
+
2957
+ Names the command that takes the item over, because a refusal that stops somebody
2958
+ has to say what to do instead. `tcw work tracker claim --take-over` is the one
2959
+ for an item whose work is shared through a tracker; `start --take-over` stays for
2960
+ a project with none, and for recovering an interrupted claim."""
2929
2961
 
2930
2962
  def __init__(self, slug: str, owner: str = "", started: str = ""):
2931
2963
  self.slug, self.owner, self.started = slug, owner, started
2932
2964
  who = owner or "an unknown owner"
2933
2965
  when = started or "an unknown time"
2934
- super().__init__(f"{slug} is already claimed by {who} since {when}")
2966
+ super().__init__(f"{slug} is already claimed by {who} since {when}. To take "
2967
+ f"it over, run `tcw work tracker claim {slug} --take-over` "
2968
+ f"(or `tcw work start {slug} --take-over` where no tracker "
2969
+ f"is configured)")
2935
2970
 
2936
2971
 
2937
2972
  @dataclass
@@ -3876,7 +3911,9 @@ class WorkStore(ABC):
3876
3911
  take_over: bool = False) -> WorkItem:
3877
3912
  item = self._require(slug)
3878
3913
  if item.status == "active":
3879
- if not take_over:
3914
+ # Active with nobody holding it — what `tcw work tracker release` leaves —
3915
+ # is taken, not refused: there is nobody to displace.
3916
+ if not take_over and item.owner:
3880
3917
  raise AlreadyClaimed(slug, item.owner, item.started)
3881
3918
  if not owner:
3882
3919
  raise ValueError("takeover requires an owner")
@@ -4053,7 +4053,9 @@ class FsWorkStore(FsTreeStore, WorkStore):
4053
4053
  if item is None:
4054
4054
  raise ValueError(f"no such work item: {slug}")
4055
4055
  if item.status == "active":
4056
- if not take_over:
4056
+ # Active with nobody holding it — what `tcw work tracker release` leaves —
4057
+ # is taken, not refused: there is nobody to displace.
4058
+ if not take_over and item.owner:
4057
4059
  raise AlreadyClaimed(slug, item.owner, item.started)
4058
4060
  if not owner:
4059
4061
  raise ValueError("takeover requires an owner")
@@ -4061,7 +4063,8 @@ class FsWorkStore(FsTreeStore, WorkStore):
4061
4063
  {"owner": owner, "started": started})
4062
4064
  if self.auto_commit_transitions():
4063
4065
  rel = str(self._require_dir(slug).relative_to(self.store_git_root))
4064
- err = git_commit_result(self.store_git_root, f"tcw work: take over {slug}", rel)
4066
+ what = "take over" if item.owner else "claim"
4067
+ err = git_commit_result(self.store_git_root, f"tcw work: {what} {slug}", rel)
4065
4068
  if err:
4066
4069
  raise TransitionCommitError(f"{slug} was taken over, but committing it failed:\n{err}")
4067
4070
  return self._require(slug)
@@ -48,6 +48,12 @@ CLAIM_NOT_OFFERED = "claim not offered by this ticket"
48
48
  # reporting one at all.
49
49
  MISCONFIGURED = "misconfigured"
50
50
  AMBIGUOUS = "ambiguous"
51
+ # The project named no start transition at all. `work.tracker.transitions.start` is
52
+ # optional — a lifecycle start works its transition out from the status it is heading
53
+ # for — but a claim has no target status of its own to derive from. Distinct from
54
+ # `CLAIM_NOT_OFFERED`, which is about a name this ticket does not offer: that one
55
+ # sends the reader to the ticket's workflow, and this one to their own configuration.
56
+ NOT_CONFIGURED = "no start transition configured"
51
57
 
52
58
 
53
59
  @dataclass(frozen=True)
@@ -92,6 +98,18 @@ def assess(claim_transition: str, *, current_status: str, offered,
92
98
  With no `landing_status` and no offered claim, the answer is `NOT_DETERMINED`.
93
99
  That is a real limitation, reported rather than papered over.
94
100
  """
101
+ # Nothing configured. Held here rather than in each caller: every reader of
102
+ # `config.start_transition` that has no status to derive from goes through this
103
+ # function, so one guard covers all of them.
104
+ if not claim_transition.strip():
105
+ return Assessment(
106
+ claimable=NOT_CLAIMABLE,
107
+ exclusivity=NOT_DETERMINED,
108
+ verdict=NOT_CONFIGURED,
109
+ detail=("work.tracker.transitions.start names no transition, so there is "
110
+ "none to claim this ticket through."),
111
+ )
112
+
95
113
  wanted = _normalize(claim_transition)
96
114
  matches = [t for t in offered if _normalize(t.name) == wanted]
97
115
  offered_names = [t.name for t in offered]
@@ -195,15 +195,17 @@ def find_binding(store, *, project: str, provider: str, ticket_id: str,
195
195
 
196
196
  def binding_document(*, provider: str, project: str, part: str, ticket_id: str,
197
197
  ticket_key: str, ticket_url: str, bound: str,
198
- unlinked: list, status_synced: bool = True,
199
- catch_up: bool = False) -> str:
198
+ unlinked: list, status_synced: bool = True) -> str:
200
199
  """The `tracker.yaml` text for a new binding. No credential goes in it.
201
200
 
202
201
  The document records that an item and a ticket are the same work and nothing
203
202
  more: it names no account, because binding does not claim the ticket.
204
203
  `status_synced=False` notes that the item was already past `backlog` and the
205
- ticket was not brought along; `catch_up=True` that `link --sync-status` asked for
206
- it to be.
204
+ ticket was not brought along.
205
+
206
+ Nothing writes `catch-up: true` any more — `link --sync-status` did, and is
207
+ retired — but a binding already carrying it is still read (`Bound.catch_up`), so
208
+ the multi-rung walk it asked for stays reachable until those bindings age out.
207
209
  """
208
210
  document = {
209
211
  "schema": 1,
@@ -216,8 +218,6 @@ def binding_document(*, provider: str, project: str, part: str, ticket_id: str,
216
218
  }
217
219
  if not status_synced:
218
220
  document["status-synced"] = False
219
- if catch_up:
220
- document["catch-up"] = True
221
221
  return yaml.safe_dump(document, sort_keys=False, allow_unicode=True)
222
222
 
223
223
 
@@ -445,6 +445,9 @@ class ClaimOutcome:
445
445
  # when no such step ran. Set on success and on a refusal alike: either way the
446
446
  # ticket has left triage, and the caller has to say so (`moved_out`).
447
447
  left_status: str = ""
448
+ # The status the claim found the ticket in, after any `pre-backlog` step: where
449
+ # `import` puts a ticket its claim moved (`put_back`). Set only on a claim.
450
+ claimed_from: str = ""
448
451
 
449
452
 
450
453
  def _fields(issue: dict) -> tuple[str, str, str, str]:
@@ -625,7 +628,8 @@ def claim(client, ticket: TicketRead) -> ClaimOutcome:
625
628
  if left:
626
629
  error.left_status = left
627
630
  raise
628
- return replace(outcome, left_status=left)
631
+ return replace(outcome, left_status=left,
632
+ claimed_from=ticket.status if outcome.claimed else "")
629
633
 
630
634
 
631
635
  def _claim_from(client, ticket: TicketRead) -> ClaimOutcome:
@@ -641,7 +645,7 @@ def _claim_from(client, ticket: TicketRead) -> ClaimOutcome:
641
645
  Authentication, permission, rate-limit and not-found errors on the transition
642
646
  propagate: the transition did not apply and there is nothing to read back.
643
647
  """
644
- from tcw.tracker.claim import AMBIGUOUS, _normalize, assess
648
+ from tcw.tracker.claim import AMBIGUOUS, NOT_CONFIGURED, _normalize, assess
645
649
  from tcw.tracker.jira import (TrackerAuthError, TrackerError, TrackerNotFound,
646
650
  TrackerPermissionError, TrackerRateLimited,
647
651
  TrackerRequestInvalid)
@@ -673,9 +677,23 @@ def _claim_from(client, ticket: TicketRead) -> ClaimOutcome:
673
677
  f"is ambiguous on this ticket.", assessment.detail)
674
678
  matches = [t for t in ticket.offered if _normalize(t.name) == _normalize(name)]
675
679
  if not matches:
680
+ # Row `1e` first, and that order is load-bearing: a ticket this account
681
+ # already holds needs no transition, which is what lets a claim that
682
+ # succeeded and then failed locally finish on a re-run, and what lets
683
+ # somebody take a ticket with `tcw work tracker claim` — assignment only —
684
+ # and then import it. A refusal above this row would break both.
676
685
  if ticket.assignee_id == ticket.me_id:
677
686
  return claimed("1e", f"not claimed by this run: {key} is already in "
678
687
  f"'{status}' and assigned to you.", status, False)
688
+ if assessment.verdict == NOT_CONFIGURED:
689
+ # Refused rather than derived: a claim is not a lifecycle move, it has no
690
+ # target status of its own to work a transition out from, and a wrong
691
+ # guess here assigns the ticket as well as moving it.
692
+ return refused("1d", f"{key} cannot be claimed: "
693
+ f"work.tracker.transitions.start is not set, and this "
694
+ f"command claims through it. Set it, or take the "
695
+ f"ticket with `tcw work tracker claim` and bind an "
696
+ f"item to it with `tcw work tracker link`.")
679
697
  offers = ", ".join(repr(t.name) for t in ticket.offered) or "nothing"
680
698
  return refused("1f", f"{key} is in '{status}', unassigned, and does not "
681
699
  f"offer {name!r}. It offers: {offers}."
@@ -731,3 +749,47 @@ def _claim_from(client, ticket: TicketRead) -> ClaimOutcome:
731
749
  return refused("3f", f"could not tell whether this run's claim applied: {key} is "
732
750
  f"in '{now_status}', unassigned. Check the ticket's history "
733
751
  f"in the tracker before assigning it.", detail)
752
+
753
+
754
+ def put_back(client, outcome: ClaimOutcome) -> tuple[str, str]:
755
+ """Move a ticket this run's claim took back to `outcome.claimed_from`, where the
756
+ claim found it. `import` does this once its backlog item exists, so the ticket
757
+ does not say the work has started while the item says it has not.
758
+
759
+ The claim's own transition is what kept a second claimant out, so it is taken
760
+ first and only undone here, after the item is bound. The way back is the one
761
+ transition the ticket offers to that status, chosen as a lifecycle move with no
762
+ named transition chooses it (`assess_move`); none, or more than one, sends
763
+ nothing.
764
+
765
+ Returns `(the status the ticket is in, why it is not back or "")`. Never raises a
766
+ tracker error: by now the item exists, so a ticket left where the claim put it is
767
+ something to report, not a reason to fail.
768
+ """
769
+ from tcw.tracker.claim import _normalize
770
+ from tcw.tracker.jira import TrackerError
771
+ from tcw.tracker.sync import CURRENT, assess_move
772
+
773
+ target = outcome.claimed_from
774
+ try:
775
+ ticket = read_ticket(client, outcome.issue_id)
776
+ state, found = assess_move(ticket, target=target, expected=(), move=None)
777
+ if state == CURRENT:
778
+ return ticket.status, ""
779
+ if state != "apply":
780
+ return ticket.status, found
781
+ client.apply_transition(ticket.issue_id, found.id)
782
+ status = _fields(client.issue(ticket.issue_id))[0]
783
+ except TrackerError as error:
784
+ # The transition may have landed with its answer lost, so where the ticket
785
+ # is has to be read rather than assumed.
786
+ try:
787
+ status = _fields(client.issue(outcome.issue_id))[0]
788
+ except TrackerError:
789
+ return outcome.status, f"{error}; where it is now could not be read"
790
+ if _normalize(status) == _normalize(target):
791
+ return status, ""
792
+ return status, str(error)
793
+ if _normalize(status) != _normalize(target):
794
+ return status, f"it is in '{status}' after '{found.name}'"
795
+ return status, ""