tcw-cli 2.1.1__tar.gz → 2.1.3__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 (139) hide show
  1. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/PKG-INFO +74 -5
  2. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/README.md +73 -4
  3. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/pyproject.toml +1 -1
  4. tcw_cli-2.1.3/tcw/__init__.py +1 -0
  5. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/capabilities/cli.py +19 -1
  6. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/base.py +172 -10
  7. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/fs.py +136 -12
  8. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/project.py +1 -1
  9. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/tracker/claim.py +7 -4
  10. tcw_cli-2.1.3/tcw/tracker/intake.py +373 -0
  11. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/tracker/jira.py +52 -3
  12. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/cli.py +273 -2
  13. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/recursion.py +16 -7
  14. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/PKG-INFO +74 -5
  15. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/SOURCES.txt +8 -0
  16. tcw_cli-2.1.3/tests/test_capabilities_rm.py +406 -0
  17. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capabilities_sidecar.py +18 -5
  18. tcw_cli-2.1.3/tests/test_eval_files_changed.py +177 -0
  19. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_eval_fixture.py +82 -1
  20. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_eval_grading.py +57 -0
  21. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_eval_runner.py +49 -4
  22. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_recursion.py +10 -0
  23. tcw_cli-2.1.3/tests/test_tracker_binding.py +250 -0
  24. tcw_cli-2.1.3/tests/test_tracker_claim.py +247 -0
  25. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_claimability.py +28 -0
  26. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_client.py +83 -0
  27. tcw_cli-2.1.3/tests/test_tracker_import.py +415 -0
  28. tcw_cli-2.1.3/tests/test_tracker_inheritance.py +686 -0
  29. tcw_cli-2.1.3/tests/test_tracker_link.py +192 -0
  30. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_work.py +23 -0
  31. tcw_cli-2.1.1/tcw/__init__.py +0 -1
  32. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/LICENSE +0 -0
  33. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/setup.cfg +0 -0
  34. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/capabilities/__init__.py +0 -0
  35. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/cli.py +0 -0
  36. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/refs.py +0 -0
  37. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/__init__.py +0 -0
  38. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  39. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
  40. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/client/index.html +0 -0
  41. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/client/theme-init.js +0 -0
  42. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/server.cjs +0 -0
  43. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/runtime.py +0 -0
  44. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/stdin.py +0 -0
  45. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/__init__.py +0 -0
  46. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/checkouts.py +0 -0
  47. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/taxonomy/__init__.py +0 -0
  48. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/taxonomy/cli.py +0 -0
  49. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/tracker/__init__.py +0 -0
  50. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/validate.py +0 -0
  51. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/__init__.py +0 -0
  52. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/generate.py +0 -0
  53. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/hooks.py +0 -0
  54. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/projection.py +0 -0
  55. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/implement.md +0 -0
  56. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/inbox.md +0 -0
  57. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/plan.md +0 -0
  58. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/postmortem.md +0 -0
  59. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/request.md +0 -0
  60. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/spec.md +0 -0
  61. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/verify.md +0 -0
  62. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/resolve.py +0 -0
  63. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/templates.py +0 -0
  64. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/dependency_links.txt +0 -0
  65. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/entry_points.txt +0 -0
  66. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/requires.txt +0 -0
  67. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/top_level.txt +0 -0
  68. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_body_prompt.py +0 -0
  69. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capabilities.py +0 -0
  70. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capabilities_federation.py +0 -0
  71. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capabilities_reset.py +0 -0
  72. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capability_ref_wording.py +0 -0
  73. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_committed_paths.py +0 -0
  74. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_cut_version.py +0 -0
  75. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_documentation_config.py +0 -0
  76. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_documentation_prompt.py +0 -0
  77. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_documentation_sync_wiring.py +0 -0
  78. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_documented_cli_surface.py +0 -0
  79. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_environment_hardness.py +0 -0
  80. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_epic_completable.py +0 -0
  81. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_eval_coverage.py +0 -0
  82. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_external_work_store.py +0 -0
  83. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_falsification_rule.py +0 -0
  84. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_generate_hook.py +0 -0
  85. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_inbox_title.py +0 -0
  86. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_baseline.py +0 -0
  87. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_hooks.py +0 -0
  88. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_inert.py +0 -0
  89. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_policy.py +0 -0
  90. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_validation.py +0 -0
  91. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_multiproject.py +0 -0
  92. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_non_git_writes.py +0 -0
  93. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_plugin_manifests.py +0 -0
  94. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_project_overrides.py +0 -0
  95. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_project_registry.py +0 -0
  96. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_projection.py +0 -0
  97. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_prompt_fallback.py +0 -0
  98. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_qualified_ref.py +0 -0
  99. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_refs.py +0 -0
  100. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_remote_session_setup.py +0 -0
  101. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_repo_lifecycle.py +0 -0
  102. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_resolve.py +0 -0
  103. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_retention.py +0 -0
  104. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_scaffold.py +0 -0
  105. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve.py +0 -0
  106. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_descendants.py +0 -0
  107. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_projection.py +0 -0
  108. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_resolve.py +0 -0
  109. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_runtime.py +0 -0
  110. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_write.py +0 -0
  111. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_session_bootstrap.py +0 -0
  112. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_shipped_prompts.py +0 -0
  113. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_show_json.py +0 -0
  114. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_skill_flow.py +0 -0
  115. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_skill_lifecycle_parity.py +0 -0
  116. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_smoke.py +0 -0
  117. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_stage_verb.py +0 -0
  118. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_status_parity.py +0 -0
  119. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_stdin.py +0 -0
  120. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_stdin_cli.py +0 -0
  121. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_bounds.py +0 -0
  122. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_editor.py +0 -0
  123. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_nodes.py +0 -0
  124. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_provisioning.py +0 -0
  125. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_publication.py +0 -0
  126. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_subprocess_stdin.py +0 -0
  127. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_taxonomy.py +0 -0
  128. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tombstone.py +0 -0
  129. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_absent.py +0 -0
  130. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_cli.py +0 -0
  131. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_config.py +0 -0
  132. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_replay.py +0 -0
  133. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_validate.py +0 -0
  134. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_unpushed_version_script.py +0 -0
  135. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_validate.py +0 -0
  136. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_validate_target.py +0 -0
  137. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_work_autocommit.py +0 -0
  138. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_work_review.py +0 -0
  139. {tcw_cli-2.1.1 → tcw_cli-2.1.3}/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.3
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`:
@@ -579,6 +603,32 @@ work:
579
603
  environment variables; TCW reads them at the moment it makes a request, so a token
580
604
  cannot be committed by accident.
581
605
 
606
+ **Write the shared settings once.** In a workspace of connected nodes, a node's
607
+ `tracker` block takes any setting it leaves out from its parent nodes, all the way
608
+ up. So the site, credentials and claim transition can live in the workspace root,
609
+ and each package writes only the tickets it wants to see:
610
+
611
+ ```yaml
612
+ # packages/api/tcw-config.yaml
613
+ work:
614
+ tracker:
615
+ candidate-query: project = EX AND component = api AND status = "To Do"
616
+ ```
617
+
618
+ The nearest file wins each setting, and `credentials` and `transitions` merge
619
+ setting by setting too. A node with no `tracker` block of its own has no tracker,
620
+ whatever its parents say. Two rules keep this safe:
621
+
622
+ - **`credentials` must sit beside `base-url`.** A node that sets its own `base-url`
623
+ must also set both of its own `credentials` keys, even if the address is the same
624
+ as its parent's, so a token is never sent to a site chosen in a different file.
625
+ - **A node with its own board is checked like any tracking node.** If it holds the
626
+ shared settings, it needs a `candidate-query` of its own. Keeping shared settings
627
+ in a node without a board avoids that.
628
+
629
+ A mistake in a parent's settings is reported by `tcw validate` in every node that
630
+ inherits it, naming the parent's file.
631
+
582
632
  `tracker show` reports two different things, and the distinction matters:
583
633
 
584
634
  - **claimable** — whether this ticket currently offers the transition you
@@ -594,6 +644,25 @@ reports `not determined`, because it cannot show you what happens to the second
594
644
  person. Making a workflow exclusive is a Jira administration change, not something
595
645
  TCW can do for you.
596
646
 
647
+ **Taking a ticket.** `tracker import` moves the ticket through your configured
648
+ claim transition, assigns it to you if nobody had it, and then checks Jira again:
649
+ only when the ticket really is started and yours does it create a backlog item. The
650
+ ticket's text goes into the item as its raw input, with a link back, and the item's
651
+ request still gets written the usual way. A ticket assigned to someone else is
652
+ refused, and so is one that is already closed.
653
+
654
+ Running `import` again for the same ticket in the same node gives you the same item
655
+ rather than a second one, and if the first run took the ticket but stopped before creating the
656
+ item, the second run finishes the job. One ticket can deliberately become several
657
+ items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
658
+ and keeps a record of it with your reason; it never changes the ticket in Jira.
659
+
660
+ Three limits to know. On a workflow that lets anyone start a ticket from any status,
661
+ two people can both take the same ticket, and TCW does not stop that. Two runs by the
662
+ same Jira account at the same moment can both create an item. And each node keeps its
663
+ own bindings: importing one ticket in two nodes of a workspace, even nodes sharing
664
+ inherited settings, gives two items, one in each.
665
+
597
666
  Two guarantees worth stating plainly. A project with no `tracker` block behaves
598
667
  exactly as before, with no new required setting and no network access. And
599
668
  `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`:
@@ -363,6 +387,32 @@ work:
363
387
  environment variables; TCW reads them at the moment it makes a request, so a token
364
388
  cannot be committed by accident.
365
389
 
390
+ **Write the shared settings once.** In a workspace of connected nodes, a node's
391
+ `tracker` block takes any setting it leaves out from its parent nodes, all the way
392
+ up. So the site, credentials and claim transition can live in the workspace root,
393
+ and each package writes only the tickets it wants to see:
394
+
395
+ ```yaml
396
+ # packages/api/tcw-config.yaml
397
+ work:
398
+ tracker:
399
+ candidate-query: project = EX AND component = api AND status = "To Do"
400
+ ```
401
+
402
+ The nearest file wins each setting, and `credentials` and `transitions` merge
403
+ setting by setting too. A node with no `tracker` block of its own has no tracker,
404
+ whatever its parents say. Two rules keep this safe:
405
+
406
+ - **`credentials` must sit beside `base-url`.** A node that sets its own `base-url`
407
+ must also set both of its own `credentials` keys, even if the address is the same
408
+ as its parent's, so a token is never sent to a site chosen in a different file.
409
+ - **A node with its own board is checked like any tracking node.** If it holds the
410
+ shared settings, it needs a `candidate-query` of its own. Keeping shared settings
411
+ in a node without a board avoids that.
412
+
413
+ A mistake in a parent's settings is reported by `tcw validate` in every node that
414
+ inherits it, naming the parent's file.
415
+
366
416
  `tracker show` reports two different things, and the distinction matters:
367
417
 
368
418
  - **claimable** — whether this ticket currently offers the transition you
@@ -378,6 +428,25 @@ reports `not determined`, because it cannot show you what happens to the second
378
428
  person. Making a workflow exclusive is a Jira administration change, not something
379
429
  TCW can do for you.
380
430
 
431
+ **Taking a ticket.** `tracker import` moves the ticket through your configured
432
+ claim transition, assigns it to you if nobody had it, and then checks Jira again:
433
+ only when the ticket really is started and yours does it create a backlog item. The
434
+ ticket's text goes into the item as its raw input, with a link back, and the item's
435
+ request still gets written the usual way. A ticket assigned to someone else is
436
+ refused, and so is one that is already closed.
437
+
438
+ Running `import` again for the same ticket in the same node gives you the same item
439
+ rather than a second one, and if the first run took the ticket but stopped before creating the
440
+ item, the second run finishes the job. One ticket can deliberately become several
441
+ items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
442
+ and keeps a record of it with your reason; it never changes the ticket in Jira.
443
+
444
+ Three limits to know. On a workflow that lets anyone start a ticket from any status,
445
+ two people can both take the same ticket, and TCW does not stop that. Two runs by the
446
+ same Jira account at the same moment can both create an item. And each node keeps its
447
+ own bindings: importing one ticket in two nodes of a workspace, even nodes sharing
448
+ inherited settings, gives two items, one in each.
449
+
381
450
  Two guarantees worth stating plainly. A project with no `tracker` block behaves
382
451
  exactly as before, with no new required setting and no network access. And
383
452
  `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.3"
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.3"
@@ -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)
@@ -14,6 +14,7 @@ extracted in Phase 4 — not pre-abstracted here.
14
14
  # time. Python 3.14 defers natively (PEP 649); this keeps <3.14 working too.
15
15
  from __future__ import annotations
16
16
 
17
+ import copy
17
18
  import re
18
19
  from abc import ABC, abstractmethod
19
20
  from dataclasses import dataclass, field
@@ -287,24 +288,27 @@ class ProjectRegistry(ABC):
287
288
 
288
289
  def declared_capabilities(capabilities: Any) -> dict[str, list[str]]:
289
290
  """Canonical read of a work item's ``capabilities.yaml`` into
290
- ``{"new": [...], "changed": [...]}`` — the work→capability back-pointers the
291
- DoD gate enforces.
291
+ ``{"new": [...], "changed": [...], "removed": [...]}`` — the work→capability
292
+ back-pointers the DoD gate enforces.
292
293
 
293
294
  ``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
295
+ a mapping with ``new:``/``changed:``/``removed:`` lists of canonical
296
+ ``namespace/path`` strings. ``removed:`` names capabilities the item deleted,
297
+ which cannot sit under ``changed:`` because they no longer resolve.
298
+ ``added:`` is accepted as a deprecated alias of ``new:``. A trailing
296
299
  `` # comment`` on a value is stripped (YAML strips it already; belt and
297
300
  suspenders). The reconcile list-form sidecar and any other shape declare
298
301
  nothing here. The ``_tcw_parse_error`` sentinel the FS adapter produces on bad
299
302
  YAML raises ``SidecarError`` so the gate fails closed rather than reading
300
303
  "no deltas".
301
304
  """
302
- out: dict[str, list[str]] = {"new": [], "changed": []}
305
+ out: dict[str, list[str]] = {"new": [], "changed": [], "removed": []}
303
306
  if not capabilities or not isinstance(capabilities, dict):
304
307
  return out
305
308
  if "_tcw_parse_error" in capabilities:
306
309
  raise SidecarError(str(capabilities["_tcw_parse_error"]))
307
- for key, bucket in (("new", "new"), ("added", "new"), ("changed", "changed")):
310
+ for key, bucket in (("new", "new"), ("added", "new"), ("changed", "changed"),
311
+ ("removed", "removed")):
308
312
  vals = capabilities.get(key)
309
313
  if vals is None:
310
314
  continue
@@ -564,13 +568,27 @@ class CapabilitiesStore(ABC):
564
568
 
565
569
  @abstractmethod
566
570
  def remove(self, identifier: str) -> None:
567
- ...
571
+ """Delete the local capability at `identifier`, writing nothing when
572
+ refused. Raise `AmbiguousRef` when a bare ref matches multiple extended
573
+ stores, and `ValueError` when:
574
+
575
+ - nothing resolves;
576
+ - the capability is inherited, overridden locally or not (an override
577
+ is dropped with `reset`, never with `remove`);
578
+ - another capability or override is nested under its path;
579
+ - another local capability or override still references it through
580
+ `Superseded by`, `Blocked by`, `Roles` or `When`.
581
+
582
+ Refusing rather than cascading is the contract: a delete never takes a
583
+ second capability with it or leaves a reference pointing at nothing.
584
+ Never mutates an extended store."""
568
585
 
569
586
  @abstractmethod
570
587
  def reset(self, identifier: str) -> None:
571
588
  """Drop the local override at `identifier`, re-inheriting the upstream
572
589
  capability verbatim. Raise `ValueError` when there is no override (a
573
- standalone local capability is not an override — use `remove`; a bare
590
+ standalone local capability is not an override — the `remove` operation,
591
+ driven by `tcw capabilities rm`, deletes it; a bare
574
592
  inherited path has nothing to drop), or `AmbiguousRef` when a bare ref
575
593
  matches multiple extended stores. Never mutates an extended store."""
576
594
 
@@ -926,7 +944,7 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
926
944
 
927
945
  problems: list[str] = []
928
946
 
929
- for key in sorted(set(raw) - TRACKER_KEYS):
947
+ for key in sorted(set(raw) - TRACKER_KEYS, key=str):
930
948
  problems.append(f"work.tracker.{key}: unknown key")
931
949
 
932
950
  def required_str(key: str) -> str:
@@ -957,7 +975,7 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
957
975
  problems.append(f"work.tracker.{key}: expected a mapping, "
958
976
  f"got {type(value).__name__}")
959
977
  return {}
960
- for sub in sorted(set(value) - allowed):
978
+ for sub in sorted(set(value) - allowed, key=str):
961
979
  problems.append(f"work.tracker.{key}.{sub}: unknown key")
962
980
  return value
963
981
 
@@ -1004,6 +1022,135 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
1004
1022
  ), []
1005
1023
 
1006
1024
 
1025
+ TrackerKeyPath = tuple[Any, ...]
1026
+
1027
+
1028
+ def merge_tracker_blocks(
1029
+ blocks: list[tuple[str, Any]],
1030
+ ) -> tuple[Any, dict[TrackerKeyPath, str], str | None]:
1031
+ """Lay `work.tracker` blocks over each other, nearest first, key by key.
1032
+
1033
+ Pure, like `parse_tracker_config`: the caller gathers the blocks from its own
1034
+ project graph, so a store that is not a filesystem merges the same way. Each
1035
+ block is `(label, raw)`, and labels must be distinct — the node itself first,
1036
+ then its ancestors, direct parent first. Whether a node takes part at all
1037
+ (opt-in) is the caller's decision; this only merges what it is given.
1038
+
1039
+ Returns `(merged, record, whole_block_label)`:
1040
+
1041
+ - `merged` goes to `parse_tracker_config` unchanged.
1042
+ - `record` maps each key path in `merged` to the label that supplied its
1043
+ value. A nearer null that let a farther value show through does not move
1044
+ it: the credentials rule reads it, and naming the null's file there would
1045
+ let a token follow a changed `base-url`.
1046
+ - `whole_block_label` is set when an ancestor's block is not a mapping. The
1047
+ merge stops there and returns that block, so the parser's "expected a
1048
+ mapping" is reported against the ancestor and every node beneath it fails
1049
+ closed.
1050
+ """
1051
+ if not blocks:
1052
+ return None, {}, None
1053
+ own = blocks[0][1]
1054
+ if not isinstance(own, dict):
1055
+ return own, {}, None
1056
+ layers = [blocks[0]]
1057
+ for label, raw in blocks[1:]:
1058
+ if raw is None or raw == {}:
1059
+ continue
1060
+ if not isinstance(raw, dict):
1061
+ return raw, {}, label
1062
+ layers.append((label, raw))
1063
+
1064
+ merged: dict = {}
1065
+ record: dict[TrackerKeyPath, str] = {}
1066
+ for label, raw in reversed(layers):
1067
+ _lay_tracker_block(merged, copy.deepcopy(raw), label, (), record)
1068
+ return merged, record, None
1069
+
1070
+
1071
+ def _lay_tracker_block(target: dict, source: dict, label: str,
1072
+ path: TrackerKeyPath, record: dict[TrackerKeyPath, str]) -> None:
1073
+ for key, value in source.items():
1074
+ key_path = (*path, key)
1075
+ if value is None:
1076
+ if key not in target: # nothing farther: kept, for the parser
1077
+ target[key] = None
1078
+ record[key_path] = label
1079
+ continue # otherwise the farther value shows through
1080
+ if isinstance(value, dict) and isinstance(target.get(key), dict):
1081
+ record[key_path] = label
1082
+ _lay_tracker_block(target[key], value, label, key_path, record)
1083
+ continue
1084
+ for stale in [p for p in record if p[:len(key_path)] == key_path]:
1085
+ del record[stale]
1086
+ target[key] = value
1087
+ record[key_path] = label
1088
+ if isinstance(value, dict):
1089
+ _record_tracker_paths(value, label, key_path, record)
1090
+
1091
+
1092
+ def _record_tracker_paths(block: dict, label: str, path: TrackerKeyPath,
1093
+ record: dict[TrackerKeyPath, str]) -> None:
1094
+ for key, value in block.items():
1095
+ key_path = (*path, key)
1096
+ record[key_path] = label
1097
+ if isinstance(value, dict):
1098
+ _record_tracker_paths(value, label, key_path, record)
1099
+
1100
+
1101
+ def tracker_credentials_problem(record: dict[TrackerKeyPath, str],
1102
+ labels: list[str]) -> str | None:
1103
+ """A problem when `credentials` came from farther up than `base-url`.
1104
+
1105
+ `JiraClient` sends a token built from the named variables to whatever
1106
+ `base-url` names. Before inheritance both sat in one block, so whoever chose
1107
+ the site chose the token. This keeps that true: a node that sets `base-url`
1108
+ also sets `credentials`, even when the URL repeats its parent's. Credentials
1109
+ partly inherited count by their farthest key. Run on a config that parsed.
1110
+ """
1111
+ position = {label: index for index, label in enumerate(labels)}
1112
+ base_url = record.get(("base-url",))
1113
+ credentials = [label for key_path, label in record.items()
1114
+ if len(key_path) > 1 and key_path[0] == "credentials"]
1115
+ if base_url is None or not credentials:
1116
+ return None
1117
+ if max(position[label] for label in credentials) <= position[base_url]:
1118
+ return None
1119
+ return (f"work.tracker.credentials: inherited from a parent node, but base-url "
1120
+ f"is set nearer, in {base_url}; set credentials in the same file as "
1121
+ f"base-url")
1122
+
1123
+
1124
+ def attribute_tracker_problems(problems: list[str], record: dict[TrackerKeyPath, str],
1125
+ own_label: str, whole_block_label: str | None) -> list[str]:
1126
+ """Prefix each parser problem with the label of the file that caused it.
1127
+
1128
+ Matched on the problem's exact key path, never the nearest enclosing
1129
+ mapping: `transitions.claim: required` under a `transitions` an ancestor
1130
+ supplied is about a key nobody set, and goes to `own_label`. The parser
1131
+ writes a path as its keys joined with `.` followed by `: `, so the longest
1132
+ recorded path whose spelling prefixes the problem that way is the one it is
1133
+ about — which also holds for a key whose own name contains `.` or `: `.
1134
+
1135
+ Two recorded paths can share a spelling (an integer key `5` and a string key
1136
+ `"5"`, or a key named `credentials.x` beside a nested one). Such a block is
1137
+ already invalid and every problem is still reported; only the file named for
1138
+ it may be the other one's.
1139
+ """
1140
+ spelled = sorted(((".".join(map(str, key_path)), label)
1141
+ for key_path, label in record.items()),
1142
+ key=lambda entry: len(entry[0]), reverse=True)
1143
+ attributed = []
1144
+ for problem in problems:
1145
+ if problem.startswith("work.tracker: "):
1146
+ label = whole_block_label or own_label
1147
+ else:
1148
+ label = next((label for spelling, label in spelled
1149
+ if problem.startswith(f"work.tracker.{spelling}: ")), own_label)
1150
+ attributed.append(f"{label}: {problem}")
1151
+ return attributed
1152
+
1153
+
1007
1154
  @dataclass(frozen=True)
1008
1155
  class RepositoryDeclaration:
1009
1156
  """Where a component store comes from, as the project records it.
@@ -1940,6 +2087,16 @@ WORK_SIDECARS: dict[str, dict[str, str]] = {
1940
2087
  "media_type": "text/markdown",
1941
2088
  "generated": "yes",
1942
2089
  },
2090
+ # The binding between an item and the tracker ticket it answers. Written by
2091
+ # `tcw work tracker import | link | unlink`, never by hand, so it is
2092
+ # `generated` like `rollup.md` and edit surfaces must not offer to write it.
2093
+ # Nothing treats it as proof that a claim was made: every command that acts on
2094
+ # a binding re-reads the ticket from the tracker.
2095
+ "tracker.yaml": {
2096
+ "media_type": "application/yaml",
2097
+ "validation": "yaml_mapping",
2098
+ "generated": "yes",
2099
+ },
1943
2100
  }
1944
2101
 
1945
2102
  # Taxonomy term fields that the abstract ``update_term`` operation may modify.
@@ -2258,6 +2415,11 @@ class WorkStore(ABC):
2258
2415
  def tracker_config(self) -> "TrackerConfig | None":
2259
2416
  """The node's external-tracker settings, or `None` when none is configured.
2260
2417
 
2418
+ A node whose own `work.tracker` is a non-empty mapping inherits the keys it
2419
+ does not set from its ancestors: an adapter gathers their blocks from its
2420
+ project graph, lays them with `merge_tracker_blocks`, parses the result,
2421
+ and refuses a config `tracker_credentials_problem` objects to.
2422
+
2261
2423
  Problems discarded, so a malformed key can never break a board read. Fails
2262
2424
  *closed*: the parser returns `None` on any problem, so a half-read config
2263
2425
  is one nobody declared. `tracker_problems` is where a user learns why.