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.
Files changed (116) hide show
  1. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/PKG-INFO +22 -5
  2. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/README.md +21 -4
  3. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/pyproject.toml +1 -1
  4. tcw_cli-2.0.2/tcw/__init__.py +1 -0
  5. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/checkouts.py +39 -0
  6. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/fs.py +106 -13
  7. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/project.py +40 -1
  8. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/validate.py +59 -1
  9. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/PKG-INFO +22 -5
  10. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_external_work_store.py +29 -0
  11. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_plugin_manifests.py +49 -3
  12. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_project_registry.py +103 -0
  13. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_skill_lifecycle_parity.py +103 -19
  14. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_provisioning.py +226 -7
  15. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_validate.py +101 -0
  16. tcw_cli-2.0.0/tcw/__init__.py +0 -1
  17. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/LICENSE +0 -0
  18. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/setup.cfg +0 -0
  19. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/capabilities/__init__.py +0 -0
  20. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/capabilities/cli.py +0 -0
  21. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/cli.py +0 -0
  22. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/refs.py +0 -0
  23. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/__init__.py +0 -0
  24. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  25. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
  26. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/client/index.html +0 -0
  27. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/client/theme-init.js +0 -0
  28. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/dist/server.cjs +0 -0
  29. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/serve/runtime.py +0 -0
  30. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/stdin.py +0 -0
  31. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/__init__.py +0 -0
  32. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/store/base.py +0 -0
  33. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/taxonomy/__init__.py +0 -0
  34. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/taxonomy/cli.py +0 -0
  35. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/__init__.py +0 -0
  36. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/cli.py +0 -0
  37. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/generate.py +0 -0
  38. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/hooks.py +0 -0
  39. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/projection.py +0 -0
  40. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/implement.md +0 -0
  41. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/inbox.md +0 -0
  42. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/plan.md +0 -0
  43. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/postmortem.md +0 -0
  44. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/request.md +0 -0
  45. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/spec.md +0 -0
  46. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/prompts/verify.md +0 -0
  47. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/recursion.py +0 -0
  48. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/resolve.py +0 -0
  49. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw/work/templates.py +0 -0
  50. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/SOURCES.txt +0 -0
  51. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/dependency_links.txt +0 -0
  52. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/entry_points.txt +0 -0
  53. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/requires.txt +0 -0
  54. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tcw_cli.egg-info/top_level.txt +0 -0
  55. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_body_prompt.py +0 -0
  56. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capabilities.py +0 -0
  57. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capabilities_federation.py +0 -0
  58. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capabilities_reset.py +0 -0
  59. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capabilities_sidecar.py +0 -0
  60. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_capability_ref_wording.py +0 -0
  61. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_cut_version.py +0 -0
  62. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_documentation_config.py +0 -0
  63. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_documentation_prompt.py +0 -0
  64. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_documentation_sync_wiring.py +0 -0
  65. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_documented_cli_surface.py +0 -0
  66. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_environment_hardness.py +0 -0
  67. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_epic_completable.py +0 -0
  68. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_falsification_rule.py +0 -0
  69. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_generate_hook.py +0 -0
  70. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_inbox_title.py +0 -0
  71. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_baseline.py +0 -0
  72. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_hooks.py +0 -0
  73. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_inert.py +0 -0
  74. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_policy.py +0 -0
  75. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_lifecycle_validation.py +0 -0
  76. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_multiproject.py +0 -0
  77. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_non_git_writes.py +0 -0
  78. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_project_overrides.py +0 -0
  79. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_projection.py +0 -0
  80. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_prompt_fallback.py +0 -0
  81. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_qualified_ref.py +0 -0
  82. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_recursion.py +0 -0
  83. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_refs.py +0 -0
  84. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_remote_session_setup.py +0 -0
  85. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_repo_lifecycle.py +0 -0
  86. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_resolve.py +0 -0
  87. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_retention.py +0 -0
  88. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_scaffold.py +0 -0
  89. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve.py +0 -0
  90. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_descendants.py +0 -0
  91. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_projection.py +0 -0
  92. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_resolve.py +0 -0
  93. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_runtime.py +0 -0
  94. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_serve_write.py +0 -0
  95. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_session_bootstrap.py +0 -0
  96. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_shipped_prompts.py +0 -0
  97. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_show_json.py +0 -0
  98. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_skill_flow.py +0 -0
  99. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_smoke.py +0 -0
  100. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_stage_verb.py +0 -0
  101. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_status_parity.py +0 -0
  102. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_stdin.py +0 -0
  103. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_stdin_cli.py +0 -0
  104. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_bounds.py +0 -0
  105. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_editor.py +0 -0
  106. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_nodes.py +0 -0
  107. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_store_publication.py +0 -0
  108. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_subprocess_stdin.py +0 -0
  109. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_taxonomy.py +0 -0
  110. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_tombstone.py +0 -0
  111. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_unpushed_version_script.py +0 -0
  112. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_validate_target.py +0 -0
  113. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_work.py +0 -0
  114. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_work_autocommit.py +0 -0
  115. {tcw_cli-2.0.0 → tcw_cli-2.0.2}/tests/test_work_review.py +0 -0
  116. {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.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_. Seven skills in [`skills/`](skills/) supply the
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
- Two read-only review agents ship alongside them — `tcw-verifier` and
503
- `tcw-post-mortem` — plus slash commands for each skill's main procedure
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_. Seven skills in [`skills/`](skills/) supply the
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
- Two read-only review agents ship alongside them — `tcw-verifier` and
287
- `tcw-post-mortem` — plus slash commands for each skill's main procedure
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.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, ...] = ()) -> None:
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
- self._stage(*(p for p, _ in pairs), *also_stage)
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
- pass
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. Rule 1 above
3030
- # resolved without it, so a store built there carries None and does not
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; missing: {', '.join(missing)}")
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._write_staged([(self._config_path(),
5171
- yaml.safe_dump(config, sort_keys=False,
5172
- allow_unicode=True))])
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.fs import FsCapabilitiesStore, FsTaxonomyStore, FsWorkStore, load_yaml
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.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_. Seven skills in [`skills/`](skills/) supply the
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
- Two read-only review agents ship alongside them — `tcw-verifier` and
503
- `tcw-post-mortem` — plus slash commands for each skill's main procedure
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