tcw-cli 2.1.2__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.2 → tcw_cli-2.1.3}/PKG-INFO +34 -6
  2. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/README.md +33 -5
  3. {tcw_cli-2.1.2 → 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.2 → tcw_cli-2.1.3}/tcw/store/base.py +137 -2
  6. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/fs.py +58 -8
  7. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/project.py +1 -1
  8. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/cli.py +14 -6
  9. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/PKG-INFO +34 -6
  10. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/SOURCES.txt +1 -0
  11. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_import.py +61 -0
  12. tcw_cli-2.1.3/tests/test_tracker_inheritance.py +686 -0
  13. tcw_cli-2.1.2/tcw/__init__.py +0 -1
  14. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/LICENSE +0 -0
  15. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/setup.cfg +0 -0
  16. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/capabilities/__init__.py +0 -0
  17. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/capabilities/cli.py +0 -0
  18. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/cli.py +0 -0
  19. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/refs.py +0 -0
  20. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/__init__.py +0 -0
  21. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  22. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
  23. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/client/index.html +0 -0
  24. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/client/theme-init.js +0 -0
  25. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/server.cjs +0 -0
  26. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/runtime.py +0 -0
  27. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/stdin.py +0 -0
  28. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/__init__.py +0 -0
  29. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/checkouts.py +0 -0
  30. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/taxonomy/__init__.py +0 -0
  31. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/taxonomy/cli.py +0 -0
  32. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/tracker/__init__.py +0 -0
  33. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/tracker/claim.py +0 -0
  34. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/tracker/intake.py +0 -0
  35. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/tracker/jira.py +0 -0
  36. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/validate.py +0 -0
  37. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/__init__.py +0 -0
  38. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/generate.py +0 -0
  39. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/hooks.py +0 -0
  40. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/projection.py +0 -0
  41. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/implement.md +0 -0
  42. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/inbox.md +0 -0
  43. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/plan.md +0 -0
  44. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/postmortem.md +0 -0
  45. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/request.md +0 -0
  46. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/spec.md +0 -0
  47. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/verify.md +0 -0
  48. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/recursion.py +0 -0
  49. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/resolve.py +0 -0
  50. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/templates.py +0 -0
  51. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/dependency_links.txt +0 -0
  52. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/entry_points.txt +0 -0
  53. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/requires.txt +0 -0
  54. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/top_level.txt +0 -0
  55. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_body_prompt.py +0 -0
  56. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities.py +0 -0
  57. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities_federation.py +0 -0
  58. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities_reset.py +0 -0
  59. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities_rm.py +0 -0
  60. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities_sidecar.py +0 -0
  61. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capability_ref_wording.py +0 -0
  62. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_committed_paths.py +0 -0
  63. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_cut_version.py +0 -0
  64. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_documentation_config.py +0 -0
  65. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_documentation_prompt.py +0 -0
  66. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_documentation_sync_wiring.py +0 -0
  67. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_documented_cli_surface.py +0 -0
  68. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_environment_hardness.py +0 -0
  69. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_epic_completable.py +0 -0
  70. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_coverage.py +0 -0
  71. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_files_changed.py +0 -0
  72. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_fixture.py +0 -0
  73. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_grading.py +0 -0
  74. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_runner.py +0 -0
  75. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_external_work_store.py +0 -0
  76. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_falsification_rule.py +0 -0
  77. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_generate_hook.py +0 -0
  78. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_inbox_title.py +0 -0
  79. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_baseline.py +0 -0
  80. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_hooks.py +0 -0
  81. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_inert.py +0 -0
  82. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_policy.py +0 -0
  83. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_validation.py +0 -0
  84. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_multiproject.py +0 -0
  85. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_non_git_writes.py +0 -0
  86. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_plugin_manifests.py +0 -0
  87. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_project_overrides.py +0 -0
  88. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_project_registry.py +0 -0
  89. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_projection.py +0 -0
  90. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_prompt_fallback.py +0 -0
  91. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_qualified_ref.py +0 -0
  92. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_recursion.py +0 -0
  93. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_refs.py +0 -0
  94. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_remote_session_setup.py +0 -0
  95. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_repo_lifecycle.py +0 -0
  96. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_resolve.py +0 -0
  97. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_retention.py +0 -0
  98. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_scaffold.py +0 -0
  99. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve.py +0 -0
  100. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_descendants.py +0 -0
  101. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_projection.py +0 -0
  102. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_resolve.py +0 -0
  103. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_runtime.py +0 -0
  104. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_write.py +0 -0
  105. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_session_bootstrap.py +0 -0
  106. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_shipped_prompts.py +0 -0
  107. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_show_json.py +0 -0
  108. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_skill_flow.py +0 -0
  109. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_skill_lifecycle_parity.py +0 -0
  110. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_smoke.py +0 -0
  111. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_stage_verb.py +0 -0
  112. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_status_parity.py +0 -0
  113. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_stdin.py +0 -0
  114. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_stdin_cli.py +0 -0
  115. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_bounds.py +0 -0
  116. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_editor.py +0 -0
  117. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_nodes.py +0 -0
  118. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_provisioning.py +0 -0
  119. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_publication.py +0 -0
  120. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_subprocess_stdin.py +0 -0
  121. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_taxonomy.py +0 -0
  122. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tombstone.py +0 -0
  123. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_absent.py +0 -0
  124. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_binding.py +0 -0
  125. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_claim.py +0 -0
  126. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_claimability.py +0 -0
  127. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_cli.py +0 -0
  128. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_client.py +0 -0
  129. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_config.py +0 -0
  130. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_link.py +0 -0
  131. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_replay.py +0 -0
  132. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_validate.py +0 -0
  133. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_unpushed_version_script.py +0 -0
  134. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_validate.py +0 -0
  135. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_validate_target.py +0 -0
  136. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_work.py +0 -0
  137. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_work_autocommit.py +0 -0
  138. {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_work_review.py +0 -0
  139. {tcw_cli-2.1.2 → 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.2
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
@@ -603,6 +603,32 @@ work:
603
603
  environment variables; TCW reads them at the moment it makes a request, so a token
604
604
  cannot be committed by accident.
605
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
+
606
632
  `tracker show` reports two different things, and the distinction matters:
607
633
 
608
634
  - **claimable** — whether this ticket currently offers the transition you
@@ -625,15 +651,17 @@ ticket's text goes into the item as its raw input, with a link back, and the ite
625
651
  request still gets written the usual way. A ticket assigned to someone else is
626
652
  refused, and so is one that is already closed.
627
653
 
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
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
630
656
  item, the second run finishes the job. One ticket can deliberately become several
631
657
  items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
632
658
  and keeps a record of it with your reason; it never changes the ticket in Jira.
633
659
 
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.
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.
637
665
 
638
666
  Two guarantees worth stating plainly. A project with no `tracker` block behaves
639
667
  exactly as before, with no new required setting and no network access. And
@@ -387,6 +387,32 @@ work:
387
387
  environment variables; TCW reads them at the moment it makes a request, so a token
388
388
  cannot be committed by accident.
389
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
+
390
416
  `tracker show` reports two different things, and the distinction matters:
391
417
 
392
418
  - **claimable** — whether this ticket currently offers the transition you
@@ -409,15 +435,17 @@ ticket's text goes into the item as its raw input, with a link back, and the ite
409
435
  request still gets written the usual way. A ticket assigned to someone else is
410
436
  refused, and so is one that is already closed.
411
437
 
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
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
414
440
  item, the second run finishes the job. One ticket can deliberately become several
415
441
  items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
416
442
  and keeps a record of it with your reason; it never changes the ticket in Jira.
417
443
 
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.
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.
421
449
 
422
450
  Two guarantees worth stating plainly. A project with no `tracker` block behaves
423
451
  exactly as before, with no new required setting and no network access. And
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tcw-cli"
7
- version = "2.1.2"
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"
@@ -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
@@ -943,7 +944,7 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
943
944
 
944
945
  problems: list[str] = []
945
946
 
946
- for key in sorted(set(raw) - TRACKER_KEYS):
947
+ for key in sorted(set(raw) - TRACKER_KEYS, key=str):
947
948
  problems.append(f"work.tracker.{key}: unknown key")
948
949
 
949
950
  def required_str(key: str) -> str:
@@ -974,7 +975,7 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
974
975
  problems.append(f"work.tracker.{key}: expected a mapping, "
975
976
  f"got {type(value).__name__}")
976
977
  return {}
977
- for sub in sorted(set(value) - allowed):
978
+ for sub in sorted(set(value) - allowed, key=str):
978
979
  problems.append(f"work.tracker.{key}.{sub}: unknown key")
979
980
  return value
980
981
 
@@ -1021,6 +1022,135 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
1021
1022
  ), []
1022
1023
 
1023
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
+
1024
1154
  @dataclass(frozen=True)
1025
1155
  class RepositoryDeclaration:
1026
1156
  """Where a component store comes from, as the project records it.
@@ -2285,6 +2415,11 @@ class WorkStore(ABC):
2285
2415
  def tracker_config(self) -> "TrackerConfig | None":
2286
2416
  """The node's external-tracker settings, or `None` when none is configured.
2287
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
+
2288
2423
  Problems discarded, so a malformed key can never break a board read. Fails
2289
2424
  *closed*: the parser returns `None` on any problem, so a half-read config
2290
2425
  is one nobody declared. `tracker_problems` is where a user learns why.
@@ -47,7 +47,8 @@ from tcw.store.base import (
47
47
  Binding, DocEntry, body_title, frontmatter_end,
48
48
  parse_documentation_entries, parse_lifecycle_policy,
49
49
  parse_connected_entry, parse_repository_declaration, parse_retention,
50
- parse_tracker_config, TrackerConfig,
50
+ parse_tracker_config, TrackerConfig, attribute_tracker_problems,
51
+ merge_tracker_blocks, tracker_credentials_problem,
51
52
  ProvisionResult,
52
53
  RepositoryDeclaration,
53
54
  PublicationError, StoreDeclarationError, StoreLocationUnusable,
@@ -5358,15 +5359,64 @@ class FsWorkStore(FsTreeStore, WorkStore):
5358
5359
  def tracker_config(self) -> "TrackerConfig | None":
5359
5360
  """Configured tracker settings, problems discarded — same contract as
5360
5361
  `documentation`: a malformed key must not break `tcw work list`."""
5361
- config, _problems = parse_tracker_config(self._work_config().get("tracker"))
5362
- return config
5362
+ return self._resolved_tracker()[0]
5363
5363
 
5364
5364
  def tracker_problems(self) -> list[str]:
5365
- """Tracker-configuration problems, prefixed with the file they came from.
5366
- Shares the parser with `tracker_config`, so the two surfaces can never
5367
- disagree about what is legal."""
5368
- _config, problems = parse_tracker_config(self._work_config().get("tracker"))
5369
- return [f"{SENTINEL}: {p}" for p in problems]
5365
+ """Tracker-configuration problems, each prefixed with the file it came from.
5366
+ Shares `_resolved_tracker` with `tracker_config`, so the two surfaces can
5367
+ never disagree about what is legal."""
5368
+ return self._resolved_tracker()[1]
5369
+
5370
+ def _resolved_tracker(self) -> "tuple[TrackerConfig | None, list[str]]":
5371
+ """This node's `work.tracker`, merged over its ancestors' when it opts in.
5372
+
5373
+ Opt-in: only a node whose own block is a non-empty mapping consults its
5374
+ ancestors. A node that writes nothing has no tracker whatever they hold, so
5375
+ a node that never asked for one neither gains one nor fails `tcw validate`
5376
+ because a parent keeps shared settings.
5377
+
5378
+ Ancestors are read through the project registry, not by walking
5379
+ directories, and a graph with problems falls back to the node's own block:
5380
+ both commands that read this refuse on graph problems first, so only a
5381
+ direct caller reaches the fallback, and it must still not raise.
5382
+ """
5383
+ own = self._work_config().get("tracker")
5384
+ if own is None or own == {}:
5385
+ return None, []
5386
+ blocks: list[tuple[str, object]] = [(SENTINEL, own)]
5387
+ unreachable: str | None = None
5388
+ if isinstance(own, dict):
5389
+ try:
5390
+ registry = FsProjectRegistry.open(self.node_root).require_valid()
5391
+ except ValueError:
5392
+ registry = None
5393
+ if registry is not None:
5394
+ ancestors = registry.ancestors()
5395
+ for ancestor in ancestors:
5396
+ work = registry.config(ancestor.id).get("work")
5397
+ if isinstance(work, dict):
5398
+ blocks.append((f"{Path(ancestor.locator) / SENTINEL} "
5399
+ f"(project '{ancestor.id}')", work.get("tracker")))
5400
+ # `ancestors()` stops at the first parent this checkout lacks, at
5401
+ # any depth, so ask who the last one reached declares.
5402
+ parent_id = registry.declared_parent_id(
5403
+ ancestors[-1].id if ancestors else None)
5404
+ if parent_id is not None and registry.get(parent_id) is None:
5405
+ unreachable = parent_id
5406
+
5407
+ merged, record, whole_block_label = merge_tracker_blocks(blocks)
5408
+ config, parsed = parse_tracker_config(merged)
5409
+ problems = attribute_tracker_problems(parsed, record, SENTINEL, whole_block_label)
5410
+ if config is not None:
5411
+ credentials = tracker_credentials_problem(record, [label for label, _ in blocks])
5412
+ if credentials is not None:
5413
+ config, problems = None, [f"{SENTINEL}: {credentials}"]
5414
+ if problems and unreachable is not None:
5415
+ problems.append(
5416
+ f"{SENTINEL}: work.tracker: declared parent '{unreachable}' is not "
5417
+ f"available in this checkout, so any tracker settings it holds were "
5418
+ f"not read (run tcw provision)")
5419
+ return config, problems
5370
5420
 
5371
5421
  def documentation_problems(self) -> list[str]:
5372
5422
  """Documentation-entry problems, prefixed with the file they came from —
@@ -445,7 +445,7 @@ class FsProjectRegistry(ProjectRegistry):
445
445
  connected = {}
446
446
  unknown = set(connected) - {"parent", "children"}
447
447
  if unknown:
448
- self._problem(path, f"unknown connected-projects keys: {', '.join(sorted(unknown))}")
448
+ self._problem(path, f"unknown connected-projects keys: {', '.join(sorted(map(str, unknown)))}")
449
449
  children = self._relation(path, connected.get("children"), "children")
450
450
  parent = self._relation(path, connected.get("parent"), "parent")
451
451
  if len(parent) > 1:
@@ -1711,10 +1711,15 @@ def _intake_text(outcome, description: str, today: str) -> str:
1711
1711
  f"{body}\n")
1712
1712
 
1713
1713
 
1714
- def _binding_for(st, outcome, part: str, today: str, unlinked: list) -> str:
1714
+ def _binding_for(provider: str, project: str, outcome, part: str, today: str,
1715
+ unlinked: list) -> str:
1716
+ """The binding document. `provider` and `project` are the values the claim was
1717
+ looked up and made with, passed in rather than read again: tracker settings can
1718
+ come from parent nodes' files, and one changed or broken mid-run would otherwise
1719
+ fail here, after the ticket is already claimed."""
1715
1720
  from tcw.tracker.intake import binding_document
1716
1721
  return binding_document(
1717
- provider=st.tracker_config().provider, project=_project_id(st), part=part,
1722
+ provider=provider, project=project, part=part,
1718
1723
  ticket_id=outcome.issue_id, ticket_key=outcome.key, ticket_url=outcome.url,
1719
1724
  account_id=outcome.account_id, account_name=outcome.account_name,
1720
1725
  bound=today, unlinked=unlinked)
@@ -1761,7 +1766,8 @@ def _tracker_import(args: argparse.Namespace) -> int:
1761
1766
  today = date.today().isoformat()
1762
1767
  try:
1763
1768
  ticket = read_ticket(client, args.ticket)
1764
- existing = find_binding(st, project=_project_id(st),
1769
+ project = _project_id(st)
1770
+ existing = find_binding(st, project=project,
1765
1771
  provider=client.config.provider,
1766
1772
  ticket_id=ticket.issue_id, part=part)
1767
1773
  if existing is not None:
@@ -1799,7 +1805,8 @@ def _tracker_import(args: argparse.Namespace) -> int:
1799
1805
  return 1
1800
1806
  try:
1801
1807
  st.write_sidecar(slug, BINDING_SIDECAR,
1802
- _binding_for(st, outcome, part, today, []), revision="")
1808
+ _binding_for(client.config.provider, project, outcome, part,
1809
+ today, []), revision="")
1803
1810
  except _LOCAL_WRITE_ERRORS as e:
1804
1811
  try:
1805
1812
  st.drop(slug)
@@ -1869,7 +1876,8 @@ def _tracker_link(args: argparse.Namespace) -> int:
1869
1876
  return 1
1870
1877
  try:
1871
1878
  ticket = read_ticket(client, args.ticket)
1872
- holder = find_binding(st, project=_project_id(st),
1879
+ project = _project_id(st)
1880
+ holder = find_binding(st, project=project,
1873
1881
  provider=client.config.provider,
1874
1882
  ticket_id=ticket.issue_id, part=part)
1875
1883
  if holder is not None:
@@ -1885,7 +1893,7 @@ def _tracker_link(args: argparse.Namespace) -> int:
1885
1893
  return 1
1886
1894
  existing = st.read_sidecar(args.slug, BINDING_SIDECAR)
1887
1895
  today = date.today().isoformat()
1888
- document = _binding_for(st, outcome, part, today,
1896
+ document = _binding_for(client.config.provider, project, outcome, part, today,
1889
1897
  unlinked_history(existing.content if existing else None))
1890
1898
  try:
1891
1899
  st.write_sidecar(args.slug, BINDING_SIDECAR, document, revision=revision or "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tcw-cli
3
- Version: 2.1.2
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
@@ -603,6 +603,32 @@ work:
603
603
  environment variables; TCW reads them at the moment it makes a request, so a token
604
604
  cannot be committed by accident.
605
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
+
606
632
  `tracker show` reports two different things, and the distinction matters:
607
633
 
608
634
  - **claimable** — whether this ticket currently offers the transition you
@@ -625,15 +651,17 @@ ticket's text goes into the item as its raw input, with a link back, and the ite
625
651
  request still gets written the usual way. A ticket assigned to someone else is
626
652
  refused, and so is one that is already closed.
627
653
 
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
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
630
656
  item, the second run finishes the job. One ticket can deliberately become several
631
657
  items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
632
658
  and keeps a record of it with your reason; it never changes the ticket in Jira.
633
659
 
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.
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.
637
665
 
638
666
  Two guarantees worth stating plainly. A project with no `tracker` block behaves
639
667
  exactly as before, with no new required setting and no network access. And
@@ -123,6 +123,7 @@ tests/test_tracker_cli.py
123
123
  tests/test_tracker_client.py
124
124
  tests/test_tracker_config.py
125
125
  tests/test_tracker_import.py
126
+ tests/test_tracker_inheritance.py
126
127
  tests/test_tracker_link.py
127
128
  tests/test_tracker_replay.py
128
129
  tests/test_tracker_validate.py
@@ -352,3 +352,64 @@ def test_a_description_that_cannot_be_read_says_the_ticket_is_claimed(node, fake
352
352
  assert code == 1 and f"claimed {TICKET}" in err
353
353
  assert_no_item(node)
354
354
  _re_run_finishes(node, fake)
355
+
356
+
357
+ # ── with tracker settings inherited from a parent node ──────────────────────
358
+
359
+
360
+ def _inheriting_child(tmp_path: Path) -> tuple[Path, Path]:
361
+ """A root holding the whole tracker block, and a child holding only its query."""
362
+ root = make_node(tmp_path, "root", email_env="TCW_A_EMAIL")
363
+ child = make_node(tmp_path, "child", email_env=None)
364
+ root_cfg = yaml.safe_load((root / "tcw-config.yaml").read_text())
365
+ root_cfg["connected-projects"] = {"children": {"child": str(child)}}
366
+ (root / "tcw-config.yaml").write_text(yaml.safe_dump(root_cfg, sort_keys=False))
367
+ child_cfg = yaml.safe_load((child / "tcw-config.yaml").read_text())
368
+ child_cfg["connected-projects"] = {"parent": {"root": str(root)}}
369
+ child_cfg.setdefault("work", {})["tracker"] = {"candidate-query": "project = EX"}
370
+ (child / "tcw-config.yaml").write_text(yaml.safe_dump(child_cfg, sort_keys=False))
371
+ from tcw.store.project import FsProjectRegistry
372
+ assert FsProjectRegistry.open(child).check() == []
373
+ assert FsWorkStore.open(child).tracker_config() is not None
374
+ return root, child
375
+
376
+
377
+ def _break_tracker_settings(root: Path):
378
+ def run():
379
+ cfg = yaml.safe_load((root / "tcw-config.yaml").read_text())
380
+ cfg["work"]["tracker"]["base-url"] = 42
381
+ (root / "tcw-config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=False))
382
+ return run
383
+
384
+
385
+ def test_import_from_an_inheriting_child_binds_with_the_childs_project(tmp_path, fake):
386
+ root, child = _inheriting_child(tmp_path)
387
+ code, out, err = run(child, "import", TICKET)
388
+ assert code == 0, err
389
+ (item,) = items(child)
390
+ assert binding(child, item.slug)["project"] == "child"
391
+ assert items(root) == []
392
+
393
+
394
+ def test_import_binds_with_the_settings_it_claimed_with_if_a_parent_changes_meanwhile(
395
+ tmp_path, fake):
396
+ """The binding's provider comes from the client that made the claim. Re-reading
397
+ the settings after the claim returned None once a parent's file broke, and
398
+ `None.provider` escaped as a traceback, leaving an unbound item behind."""
399
+ root, child = _inheriting_child(tmp_path)
400
+ fake.before("POST", "/transitions", _break_tracker_settings(root))
401
+ code, out, err = run(child, "import", TICKET)
402
+ assert code == 0, err
403
+ assert "Traceback" not in err and "AttributeError" not in err
404
+ (item,) = items(child)
405
+ assert binding(child, item.slug)["provider"] == "jira-cloud"
406
+
407
+
408
+ def test_link_binds_with_the_settings_it_claimed_with_if_a_parent_changes_meanwhile(
409
+ tmp_path, fake):
410
+ root, child = _inheriting_child(tmp_path)
411
+ slug = FsWorkStore.open(child).create("An existing item").slug
412
+ fake.before("POST", "/transitions", _break_tracker_settings(root))
413
+ code, out, err = run(child, "link", slug, TICKET)
414
+ assert code == 0, err
415
+ assert binding(child, slug)["provider"] == "jira-cloud"