tcw-cli 2.0.0__tar.gz → 2.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/PKG-INFO +22 -5
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/README.md +21 -4
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/pyproject.toml +1 -1
- tcw_cli-2.0.2/tcw/__init__.py +1 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/checkouts.py +39 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/fs.py +106 -13
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/project.py +40 -1
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/validate.py +59 -1
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/PKG-INFO +22 -5
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_external_work_store.py +29 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_plugin_manifests.py +49 -3
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_project_registry.py +103 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_skill_lifecycle_parity.py +103 -19
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_provisioning.py +226 -7
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_validate.py +101 -0
- tcw_cli-2.0.0/tcw/__init__.py +0 -1
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/LICENSE +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/setup.cfg +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/capabilities/cli.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/cli.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/refs.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/__init__.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/stdin.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/base.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/cli.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/generate.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/projection.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/implement.md +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/inbox.md +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/request.md +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/verify.md +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/recursion.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/templates.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/SOURCES.txt +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_non_git_writes.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_projection.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_prompt_fallback.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_recursion.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_refs.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_resolve.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_retention.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_show_json.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_smoke.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_stage_verb.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_stdin.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_work.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_work_autocommit.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_work_review.py +0 -0
- {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_work_tags.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tcw-cli
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -483,8 +483,10 @@ Every command group also has `--help`, and a `check` that validates its tree.
|
|
|
483
483
|
|
|
484
484
|
## Skills — the judgment layer
|
|
485
485
|
|
|
486
|
-
The CLI is the _mechanism_.
|
|
487
|
-
_judgment_ that drives it — the parts a deterministic tool cannot decide.
|
|
486
|
+
The CLI is the _mechanism_. Fourteen skills in [`skills/`](skills/) supply the
|
|
487
|
+
_judgment_ that drives it — the parts a deterministic tool cannot decide. Eight
|
|
488
|
+
carry a distinct procedure; the other six all compose one lifecycle stage and
|
|
489
|
+
are listed together at the end.
|
|
488
490
|
|
|
489
491
|
| Skill | What it does |
|
|
490
492
|
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -495,12 +497,27 @@ _judgment_ that drives it — the parts a deterministic tool cannot decide.
|
|
|
495
497
|
| [`tcw-report`](skills/tcw-report/SKILL.md) | Reporting a `tcw` bug or suggestion upstream to [this project's issues](https://github.com/brocef/TCW/issues) |
|
|
496
498
|
| [`tcw-triage-issues`](skills/tcw-triage-issues/SKILL.md) | Sweeps **your** project's GitHub issues and turns the ones worth doing into work items |
|
|
497
499
|
| [`documentation-sync`](skills/documentation-sync/SKILL.md) | Keeps README, changelogs, release notes, and driving skills moving with the code that changes them |
|
|
500
|
+
| [`tcw-post-mortem`](skills/tcw-post-mortem/SKILL.md) | Finds which lifecycle stage could first have caught a problem, once one has surfaced |
|
|
498
501
|
|
|
499
502
|
They name `tcw` commands and never reimplement tool logic: mechanism stays in the
|
|
500
503
|
binary, judgment stays in the skills.
|
|
501
504
|
|
|
502
|
-
|
|
503
|
-
|
|
505
|
+
### Reading a lifecycle stage
|
|
506
|
+
|
|
507
|
+
Six more skills do one job between them: hand you a stage's own working document
|
|
508
|
+
and the instructions your project resolves for it, as a single read rather than a
|
|
509
|
+
file you open and a command you run separately. They only read — `tcw work stage
|
|
510
|
+
gate` is still what refuses.
|
|
511
|
+
|
|
512
|
+
[`tcw-work-stage`](skills/tcw-work-stage/SKILL.md) is the general one and takes
|
|
513
|
+
the stage id, so it reaches all seven stages including `inbox` and `postmortem`.
|
|
514
|
+
The other five bake their stage in and ask only for the work item, which is
|
|
515
|
+
optional: `tcw-work-stage-request`, `tcw-work-stage-spec`,
|
|
516
|
+
`tcw-work-stage-plan`, `tcw-work-stage-implement`, `tcw-work-stage-verify`.
|
|
517
|
+
|
|
518
|
+
Three read-only review agents ship alongside them — `tcw-verifier`,
|
|
519
|
+
`tcw-backlog-auditor`, and `tcw-post-mortem`, which accelerates the skill of the
|
|
520
|
+
same name — plus slash commands for each skill's main procedure
|
|
504
521
|
(`/tcw-plan-work`, `/tcw-drive-work-to-completion`, `/tcw-verify-work`,
|
|
505
522
|
`/tcw-process-inbox`, `/tcw-triage-issues`, `/tcw-audit-work-backlog`,
|
|
506
523
|
`/tcw-taxonomy-init`, `/tcw-capabilities-init`, `/tcw-docs-sync-setup`,
|
|
@@ -267,8 +267,10 @@ Every command group also has `--help`, and a `check` that validates its tree.
|
|
|
267
267
|
|
|
268
268
|
## Skills — the judgment layer
|
|
269
269
|
|
|
270
|
-
The CLI is the _mechanism_.
|
|
271
|
-
_judgment_ that drives it — the parts a deterministic tool cannot decide.
|
|
270
|
+
The CLI is the _mechanism_. Fourteen skills in [`skills/`](skills/) supply the
|
|
271
|
+
_judgment_ that drives it — the parts a deterministic tool cannot decide. Eight
|
|
272
|
+
carry a distinct procedure; the other six all compose one lifecycle stage and
|
|
273
|
+
are listed together at the end.
|
|
272
274
|
|
|
273
275
|
| Skill | What it does |
|
|
274
276
|
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -279,12 +281,27 @@ _judgment_ that drives it — the parts a deterministic tool cannot decide.
|
|
|
279
281
|
| [`tcw-report`](skills/tcw-report/SKILL.md) | Reporting a `tcw` bug or suggestion upstream to [this project's issues](https://github.com/brocef/TCW/issues) |
|
|
280
282
|
| [`tcw-triage-issues`](skills/tcw-triage-issues/SKILL.md) | Sweeps **your** project's GitHub issues and turns the ones worth doing into work items |
|
|
281
283
|
| [`documentation-sync`](skills/documentation-sync/SKILL.md) | Keeps README, changelogs, release notes, and driving skills moving with the code that changes them |
|
|
284
|
+
| [`tcw-post-mortem`](skills/tcw-post-mortem/SKILL.md) | Finds which lifecycle stage could first have caught a problem, once one has surfaced |
|
|
282
285
|
|
|
283
286
|
They name `tcw` commands and never reimplement tool logic: mechanism stays in the
|
|
284
287
|
binary, judgment stays in the skills.
|
|
285
288
|
|
|
286
|
-
|
|
287
|
-
|
|
289
|
+
### Reading a lifecycle stage
|
|
290
|
+
|
|
291
|
+
Six more skills do one job between them: hand you a stage's own working document
|
|
292
|
+
and the instructions your project resolves for it, as a single read rather than a
|
|
293
|
+
file you open and a command you run separately. They only read — `tcw work stage
|
|
294
|
+
gate` is still what refuses.
|
|
295
|
+
|
|
296
|
+
[`tcw-work-stage`](skills/tcw-work-stage/SKILL.md) is the general one and takes
|
|
297
|
+
the stage id, so it reaches all seven stages including `inbox` and `postmortem`.
|
|
298
|
+
The other five bake their stage in and ask only for the work item, which is
|
|
299
|
+
optional: `tcw-work-stage-request`, `tcw-work-stage-spec`,
|
|
300
|
+
`tcw-work-stage-plan`, `tcw-work-stage-implement`, `tcw-work-stage-verify`.
|
|
301
|
+
|
|
302
|
+
Three read-only review agents ship alongside them — `tcw-verifier`,
|
|
303
|
+
`tcw-backlog-auditor`, and `tcw-post-mortem`, which accelerates the skill of the
|
|
304
|
+
same name — plus slash commands for each skill's main procedure
|
|
288
305
|
(`/tcw-plan-work`, `/tcw-drive-work-to-completion`, `/tcw-verify-work`,
|
|
289
306
|
`/tcw-process-inbox`, `/tcw-triage-issues`, `/tcw-audit-work-backlog`,
|
|
290
307
|
`/tcw-taxonomy-init`, `/tcw-capabilities-init`, `/tcw-docs-sync-setup`,
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tcw-cli"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.2"
|
|
8
8
|
description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.2"
|
|
@@ -18,6 +18,45 @@ from pathlib import Path
|
|
|
18
18
|
from tcw.store.base import RepositoryDeclaration, StoreDeclarationError
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def normalized_url(url: str) -> str:
|
|
22
|
+
"""A canonical form of `url`, for asking whether two declarations name the
|
|
23
|
+
**same repository**.
|
|
24
|
+
|
|
25
|
+
Identity, not display, and the distinction is load-bearing. `_cache_key`
|
|
26
|
+
below does similar-looking text handling, but only for the *readable* half
|
|
27
|
+
of a directory name: the half that decides which directory a store lands in
|
|
28
|
+
is a digest of the raw url. So it deliberately keeps two spellings of one
|
|
29
|
+
repository apart, which is the opposite of what this answers. The two must
|
|
30
|
+
not be folded into one helper — routing that digest through here would
|
|
31
|
+
rename every provisioned directory on every machine at once, silently, and
|
|
32
|
+
orphan every store already in the cache.
|
|
33
|
+
|
|
34
|
+
Handles the spellings one workspace actually mixes, because the two sides of
|
|
35
|
+
a connection are written by different people at different times: a trailing
|
|
36
|
+
slash, a `.git` suffix, and `git@host:owner/repo` against
|
|
37
|
+
`https://host/owner/repo`. A local filesystem path round-trips unchanged,
|
|
38
|
+
which the tests rely on — they use real repositories in a temporary
|
|
39
|
+
directory as remotes.
|
|
40
|
+
|
|
41
|
+
The host is lowercased because hostnames are case-insensitive. The path is
|
|
42
|
+
not, because whether a forge treats `Owner/Repo` and `owner/repo` as one
|
|
43
|
+
thing varies, and merging them here would be this function claiming to know.
|
|
44
|
+
A port is kept as though it were a path segment: two spellings carrying the
|
|
45
|
+
same port still match each other, which is all identity needs, and dropping
|
|
46
|
+
it could merge two genuinely different hosts.
|
|
47
|
+
"""
|
|
48
|
+
cleaned = url.strip().rstrip("/")
|
|
49
|
+
if cleaned.endswith(".git"):
|
|
50
|
+
cleaned = cleaned[:-4]
|
|
51
|
+
cleaned = re.sub(r"^[A-Za-z][A-Za-z0-9+.-]*://", "", cleaned)
|
|
52
|
+
cleaned = cleaned.rpartition("@")[2] # drop any `git@` user part
|
|
53
|
+
host, slash, rest = cleaned.partition("/")
|
|
54
|
+
if ":" in host: # scp-style `host:owner/repo`
|
|
55
|
+
host, _, prefix = host.partition(":")
|
|
56
|
+
rest = f"{prefix}/{rest}" if slash else prefix
|
|
57
|
+
return f"{host.lower()}/{rest}"
|
|
58
|
+
|
|
59
|
+
|
|
21
60
|
def _cache_root() -> Path:
|
|
22
61
|
"""Where working copies land when a declaration names no `checkout`.
|
|
23
62
|
|
|
@@ -1552,7 +1552,8 @@ class FsTreeStore:
|
|
|
1552
1552
|
|
|
1553
1553
|
def _write_staged(self, pairs: list[tuple[Path, str]], *,
|
|
1554
1554
|
owned_dir: Path | None = None,
|
|
1555
|
-
also_stage: tuple[Path, ...] = ()
|
|
1555
|
+
also_stage: tuple[Path, ...] = (),
|
|
1556
|
+
stage_root: Path | None = None) -> None:
|
|
1556
1557
|
"""Write every `(path, content)` and stage the lot, undoing what *this
|
|
1557
1558
|
call* created if either half fails, then re-raising.
|
|
1558
1559
|
|
|
@@ -1575,6 +1576,12 @@ class FsTreeStore:
|
|
|
1575
1576
|
|
|
1576
1577
|
Best-effort and silent: the undo must not mask the original error, and
|
|
1577
1578
|
must not add a second line to a refusal whose one-line shape is pinned.
|
|
1579
|
+
|
|
1580
|
+
`stage_root` names the repository to stage in, for the one write that is
|
|
1581
|
+
not a store file: the node's own `tcw-config.yaml`. The store root and
|
|
1582
|
+
the node root vary independently — in the orchestrator layout they are
|
|
1583
|
+
different repositories — and `git add` refuses a path outside the
|
|
1584
|
+
repository it is given. Everything else defaults to the store's.
|
|
1578
1585
|
"""
|
|
1579
1586
|
# `exists() or is_symlink()`: `exists()` follows the link, so a
|
|
1580
1587
|
# pre-existing *dangling* symlink read as absent, was replaced by a real
|
|
@@ -1583,7 +1590,12 @@ class FsTreeStore:
|
|
|
1583
1590
|
new = [p for p, _ in pairs if not (p.exists() or p.is_symlink())]
|
|
1584
1591
|
try:
|
|
1585
1592
|
_atomic_write_all(pairs)
|
|
1586
|
-
|
|
1593
|
+
paths = (*(p for p, _ in pairs), *also_stage)
|
|
1594
|
+
if stage_root is None:
|
|
1595
|
+
self._stage(*paths)
|
|
1596
|
+
else:
|
|
1597
|
+
self._require_repository()
|
|
1598
|
+
git_stage(stage_root, *paths)
|
|
1587
1599
|
except BaseException:
|
|
1588
1600
|
if owned_dir is not None:
|
|
1589
1601
|
shutil.rmtree(owned_dir, ignore_errors=True)
|
|
@@ -2936,11 +2948,41 @@ def _is_store_layout(root: Path, component: str) -> bool:
|
|
|
2936
2948
|
return all((root / name).is_dir() for name in STORE_LAYOUT)
|
|
2937
2949
|
|
|
2938
2950
|
|
|
2951
|
+
def _registry_checkout_root(
|
|
2952
|
+
node_root: Path, declaration: "RepositoryDeclaration"
|
|
2953
|
+
) -> Path | None:
|
|
2954
|
+
"""Where the declared store sits inside a project this machine already has.
|
|
2955
|
+
|
|
2956
|
+
The join the ladder's rung 1.5 needs: ask the project registry which project
|
|
2957
|
+
is a checkout of the declared repository, then descend to the store's path
|
|
2958
|
+
within it. None when the registry cannot answer, which sends the ladder on
|
|
2959
|
+
to the provisioned copy.
|
|
2960
|
+
|
|
2961
|
+
**Every failure here is "no answer", never an error.** This rung is a
|
|
2962
|
+
fallback reached only after a configured path has already failed, and a
|
|
2963
|
+
graph problem must not turn a working provisioned store into a refusal. So
|
|
2964
|
+
the registry is opened without `require_valid()` — the shape `run_provision`
|
|
2965
|
+
already uses for the same reason — and anything raised is swallowed.
|
|
2966
|
+
"""
|
|
2967
|
+
try:
|
|
2968
|
+
found = FsProjectRegistry.open(node_root).checkout_of(declaration.url)
|
|
2969
|
+
except Exception:
|
|
2970
|
+
return None
|
|
2971
|
+
if found is None:
|
|
2972
|
+
return None
|
|
2973
|
+
return (found / declaration.path) if declaration.path else found
|
|
2974
|
+
|
|
2975
|
+
|
|
2939
2976
|
def resolve_store(store_cls, node_root: Path, _walk=None):
|
|
2940
2977
|
"""This node's store for `store_cls`'s component, in one ordered ladder.
|
|
2941
2978
|
|
|
2942
2979
|
1. the local store (`<component>.path`, else `docs/<component>`) when it is
|
|
2943
2980
|
usable;
|
|
2981
|
+
1.5. else inside a project the registry has already located here, when one
|
|
2982
|
+
is a checkout of the declared repository — local before remote, the same
|
|
2983
|
+
rule `ConnectedProject` states for a project's own location. Numbered as
|
|
2984
|
+
a half because it was added after the others and renumbering them would
|
|
2985
|
+
silently rewrite what every message and comment elsewhere calls "rule 2";
|
|
2944
2986
|
2. else the declared home repository's provisioned location, if usable;
|
|
2945
2987
|
3. else `StoreNotProvisioned`, when a home repository is declared;
|
|
2946
2988
|
4. else exactly what the component did before a declaration existed — the
|
|
@@ -3018,17 +3060,46 @@ def resolve_store(store_cls, node_root: Path, _walk=None):
|
|
|
3018
3060
|
raw_root, node_root, config_path,
|
|
3019
3061
|
external=configured is not None, must_exist=must_exist,
|
|
3020
3062
|
_walk=_walk)
|
|
3021
|
-
except StoreLocationUnusable:
|
|
3063
|
+
except StoreLocationUnusable as unusable:
|
|
3022
3064
|
# Only this. A store that is *there* and fails to open — a federation
|
|
3023
3065
|
# error, a malformed `extends` — is a real error, and swallowing it here
|
|
3024
3066
|
# reported "not provisioned; run `tcw provision`", which then succeeded
|
|
3025
3067
|
# and left the store exactly as unopenable. `_extended_component_stores`
|
|
3026
3068
|
# raises for many more reasons than it used to, so the hole widened.
|
|
3027
|
-
|
|
3069
|
+
#
|
|
3070
|
+
# The reason is carried rather than discarded, but **only when the
|
|
3071
|
+
# configured path is actually there**. A path that does not exist,
|
|
3072
|
+
# beside a declaration, is the ordinary case the declaration exists for
|
|
3073
|
+
# — reporting it would make every provisioned node noisy. A path that
|
|
3074
|
+
# exists and holds no store is a second configuration problem, and it
|
|
3075
|
+
# used to vanish behind rule 3's message entirely.
|
|
3076
|
+
#
|
|
3077
|
+
# Presence is tested here rather than read out of the message: `_open_at`
|
|
3078
|
+
# raises the same "is not a directory" for an absent path and for one
|
|
3079
|
+
# that is a file, so the message cannot tell them apart.
|
|
3080
|
+
local_failure = str(unusable) if raw_root.exists() else None
|
|
3081
|
+
prefix = f"{config_path}: "
|
|
3082
|
+
if local_failure and local_failure.startswith(prefix):
|
|
3083
|
+
local_failure = local_failure[len(prefix):]
|
|
3084
|
+
local = _registry_checkout_root(node_root, declaration)
|
|
3085
|
+
if local is not None:
|
|
3086
|
+
try: # rule 1.5
|
|
3087
|
+
# No `declaration=`, and that omission is the whole of "must not
|
|
3088
|
+
# publish". This store is on the user's own disk and they push it
|
|
3089
|
+
# themselves — the same reasoning rule 1 relies on. `publishes`
|
|
3090
|
+
# consults nothing else.
|
|
3091
|
+
return store_cls._open_at(
|
|
3092
|
+
local, node_root, config_path,
|
|
3093
|
+
external=True, must_exist=True, _walk=_walk)
|
|
3094
|
+
except StoreLocationUnusable:
|
|
3095
|
+
# The project is here but holds no store at the declared path.
|
|
3096
|
+
# A location that did not work out, so the ladder carries on —
|
|
3097
|
+
# the same rule rules 1 and 2 follow.
|
|
3098
|
+
pass
|
|
3028
3099
|
try: # rule 2
|
|
3029
|
-
# The declaration travels with the store *only* here.
|
|
3030
|
-
# resolved without it, so a store built there carries None and
|
|
3031
|
-
# publish — see `FsWorkStore.publishes`.
|
|
3100
|
+
# The declaration travels with the store *only* here. Rules 1 and 1.5
|
|
3101
|
+
# above resolved without it, so a store built there carries None and
|
|
3102
|
+
# does not publish — see `FsWorkStore.publishes`.
|
|
3032
3103
|
return store_cls._open_at(
|
|
3033
3104
|
provisioned_store_root(node_root, declaration), node_root, config_path,
|
|
3034
3105
|
external=True, must_exist=True, declaration=declaration,
|
|
@@ -3038,7 +3109,8 @@ def resolve_store(store_cls, node_root: Path, _walk=None):
|
|
|
3038
3109
|
raise StoreNotProvisioned( # rule 3
|
|
3039
3110
|
f"{config_path}: the {component} store is declared in "
|
|
3040
3111
|
f"{declaration.url} but has not been provisioned here; "
|
|
3041
|
-
f"run `tcw provision` to obtain it"
|
|
3112
|
+
f"run `tcw provision` to obtain it"
|
|
3113
|
+
+ (f" — and the configured {local_failure}" if local_failure else ""))
|
|
3042
3114
|
|
|
3043
3115
|
|
|
3044
3116
|
def declared_repository(
|
|
@@ -3383,8 +3455,13 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
3383
3455
|
root = raw_root.resolve()
|
|
3384
3456
|
missing = [name for name in ("inbox", *WORK_STATUSES) if not (root / name).is_dir()]
|
|
3385
3457
|
if missing:
|
|
3458
|
+
# Names the directory, as the two branches above already do. Without
|
|
3459
|
+
# it the user is told `work.path` is not a work store and never told
|
|
3460
|
+
# which path that is — which matters most exactly when the value is
|
|
3461
|
+
# relative and resolved somewhere they did not expect.
|
|
3386
3462
|
raise StoreLocationUnusable(
|
|
3387
|
-
f"{config_path}: work.path is not a work store
|
|
3463
|
+
f"{config_path}: work.path is not a work store: {root}; "
|
|
3464
|
+
f"missing: {', '.join(missing)}")
|
|
3388
3465
|
repository = git_root(root) if external else node_root
|
|
3389
3466
|
if repository is None and external:
|
|
3390
3467
|
# `StoreDeclarationError`, and both halves of that choice matter.
|
|
@@ -5158,7 +5235,18 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
5158
5235
|
def _write_tags(self, tags: set[str]) -> list[str]:
|
|
5159
5236
|
"""Read-modify-write `work.tags` (preserving other config keys), stage
|
|
5160
5237
|
the file. `dump_yaml` rewrites the sentinel wholesale, dropping its stub
|
|
5161
|
-
comments — accepted per plan.
|
|
5238
|
+
comments — accepted per plan.
|
|
5239
|
+
|
|
5240
|
+
**Staged in the node's repository, not the store's.** This is the only
|
|
5241
|
+
write here that touches a file outside the store, and with an external
|
|
5242
|
+
`work.path` the two are different repositories — the intended
|
|
5243
|
+
orchestrator layout, not an edge case. Staging it against the store's
|
|
5244
|
+
repository made `tcw work tags add` fail outright with *is outside
|
|
5245
|
+
repository at …*, so the whole verb was unusable there.
|
|
5246
|
+
|
|
5247
|
+
A node outside git stages nothing rather than failing: the file is
|
|
5248
|
+
written and that is the whole of what a non-git node can do.
|
|
5249
|
+
"""
|
|
5162
5250
|
self._require_repository()
|
|
5163
5251
|
config = self._config()
|
|
5164
5252
|
work = config.get("work")
|
|
@@ -5167,9 +5255,14 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
5167
5255
|
result = sorted(tags)
|
|
5168
5256
|
work["tags"] = result
|
|
5169
5257
|
config["work"] = work
|
|
5170
|
-
self.
|
|
5171
|
-
|
|
5172
|
-
|
|
5258
|
+
config_path = self._config_path()
|
|
5259
|
+
node_repository = git_root(config_path.parent)
|
|
5260
|
+
payload = [(config_path, yaml.safe_dump(config, sort_keys=False,
|
|
5261
|
+
allow_unicode=True))]
|
|
5262
|
+
if node_repository is None:
|
|
5263
|
+
_atomic_write_all(payload)
|
|
5264
|
+
else:
|
|
5265
|
+
self._write_staged(payload, stage_root=node_repository)
|
|
5173
5266
|
return result
|
|
5174
5267
|
|
|
5175
5268
|
def register_tags(self, tags: list[str]) -> list[str]:
|
|
@@ -16,7 +16,7 @@ from tcw.store.base import (
|
|
|
16
16
|
RepositoryDeclaration, StoreDeclarationError, UnreachableProject,
|
|
17
17
|
WORK_STATUSES, parse_connected_entry,
|
|
18
18
|
)
|
|
19
|
-
from tcw.store.checkouts import provisioned_root
|
|
19
|
+
from tcw.store.checkouts import normalized_url, provisioned_root
|
|
20
20
|
|
|
21
21
|
SENTINEL = "tcw-config.yaml"
|
|
22
22
|
PROJECT_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
@@ -231,6 +231,45 @@ class FsProjectRegistry(ProjectRegistry):
|
|
|
231
231
|
self._load_graph()
|
|
232
232
|
return [cfg.project for cfg in self._by_id.values()]
|
|
233
233
|
|
|
234
|
+
def checkout_of(self, url: str) -> Path | None:
|
|
235
|
+
"""Where a project this graph holds is a checkout of `url`, or None.
|
|
236
|
+
|
|
237
|
+
**Filesystem-adapter private, deliberately not on `ProjectRegistry`.**
|
|
238
|
+
The question itself is storage-neutral — a tracker-backed registry could
|
|
239
|
+
answer "which of your projects comes from this source" perfectly well —
|
|
240
|
+
but answering it here means comparing `RepositoryDeclaration.url` and
|
|
241
|
+
returning a `Project.locator`, and both are documented as things nothing
|
|
242
|
+
above the adapter may read. The one caller is `resolve_store` in
|
|
243
|
+
`fs.py`, which is the same adapter reading its own values.
|
|
244
|
+
|
|
245
|
+
Asks the *resolved* project rather than the declaration that named it.
|
|
246
|
+
That is the entire point: a declaration says where a project comes from,
|
|
247
|
+
and only the graph knows where it landed here, `TCW_PROJECT_<ID>`
|
|
248
|
+
included. A lookup that returned the declared locator would answer with
|
|
249
|
+
the path the config wrote, which on a machine needing this is exactly
|
|
250
|
+
the path that does not exist.
|
|
251
|
+
|
|
252
|
+
A project is reached through the edges pointing at it, since a node's
|
|
253
|
+
own repository is declared by whoever knows about that edge rather than
|
|
254
|
+
by the node itself. A declared project the graph does not hold answers
|
|
255
|
+
None: it names a repository but no location here, and sending the store
|
|
256
|
+
ladder to a path that is not there is worse than sending it nowhere.
|
|
257
|
+
"""
|
|
258
|
+
self._load_graph()
|
|
259
|
+
wanted = normalized_url(url)
|
|
260
|
+
if not wanted:
|
|
261
|
+
return None
|
|
262
|
+
for cfg in list(self._cache.values()):
|
|
263
|
+
for entry in (*cfg.parent.values(), *cfg.children.values()):
|
|
264
|
+
if entry.repository is None:
|
|
265
|
+
continue
|
|
266
|
+
if normalized_url(entry.repository.url) != wanted:
|
|
267
|
+
continue
|
|
268
|
+
target = self._by_id.get(entry.id)
|
|
269
|
+
if target is not None:
|
|
270
|
+
return Path(target.project.locator).resolve()
|
|
271
|
+
return None
|
|
272
|
+
|
|
234
273
|
def check(self) -> list[str]:
|
|
235
274
|
return list(self._problems)
|
|
236
275
|
|
|
@@ -21,7 +21,10 @@ from typing import Literal
|
|
|
21
21
|
import yaml
|
|
22
22
|
|
|
23
23
|
from tcw.refs import resolve_tcw_ref
|
|
24
|
-
from tcw.store.
|
|
24
|
+
from tcw.store.base import StoreLocationUnusable
|
|
25
|
+
from tcw.store.fs import (
|
|
26
|
+
STORE_CLASSES, FsCapabilitiesStore, FsTaxonomyStore, FsWorkStore, load_yaml,
|
|
27
|
+
)
|
|
25
28
|
|
|
26
29
|
_COMPONENTS = ("taxonomy", "capabilities", "work")
|
|
27
30
|
_LINK_RE = re.compile(r"\]\((tcw://[^)\s]+)\)")
|
|
@@ -126,6 +129,56 @@ def _components_to_check(node_root: Path, path) -> list[str]:
|
|
|
126
129
|
return []
|
|
127
130
|
|
|
128
131
|
|
|
132
|
+
def _configured_path_problem(node_root: Path, comp: str) -> str | None:
|
|
133
|
+
"""A configured `<comp>.path` that is present and holds no store, or None.
|
|
134
|
+
|
|
135
|
+
**Asked independently of resolution, and that is the whole point.** The
|
|
136
|
+
resolution ladder treats an unusable configured path as a location that did
|
|
137
|
+
not work out and moves on to the declaration, which is right for reading and
|
|
138
|
+
wrong for a command whose entire job is enumerating configuration problems.
|
|
139
|
+
Two faults were reported as one, and `_run_check` collapses any opening
|
|
140
|
+
failure into a single string, so even a compound message still counts once.
|
|
141
|
+
|
|
142
|
+
Running this only when resolution fails would miss the user it helps most:
|
|
143
|
+
the one whose declared store provisions fine, so nothing ever tells them the
|
|
144
|
+
path they wrote is wrong and they quietly read a second copy of the board.
|
|
145
|
+
|
|
146
|
+
**Silent when the path is absent.** With a declaration present that is the
|
|
147
|
+
ordinary case the declaration exists for, and reporting it would make every
|
|
148
|
+
provisioned node noisy. Presence is tested here rather than inferred from
|
|
149
|
+
the message, because `_open_at` raises the same text for a path that is
|
|
150
|
+
missing and one that is a file.
|
|
151
|
+
|
|
152
|
+
Asks the component's own `_open_at` rather than re-listing what a store
|
|
153
|
+
looks like. A third spelling of the store layout is exactly the drift the
|
|
154
|
+
shared ladder exists to prevent.
|
|
155
|
+
"""
|
|
156
|
+
config_path = node_root / "tcw-config.yaml"
|
|
157
|
+
try:
|
|
158
|
+
config = load_yaml(config_path, unique=True)
|
|
159
|
+
except Exception:
|
|
160
|
+
return None # reported by the YAML pass above
|
|
161
|
+
section = config.get(comp) if isinstance(config, dict) else None
|
|
162
|
+
configured = section.get("path") if isinstance(section, dict) else None
|
|
163
|
+
if not isinstance(configured, str) or not configured.strip():
|
|
164
|
+
return None
|
|
165
|
+
store_cls = STORE_CLASSES[comp]
|
|
166
|
+
raw_root = store_cls._local_root(node_root, configured)
|
|
167
|
+
if not raw_root.exists():
|
|
168
|
+
return None
|
|
169
|
+
try:
|
|
170
|
+
store_cls._open_at(raw_root, node_root, config_path,
|
|
171
|
+
external=True, must_exist=True)
|
|
172
|
+
except StoreLocationUnusable as unusable:
|
|
173
|
+
return str(unusable)
|
|
174
|
+
except Exception:
|
|
175
|
+
# Anything else is either not this check's business or is already
|
|
176
|
+
# reported by the component check beside it. Never swallow it into a
|
|
177
|
+
# second, differently worded copy of the same fault.
|
|
178
|
+
return None
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
129
182
|
def _run_check(node_root: Path, comp: str, identifier: str | None = None) -> list[str]:
|
|
130
183
|
"""One component's `check()`, or the reason its store could not be opened.
|
|
131
184
|
|
|
@@ -237,6 +290,11 @@ def validate(node_root: Path, path: Path | None = None, *,
|
|
|
237
290
|
else:
|
|
238
291
|
components = [target.axis] if target is not None else _components_to_check(node_root, path)
|
|
239
292
|
for comp in components:
|
|
293
|
+
# Before the component's own check, so a node reads "your path is
|
|
294
|
+
# broken" ahead of whatever the store it fell back to has to say.
|
|
295
|
+
configured = _configured_path_problem(node_root, comp)
|
|
296
|
+
if configured is not None:
|
|
297
|
+
problems.append(f"{comp} path: {configured}")
|
|
240
298
|
problems += _run_check(node_root, comp, target.ref if target else None)
|
|
241
299
|
|
|
242
300
|
return problems
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tcw-cli
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -483,8 +483,10 @@ Every command group also has `--help`, and a `check` that validates its tree.
|
|
|
483
483
|
|
|
484
484
|
## Skills — the judgment layer
|
|
485
485
|
|
|
486
|
-
The CLI is the _mechanism_.
|
|
487
|
-
_judgment_ that drives it — the parts a deterministic tool cannot decide.
|
|
486
|
+
The CLI is the _mechanism_. Fourteen skills in [`skills/`](skills/) supply the
|
|
487
|
+
_judgment_ that drives it — the parts a deterministic tool cannot decide. Eight
|
|
488
|
+
carry a distinct procedure; the other six all compose one lifecycle stage and
|
|
489
|
+
are listed together at the end.
|
|
488
490
|
|
|
489
491
|
| Skill | What it does |
|
|
490
492
|
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -495,12 +497,27 @@ _judgment_ that drives it — the parts a deterministic tool cannot decide.
|
|
|
495
497
|
| [`tcw-report`](skills/tcw-report/SKILL.md) | Reporting a `tcw` bug or suggestion upstream to [this project's issues](https://github.com/brocef/TCW/issues) |
|
|
496
498
|
| [`tcw-triage-issues`](skills/tcw-triage-issues/SKILL.md) | Sweeps **your** project's GitHub issues and turns the ones worth doing into work items |
|
|
497
499
|
| [`documentation-sync`](skills/documentation-sync/SKILL.md) | Keeps README, changelogs, release notes, and driving skills moving with the code that changes them |
|
|
500
|
+
| [`tcw-post-mortem`](skills/tcw-post-mortem/SKILL.md) | Finds which lifecycle stage could first have caught a problem, once one has surfaced |
|
|
498
501
|
|
|
499
502
|
They name `tcw` commands and never reimplement tool logic: mechanism stays in the
|
|
500
503
|
binary, judgment stays in the skills.
|
|
501
504
|
|
|
502
|
-
|
|
503
|
-
|
|
505
|
+
### Reading a lifecycle stage
|
|
506
|
+
|
|
507
|
+
Six more skills do one job between them: hand you a stage's own working document
|
|
508
|
+
and the instructions your project resolves for it, as a single read rather than a
|
|
509
|
+
file you open and a command you run separately. They only read — `tcw work stage
|
|
510
|
+
gate` is still what refuses.
|
|
511
|
+
|
|
512
|
+
[`tcw-work-stage`](skills/tcw-work-stage/SKILL.md) is the general one and takes
|
|
513
|
+
the stage id, so it reaches all seven stages including `inbox` and `postmortem`.
|
|
514
|
+
The other five bake their stage in and ask only for the work item, which is
|
|
515
|
+
optional: `tcw-work-stage-request`, `tcw-work-stage-spec`,
|
|
516
|
+
`tcw-work-stage-plan`, `tcw-work-stage-implement`, `tcw-work-stage-verify`.
|
|
517
|
+
|
|
518
|
+
Three read-only review agents ship alongside them — `tcw-verifier`,
|
|
519
|
+
`tcw-backlog-auditor`, and `tcw-post-mortem`, which accelerates the skill of the
|
|
520
|
+
same name — plus slash commands for each skill's main procedure
|
|
504
521
|
(`/tcw-plan-work`, `/tcw-drive-work-to-completion`, `/tcw-verify-work`,
|
|
505
522
|
`/tcw-process-inbox`, `/tcw-triage-issues`, `/tcw-audit-work-backlog`,
|
|
506
523
|
`/tcw-taxonomy-init`, `/tcw-capabilities-init`, `/tcw-docs-sync-setup`,
|
|
@@ -1186,3 +1186,32 @@ def test_get_detail_of_a_genuinely_unknown_slug_is_none(tmp_path):
|
|
|
1186
1186
|
code = _repo(tmp_path / "code")
|
|
1187
1187
|
init(["work"], code, "corelib")
|
|
1188
1188
|
assert FsWorkStore.open(code).get_detail("2026-01-01-nope") is None
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def test_registering_a_tag_stages_the_config_in_its_own_repository(tmp_path):
|
|
1192
|
+
"""`work.tags` lives in the node's `tcw-config.yaml`, which an external store
|
|
1193
|
+
puts in a *different* repository from the store.
|
|
1194
|
+
|
|
1195
|
+
Staging it against the store's repository fails outright — `is outside
|
|
1196
|
+
repository at …` — so registering a tag was impossible in the orchestrator
|
|
1197
|
+
layout this project documents as the intended one. The store root and the
|
|
1198
|
+
node root vary independently; a write has to go to whichever repository owns
|
|
1199
|
+
the file it touches.
|
|
1200
|
+
"""
|
|
1201
|
+
code, work_root = _external_node(tmp_path, "../orchestrator/stores/corelib")
|
|
1202
|
+
store = FsWorkStore.open(code)
|
|
1203
|
+
assert store.store_git_root != store.node_root, "the premise of this test"
|
|
1204
|
+
|
|
1205
|
+
assert store.register_tags(["chore"]) == ["chore"]
|
|
1206
|
+
|
|
1207
|
+
assert "chore" in yaml.safe_load(
|
|
1208
|
+
(code / "tcw-config.yaml").read_text())["work"]["tags"]
|
|
1209
|
+
staged = subprocess.run(
|
|
1210
|
+
["git", "-C", str(code), "diff", "--cached", "--name-only"],
|
|
1211
|
+
capture_output=True, text=True, check=True).stdout.split()
|
|
1212
|
+
assert "tcw-config.yaml" in staged, \
|
|
1213
|
+
"the config must be staged in the repository that holds it"
|
|
1214
|
+
store_staged = subprocess.run(
|
|
1215
|
+
["git", "-C", str(store.store_git_root), "diff", "--cached", "--name-only"],
|
|
1216
|
+
capture_output=True, text=True, check=True).stdout
|
|
1217
|
+
assert "tcw-config.yaml" not in store_staged
|