tcw-cli 2.1.1__tar.gz → 2.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/PKG-INFO +46 -5
  2. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/README.md +45 -4
  3. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/pyproject.toml +1 -1
  4. tcw_cli-2.1.2/tcw/__init__.py +1 -0
  5. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/capabilities/cli.py +19 -1
  6. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/store/base.py +35 -8
  7. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/store/fs.py +78 -4
  8. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/tracker/claim.py +7 -4
  9. tcw_cli-2.1.2/tcw/tracker/intake.py +373 -0
  10. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/tracker/jira.py +52 -3
  11. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/cli.py +265 -2
  12. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/recursion.py +16 -7
  13. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw_cli.egg-info/PKG-INFO +46 -5
  14. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw_cli.egg-info/SOURCES.txt +7 -0
  15. tcw_cli-2.1.2/tests/test_capabilities_rm.py +406 -0
  16. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_capabilities_sidecar.py +18 -5
  17. tcw_cli-2.1.2/tests/test_eval_files_changed.py +177 -0
  18. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_eval_fixture.py +82 -1
  19. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_eval_grading.py +57 -0
  20. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_eval_runner.py +49 -4
  21. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_recursion.py +10 -0
  22. tcw_cli-2.1.2/tests/test_tracker_binding.py +250 -0
  23. tcw_cli-2.1.2/tests/test_tracker_claim.py +247 -0
  24. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_tracker_claimability.py +28 -0
  25. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_tracker_client.py +83 -0
  26. tcw_cli-2.1.2/tests/test_tracker_import.py +354 -0
  27. tcw_cli-2.1.2/tests/test_tracker_link.py +192 -0
  28. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_work.py +23 -0
  29. tcw_cli-2.1.1/tcw/__init__.py +0 -1
  30. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/LICENSE +0 -0
  31. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/setup.cfg +0 -0
  32. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/capabilities/__init__.py +0 -0
  33. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/cli.py +0 -0
  34. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/refs.py +0 -0
  35. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/serve/__init__.py +0 -0
  36. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  37. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
  38. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/serve/dist/client/index.html +0 -0
  39. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/serve/dist/client/theme-init.js +0 -0
  40. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/serve/dist/server.cjs +0 -0
  41. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/serve/runtime.py +0 -0
  42. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/stdin.py +0 -0
  43. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/store/__init__.py +0 -0
  44. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/store/checkouts.py +0 -0
  45. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/store/project.py +0 -0
  46. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/taxonomy/__init__.py +0 -0
  47. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/taxonomy/cli.py +0 -0
  48. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/tracker/__init__.py +0 -0
  49. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/validate.py +0 -0
  50. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/__init__.py +0 -0
  51. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/generate.py +0 -0
  52. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/hooks.py +0 -0
  53. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/projection.py +0 -0
  54. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/prompts/implement.md +0 -0
  55. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/prompts/inbox.md +0 -0
  56. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/prompts/plan.md +0 -0
  57. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/prompts/postmortem.md +0 -0
  58. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/prompts/request.md +0 -0
  59. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/prompts/spec.md +0 -0
  60. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/prompts/verify.md +0 -0
  61. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/resolve.py +0 -0
  62. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw/work/templates.py +0 -0
  63. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw_cli.egg-info/dependency_links.txt +0 -0
  64. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw_cli.egg-info/entry_points.txt +0 -0
  65. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw_cli.egg-info/requires.txt +0 -0
  66. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tcw_cli.egg-info/top_level.txt +0 -0
  67. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_body_prompt.py +0 -0
  68. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_capabilities.py +0 -0
  69. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_capabilities_federation.py +0 -0
  70. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_capabilities_reset.py +0 -0
  71. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_capability_ref_wording.py +0 -0
  72. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_committed_paths.py +0 -0
  73. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_cut_version.py +0 -0
  74. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_documentation_config.py +0 -0
  75. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_documentation_prompt.py +0 -0
  76. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_documentation_sync_wiring.py +0 -0
  77. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_documented_cli_surface.py +0 -0
  78. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_environment_hardness.py +0 -0
  79. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_epic_completable.py +0 -0
  80. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_eval_coverage.py +0 -0
  81. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_external_work_store.py +0 -0
  82. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_falsification_rule.py +0 -0
  83. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_generate_hook.py +0 -0
  84. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_inbox_title.py +0 -0
  85. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_lifecycle_baseline.py +0 -0
  86. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_lifecycle_hooks.py +0 -0
  87. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_lifecycle_inert.py +0 -0
  88. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_lifecycle_policy.py +0 -0
  89. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_lifecycle_validation.py +0 -0
  90. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_multiproject.py +0 -0
  91. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_non_git_writes.py +0 -0
  92. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_plugin_manifests.py +0 -0
  93. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_project_overrides.py +0 -0
  94. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_project_registry.py +0 -0
  95. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_projection.py +0 -0
  96. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_prompt_fallback.py +0 -0
  97. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_qualified_ref.py +0 -0
  98. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_refs.py +0 -0
  99. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_remote_session_setup.py +0 -0
  100. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_repo_lifecycle.py +0 -0
  101. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_resolve.py +0 -0
  102. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_retention.py +0 -0
  103. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_scaffold.py +0 -0
  104. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_serve.py +0 -0
  105. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_serve_descendants.py +0 -0
  106. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_serve_projection.py +0 -0
  107. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_serve_resolve.py +0 -0
  108. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_serve_runtime.py +0 -0
  109. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_serve_write.py +0 -0
  110. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_session_bootstrap.py +0 -0
  111. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_shipped_prompts.py +0 -0
  112. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_show_json.py +0 -0
  113. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_skill_flow.py +0 -0
  114. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_skill_lifecycle_parity.py +0 -0
  115. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_smoke.py +0 -0
  116. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_stage_verb.py +0 -0
  117. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_status_parity.py +0 -0
  118. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_stdin.py +0 -0
  119. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_stdin_cli.py +0 -0
  120. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_store_bounds.py +0 -0
  121. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_store_editor.py +0 -0
  122. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_store_nodes.py +0 -0
  123. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_store_provisioning.py +0 -0
  124. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_store_publication.py +0 -0
  125. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_subprocess_stdin.py +0 -0
  126. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_taxonomy.py +0 -0
  127. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_tombstone.py +0 -0
  128. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_tracker_absent.py +0 -0
  129. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_tracker_cli.py +0 -0
  130. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_tracker_config.py +0 -0
  131. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_tracker_replay.py +0 -0
  132. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_tracker_validate.py +0 -0
  133. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_unpushed_version_script.py +0 -0
  134. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_validate.py +0 -0
  135. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_validate_target.py +0 -0
  136. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_work_autocommit.py +0 -0
  137. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_work_review.py +0 -0
  138. {tcw_cli-2.1.1 → tcw_cli-2.1.2}/tests/test_work_tags.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tcw-cli
3
- Version: 2.1.1
3
+ Version: 2.1.2
4
4
  Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -229,6 +229,28 @@ They link by one-directional pointers — a capability names the taxonomy terms
229
229
  involves, a work item names the capability it changes — and never copy each
230
230
  other's content.
231
231
 
232
+ ## Contents
233
+
234
+ - [The problem](#the-problem)
235
+ - [What it looks like](#what-it-looks-like)
236
+ - [Why many repositories is the case it is built for](#why-many-repositories-is-the-case-it-is-built-for)
237
+ - [What it deliberately refuses](#what-it-deliberately-refuses)
238
+ - [What adopting it costs](#what-adopting-it-costs)
239
+ - [Who it's for](#who-its-for)
240
+ - [Storage abstraction (the prime directive)](#storage-abstraction-the-prime-directive)
241
+ - [Install](#install)
242
+ - [As a plugin (recommended)](#as-a-plugin-recommended)
243
+ - [As a Python package](#as-a-python-package)
244
+ - [In a cloud environment](#in-a-cloud-environment)
245
+ - [Quickstart](#quickstart)
246
+ - [Working from your Jira tickets](#working-from-your-jira-tickets)
247
+ - [Documentation](#documentation)
248
+ - [Skills — the judgment layer](#skills--the-judgment-layer)
249
+ - [Reading a lifecycle stage](#reading-a-lifecycle-stage)
250
+ - [Review agents and slash commands](#review-agents-and-slash-commands)
251
+ - [Status](#status)
252
+ - [Further reading](#further-reading)
253
+
232
254
  ---
233
255
 
234
256
  ## The problem
@@ -548,15 +570,17 @@ studies your code, proposes a first draft, refines it with you, and writes it.
548
570
 
549
571
  ---
550
572
 
551
- ## Reading your Jira tickets
573
+ ## Working from your Jira tickets
552
574
 
553
- A project can name the Jira Cloud site it uses, and then read from it. Both
554
- commands are read-only: nothing here changes a ticket, and nothing yet turns a
555
- ticket into a work item.
575
+ A project can name the Jira Cloud site it uses, read its tickets, and take one as
576
+ a work item.
556
577
 
557
578
  ```sh
558
579
  tcw work tracker list # tickets the configured query selects
559
580
  tcw work tracker show ENG-482 # one ticket, and whether it is yours to take
581
+ tcw work tracker import ENG-482 # take the ticket and get a work item for it
582
+ tcw work tracker link <slug> ENG-482 # take it for an item you already have
583
+ tcw work tracker unlink <slug> --reason "wrong ticket"
560
584
  ```
561
585
 
562
586
  Configuration goes in the node's `tcw-config.yaml`:
@@ -594,6 +618,23 @@ reports `not determined`, because it cannot show you what happens to the second
594
618
  person. Making a workflow exclusive is a Jira administration change, not something
595
619
  TCW can do for you.
596
620
 
621
+ **Taking a ticket.** `tracker import` moves the ticket through your configured
622
+ claim transition, assigns it to you if nobody had it, and then checks Jira again:
623
+ only when the ticket really is started and yours does it create a backlog item. The
624
+ ticket's text goes into the item as its raw input, with a link back, and the item's
625
+ request still gets written the usual way. A ticket assigned to someone else is
626
+ refused, and so is one that is already closed.
627
+
628
+ Running `import` again for the same ticket gives you the same item rather than a
629
+ second one, and if the first run took the ticket but stopped before creating the
630
+ item, the second run finishes the job. One ticket can deliberately become several
631
+ items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
632
+ and keeps a record of it with your reason; it never changes the ticket in Jira.
633
+
634
+ Two limits to know. On a workflow that lets anyone start a ticket from any status,
635
+ two people can both take the same ticket, and TCW does not stop that. And two runs
636
+ by the same Jira account at the same moment can both create an item.
637
+
597
638
  Two guarantees worth stating plainly. A project with no `tracker` block behaves
598
639
  exactly as before, with no new required setting and no network access. And
599
640
  `tcw validate` never contacts the tracker, which matters because projects commonly
@@ -13,6 +13,28 @@ They link by one-directional pointers — a capability names the taxonomy terms
13
13
  involves, a work item names the capability it changes — and never copy each
14
14
  other's content.
15
15
 
16
+ ## Contents
17
+
18
+ - [The problem](#the-problem)
19
+ - [What it looks like](#what-it-looks-like)
20
+ - [Why many repositories is the case it is built for](#why-many-repositories-is-the-case-it-is-built-for)
21
+ - [What it deliberately refuses](#what-it-deliberately-refuses)
22
+ - [What adopting it costs](#what-adopting-it-costs)
23
+ - [Who it's for](#who-its-for)
24
+ - [Storage abstraction (the prime directive)](#storage-abstraction-the-prime-directive)
25
+ - [Install](#install)
26
+ - [As a plugin (recommended)](#as-a-plugin-recommended)
27
+ - [As a Python package](#as-a-python-package)
28
+ - [In a cloud environment](#in-a-cloud-environment)
29
+ - [Quickstart](#quickstart)
30
+ - [Working from your Jira tickets](#working-from-your-jira-tickets)
31
+ - [Documentation](#documentation)
32
+ - [Skills — the judgment layer](#skills--the-judgment-layer)
33
+ - [Reading a lifecycle stage](#reading-a-lifecycle-stage)
34
+ - [Review agents and slash commands](#review-agents-and-slash-commands)
35
+ - [Status](#status)
36
+ - [Further reading](#further-reading)
37
+
16
38
  ---
17
39
 
18
40
  ## The problem
@@ -332,15 +354,17 @@ studies your code, proposes a first draft, refines it with you, and writes it.
332
354
 
333
355
  ---
334
356
 
335
- ## Reading your Jira tickets
357
+ ## Working from your Jira tickets
336
358
 
337
- A project can name the Jira Cloud site it uses, and then read from it. Both
338
- commands are read-only: nothing here changes a ticket, and nothing yet turns a
339
- ticket into a work item.
359
+ A project can name the Jira Cloud site it uses, read its tickets, and take one as
360
+ a work item.
340
361
 
341
362
  ```sh
342
363
  tcw work tracker list # tickets the configured query selects
343
364
  tcw work tracker show ENG-482 # one ticket, and whether it is yours to take
365
+ tcw work tracker import ENG-482 # take the ticket and get a work item for it
366
+ tcw work tracker link <slug> ENG-482 # take it for an item you already have
367
+ tcw work tracker unlink <slug> --reason "wrong ticket"
344
368
  ```
345
369
 
346
370
  Configuration goes in the node's `tcw-config.yaml`:
@@ -378,6 +402,23 @@ reports `not determined`, because it cannot show you what happens to the second
378
402
  person. Making a workflow exclusive is a Jira administration change, not something
379
403
  TCW can do for you.
380
404
 
405
+ **Taking a ticket.** `tracker import` moves the ticket through your configured
406
+ claim transition, assigns it to you if nobody had it, and then checks Jira again:
407
+ only when the ticket really is started and yours does it create a backlog item. The
408
+ ticket's text goes into the item as its raw input, with a link back, and the item's
409
+ request still gets written the usual way. A ticket assigned to someone else is
410
+ refused, and so is one that is already closed.
411
+
412
+ Running `import` again for the same ticket gives you the same item rather than a
413
+ second one, and if the first run took the ticket but stopped before creating the
414
+ item, the second run finishes the job. One ticket can deliberately become several
415
+ items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
416
+ and keeps a record of it with your reason; it never changes the ticket in Jira.
417
+
418
+ Two limits to know. On a workflow that lets anyone start a ticket from any status,
419
+ two people can both take the same ticket, and TCW does not stop that. And two runs
420
+ by the same Jira account at the same moment can both create an item.
421
+
381
422
  Two guarantees worth stating plainly. A project with no `tracker` block behaves
382
423
  exactly as before, with no new required setting and no network access. And
383
424
  `tcw validate` never contacts the tracker, which matters because projects commonly
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tcw-cli"
7
- version = "2.1.1"
7
+ version = "2.1.2"
8
8
  description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "2.1.2"
@@ -9,7 +9,8 @@ from tcw.store.base import AmbiguousRef
9
9
  from tcw.store.fs import FsCapabilitiesStore, find_node, git_root
10
10
 
11
11
  NAME = "capabilities"
12
- SUBCOMMANDS = {"init", "list", "show", "path", "add", "search", "check", "set", "reset", "extends", "drift"}
12
+ SUBCOMMANDS = {"init", "list", "show", "path", "add", "search", "check", "set", "reset", "rm",
13
+ "extends", "drift"}
13
14
  DEFAULT_SUBCOMMAND = "show" # `tcw capabilities <path>` == `tcw capabilities show <path>`
14
15
 
15
16
 
@@ -129,6 +130,19 @@ def _reset(args: argparse.Namespace) -> int:
129
130
  return 0
130
131
 
131
132
 
133
+ def _rm(args: argparse.Namespace) -> int:
134
+ st = _store()
135
+ if st is None:
136
+ return 1
137
+ try:
138
+ st.remove(args.id)
139
+ except (ValueError, RefError) as e: # AmbiguousRef carries its own message
140
+ print(f"tcw capabilities rm: {e}", file=sys.stderr)
141
+ return 1
142
+ print(f"Removed capability {args.id}")
143
+ return 0
144
+
145
+
132
146
  def _search(args: argparse.Namespace) -> int:
133
147
  st = _store()
134
148
  if st is None:
@@ -294,6 +308,10 @@ def add_subparser(sub: argparse._SubParsersAction) -> None:
294
308
  prst.add_argument("id", metavar="path")
295
309
  prst.set_defaults(func=_reset)
296
310
 
311
+ prm = g.add_parser("rm", help="remove a local capability")
312
+ prm.add_argument("id", metavar="path")
313
+ prm.set_defaults(func=_rm)
314
+
297
315
  pse = g.add_parser("search", help="search names + bodies")
298
316
  pse.add_argument("query")
299
317
  pse.set_defaults(func=_search)
@@ -287,24 +287,27 @@ class ProjectRegistry(ABC):
287
287
 
288
288
  def declared_capabilities(capabilities: Any) -> dict[str, list[str]]:
289
289
  """Canonical read of a work item's ``capabilities.yaml`` into
290
- ``{"new": [...], "changed": [...]}`` — the work→capability back-pointers the
291
- DoD gate enforces.
290
+ ``{"new": [...], "changed": [...], "removed": [...]}`` — the work→capability
291
+ back-pointers the DoD gate enforces.
292
292
 
293
293
  ``capabilities`` is the already-parsed sidecar object (``WorkItem.capabilities``):
294
- a mapping with ``new:``/``changed:`` lists of canonical ``namespace/path``
295
- strings. ``added:`` is accepted as a deprecated alias of ``new:``. A trailing
294
+ a mapping with ``new:``/``changed:``/``removed:`` lists of canonical
295
+ ``namespace/path`` strings. ``removed:`` names capabilities the item deleted,
296
+ which cannot sit under ``changed:`` because they no longer resolve.
297
+ ``added:`` is accepted as a deprecated alias of ``new:``. A trailing
296
298
  `` # comment`` on a value is stripped (YAML strips it already; belt and
297
299
  suspenders). The reconcile list-form sidecar and any other shape declare
298
300
  nothing here. The ``_tcw_parse_error`` sentinel the FS adapter produces on bad
299
301
  YAML raises ``SidecarError`` so the gate fails closed rather than reading
300
302
  "no deltas".
301
303
  """
302
- out: dict[str, list[str]] = {"new": [], "changed": []}
304
+ out: dict[str, list[str]] = {"new": [], "changed": [], "removed": []}
303
305
  if not capabilities or not isinstance(capabilities, dict):
304
306
  return out
305
307
  if "_tcw_parse_error" in capabilities:
306
308
  raise SidecarError(str(capabilities["_tcw_parse_error"]))
307
- for key, bucket in (("new", "new"), ("added", "new"), ("changed", "changed")):
309
+ for key, bucket in (("new", "new"), ("added", "new"), ("changed", "changed"),
310
+ ("removed", "removed")):
308
311
  vals = capabilities.get(key)
309
312
  if vals is None:
310
313
  continue
@@ -564,13 +567,27 @@ class CapabilitiesStore(ABC):
564
567
 
565
568
  @abstractmethod
566
569
  def remove(self, identifier: str) -> None:
567
- ...
570
+ """Delete the local capability at `identifier`, writing nothing when
571
+ refused. Raise `AmbiguousRef` when a bare ref matches multiple extended
572
+ stores, and `ValueError` when:
573
+
574
+ - nothing resolves;
575
+ - the capability is inherited, overridden locally or not (an override
576
+ is dropped with `reset`, never with `remove`);
577
+ - another capability or override is nested under its path;
578
+ - another local capability or override still references it through
579
+ `Superseded by`, `Blocked by`, `Roles` or `When`.
580
+
581
+ Refusing rather than cascading is the contract: a delete never takes a
582
+ second capability with it or leaves a reference pointing at nothing.
583
+ Never mutates an extended store."""
568
584
 
569
585
  @abstractmethod
570
586
  def reset(self, identifier: str) -> None:
571
587
  """Drop the local override at `identifier`, re-inheriting the upstream
572
588
  capability verbatim. Raise `ValueError` when there is no override (a
573
- standalone local capability is not an override — use `remove`; a bare
589
+ standalone local capability is not an override — the `remove` operation,
590
+ driven by `tcw capabilities rm`, deletes it; a bare
574
591
  inherited path has nothing to drop), or `AmbiguousRef` when a bare ref
575
592
  matches multiple extended stores. Never mutates an extended store."""
576
593
 
@@ -1940,6 +1957,16 @@ WORK_SIDECARS: dict[str, dict[str, str]] = {
1940
1957
  "media_type": "text/markdown",
1941
1958
  "generated": "yes",
1942
1959
  },
1960
+ # The binding between an item and the tracker ticket it answers. Written by
1961
+ # `tcw work tracker import | link | unlink`, never by hand, so it is
1962
+ # `generated` like `rollup.md` and edit surfaces must not offer to write it.
1963
+ # Nothing treats it as proof that a claim was made: every command that acts on
1964
+ # a binding re-reads the ticket from the tracker.
1965
+ "tracker.yaml": {
1966
+ "media_type": "application/yaml",
1967
+ "validation": "yaml_mapping",
1968
+ "generated": "yes",
1969
+ },
1943
1970
  }
1944
1971
 
1945
1972
  # Taxonomy term fields that the abstract ``update_term`` operation may modify.
@@ -565,7 +565,10 @@ def git_stage(node_root: Path, *paths: Path) -> None:
565
565
 
566
566
  def git_rm(node_root: Path, path: Path) -> None:
567
567
  # -f so a term staged-but-not-yet-committed (just `add`ed) can still be removed.
568
- _git(["git", "-C", str(node_root), "rm", "-rfq", "--", str(path)], check=True)
568
+ # --literal-pathspecs: `--` ends options but a path is still a glob to git, so
569
+ # removing a folder named `a*` would also delete `abc`.
570
+ _git(["git", "-C", str(node_root), "--literal-pathspecs", "rm", "-rfq", "--", str(path)],
571
+ check=True)
569
572
 
570
573
 
571
574
  NOT_A_REPOSITORY = "not inside a git repository. Run `git init` first."
@@ -1077,8 +1080,13 @@ _UniqueKeyLoader.add_constructor(
1077
1080
  #: the Definition-of-Done gate reads, and the list form `reconcile` writes — so
1078
1081
  #: holding it to one of them would report a sound file, and worse, make that
1079
1082
  #: gate fail closed on it. See `declared_capabilities` in `tcw/store/base.py`.
1083
+ #:
1084
+ #: `tracker.yaml`, an item's binding to a tracker ticket, has one shape — a mapping,
1085
+ #: which `write_sidecar` already enforces — so it is held to the contract like
1086
+ #: `state.yaml`.
1080
1087
  OWNED_YAML_NAMES = frozenset({
1081
1088
  "state.yaml", "meta.yaml", "graveyard.yaml", "config.yaml", ".config.yaml",
1089
+ "tracker.yaml",
1082
1090
  })
1083
1091
 
1084
1092
 
@@ -2480,18 +2488,84 @@ class FsCapabilitiesStore(FsTreeStore, _FederationCycles, CapabilitiesStore):
2480
2488
 
2481
2489
  def remove(self, identifier: str) -> None:
2482
2490
  cap = self.get(identifier)
2491
+ # The exact listed spelling only. `get` resolves `routes/`, `./routes`,
2492
+ # or `Routes` on a case-insensitive disk, to the `routes` folder but
2493
+ # echoes the spelling back as the path, so everything below that works
2494
+ # from the path would be working from the wrong one.
2495
+ if cap is not None and cap.origin == "local" and cap.path not in self._local_paths():
2496
+ cap = None
2483
2497
  if cap is None:
2484
2498
  raise ValueError(f"no such capability: {identifier}")
2485
2499
  if cap.origin != "local":
2486
2500
  raise ValueError(f"cannot remove inherited capability '{cap.qualified}' "
2487
- f"(edit it at its source)")
2488
- self._rm(self.root / cap.path)
2501
+ f"(edit it at its source; to drop a local override use "
2502
+ f"`tcw capabilities reset`)")
2503
+ d = self.root / cap.path
2504
+ # Refused, never cascaded: `_rm` deletes the whole folder. Asked of the
2505
+ # folder itself rather than `_all_meta_dirs`, which skips dot-directories
2506
+ # and unreadable nodes that `git rm -rf` would delete all the same.
2507
+ nested = sorted(str(m.parent.relative_to(self.root))
2508
+ for m in d.rglob("meta.yaml") if m.parent != d)
2509
+ if nested:
2510
+ raise ValueError(f"cannot remove '{cap.path}': nested under it: "
2511
+ f"{', '.join(nested)} (remove those first)")
2512
+ referrers = self._referrers(d)
2513
+ if referrers:
2514
+ raise ValueError(f"cannot remove '{cap.path}': still referenced by "
2515
+ f"{', '.join(referrers)} (repoint or clear those fields first)")
2516
+ self._rm(d)
2517
+
2518
+ def _referrers(self, target: Path) -> list[str]:
2519
+ """`<folder> (<field>)` for every other local capability or override whose
2520
+ reference fields resolve to the local capability folder `target`.
2521
+
2522
+ Each field is read exactly as `check` reads it (`_ref_problems`,
2523
+ `_check_globals`), so this refuses on precisely the references `check`
2524
+ would report as dangling afterwards. A hit is compared by folder identity,
2525
+ not spelling: `a/b/`, `x/../a/b` and a differently-cased `A/B` all
2526
+ resolve, and `set` accepts each.
2527
+ """
2528
+ def tokens(field: str, raw) -> list[str]:
2529
+ if field in ("Superseded by", "Blocked by"):
2530
+ return [str(raw)]
2531
+ ns = "roles" if field == "Roles" else "conditions"
2532
+ toks = raw if isinstance(raw, list) else str(raw).split(",")
2533
+ refs = [str(s).strip().lstrip("!") for s in toks if str(s).strip()]
2534
+ return [r for r in refs if r.startswith(f"{ns}/")]
2535
+
2536
+ def same(a: Path) -> bool:
2537
+ try:
2538
+ return a.samefile(target)
2539
+ # Gone since it was listed or resolved: not the target. Only that —
2540
+ # any other error leaves the question open, so it propagates and
2541
+ # the delete stops.
2542
+ except FileNotFoundError:
2543
+ return False
2544
+
2545
+ out = []
2546
+ for p in self._all_meta_dirs():
2547
+ folder = self.root / p
2548
+ if same(folder):
2549
+ continue
2550
+ meta = load_yaml(folder / "meta.yaml")
2551
+ for field in ("Superseded by", "Blocked by", "Roles", "When"):
2552
+ if field not in meta:
2553
+ continue
2554
+ for ref in tokens(field, meta[field]):
2555
+ try:
2556
+ hit = self.get(ref)
2557
+ except RefError:
2558
+ continue
2559
+ if hit is not None and hit.origin == "local" and same(self.root / hit.path):
2560
+ out.append(f"{p} ({field})")
2561
+ break
2562
+ return out
2489
2563
 
2490
2564
  def reset(self, identifier: str) -> None:
2491
2565
  # A standalone local capability is not an override — `remove` deletes it.
2492
2566
  if self.get_local(identifier) is not None:
2493
2567
  raise ValueError(f"'{identifier}' is a local capability, not an override "
2494
- f"(use `remove` to delete it)")
2568
+ f"(use `tcw capabilities rm` to delete it)")
2495
2569
  cap = self.get(identifier) # federated; may raise AmbiguousRef
2496
2570
  if cap is None:
2497
2571
  raise ValueError(f"no such capability: {identifier}")
@@ -127,8 +127,10 @@ def assess(claim_transition: str, *, current_status: str, offered,
127
127
  exclusivity=NOT_DETERMINED,
128
128
  verdict=OK,
129
129
  landing_status=landing,
130
- detail=(f"{matches[0].name!r} leads to {landing!r}. Exclusivity can only "
131
- f"be read from a ticket already in that status."),
130
+ detail=(f"{matches[0].name!r} leads to {landing!r}. A ticket already in "
131
+ f"that status can show a workflow that is not exclusive, but never "
132
+ f"one that is. That is confirmed only when a claim is made, or "
133
+ f"from the workflow definition."),
132
134
  )
133
135
 
134
136
  # The claim is not offered. Two very different situations.
@@ -164,8 +166,9 @@ def assess(claim_transition: str, *, current_status: str, offered,
164
166
  detail=(f"work.tracker.transitions.claim is {claim_transition!r}, which "
165
167
  f"this ticket does not offer. It offers: "
166
168
  f"{', '.join(repr(n) for n in offered_names)}. Either the name "
167
- f"is wrong, or this ticket is past the point where it applies — "
168
- f"one ticket cannot tell those apart."),
169
+ f"is wrong, or this ticket is not at the point where it applies — "
170
+ f"it has not reached it yet, or is already past it. One ticket "
171
+ f"cannot tell those apart."),
169
172
  )
170
173
 
171
174
  # No transitions at all: a resolved ticket, or one this account cannot move.