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.
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/PKG-INFO +74 -5
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/README.md +73 -4
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/pyproject.toml +1 -1
- tcw_cli-2.1.3/tcw/__init__.py +1 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/capabilities/cli.py +19 -1
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/base.py +172 -10
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/fs.py +136 -12
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/project.py +1 -1
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/tracker/claim.py +7 -4
- tcw_cli-2.1.3/tcw/tracker/intake.py +373 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/tracker/jira.py +52 -3
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/cli.py +273 -2
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/recursion.py +16 -7
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/PKG-INFO +74 -5
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/SOURCES.txt +8 -0
- tcw_cli-2.1.3/tests/test_capabilities_rm.py +406 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capabilities_sidecar.py +18 -5
- tcw_cli-2.1.3/tests/test_eval_files_changed.py +177 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_eval_fixture.py +82 -1
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_eval_grading.py +57 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_eval_runner.py +49 -4
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_recursion.py +10 -0
- tcw_cli-2.1.3/tests/test_tracker_binding.py +250 -0
- tcw_cli-2.1.3/tests/test_tracker_claim.py +247 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_claimability.py +28 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_client.py +83 -0
- tcw_cli-2.1.3/tests/test_tracker_import.py +415 -0
- tcw_cli-2.1.3/tests/test_tracker_inheritance.py +686 -0
- tcw_cli-2.1.3/tests/test_tracker_link.py +192 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_work.py +23 -0
- tcw_cli-2.1.1/tcw/__init__.py +0 -1
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/LICENSE +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/setup.cfg +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/cli.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/refs.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/__init__.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/stdin.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/store/checkouts.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/tracker/__init__.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/validate.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/generate.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/projection.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/implement.md +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/inbox.md +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/request.md +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/prompts/verify.md +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw/work/templates.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_committed_paths.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_eval_coverage.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_external_work_store.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_non_git_writes.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_project_registry.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_projection.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_prompt_fallback.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_refs.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_resolve.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_retention.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_show_json.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_smoke.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_stage_verb.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_stdin.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_provisioning.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_absent.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_cli.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_config.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_replay.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_tracker_validate.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_validate.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_work_autocommit.py +0 -0
- {tcw_cli-2.1.1 → tcw_cli-2.1.3}/tests/test_work_review.py +0 -0
- {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.
|
|
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
|
-
##
|
|
573
|
+
## Working from your Jira tickets
|
|
552
574
|
|
|
553
|
-
A project can name the Jira Cloud site it uses,
|
|
554
|
-
|
|
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
|
-
##
|
|
357
|
+
## Working from your Jira tickets
|
|
336
358
|
|
|
337
|
-
A project can name the Jira Cloud site it uses,
|
|
338
|
-
|
|
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.
|
|
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", "
|
|
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
|
|
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
|
|
295
|
-
strings. ``
|
|
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 —
|
|
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.
|