tcw-cli 2.0.1__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.1 → tcw_cli-2.0.2}/PKG-INFO +1 -1
  2. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/pyproject.toml +1 -1
  3. tcw_cli-2.0.2/tcw/__init__.py +1 -0
  4. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/checkouts.py +39 -0
  5. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/fs.py +106 -13
  6. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/project.py +40 -1
  7. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/validate.py +59 -1
  8. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/PKG-INFO +1 -1
  9. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_external_work_store.py +29 -0
  10. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_project_registry.py +103 -0
  11. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_provisioning.py +226 -7
  12. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_validate.py +101 -0
  13. tcw_cli-2.0.1/tcw/__init__.py +0 -1
  14. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/LICENSE +0 -0
  15. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/README.md +0 -0
  16. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/setup.cfg +0 -0
  17. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/capabilities/__init__.py +0 -0
  18. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/capabilities/cli.py +0 -0
  19. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/cli.py +0 -0
  20. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/refs.py +0 -0
  21. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/__init__.py +0 -0
  22. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  23. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
  24. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/client/index.html +0 -0
  25. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/client/theme-init.js +0 -0
  26. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/server.cjs +0 -0
  27. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/runtime.py +0 -0
  28. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/stdin.py +0 -0
  29. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/__init__.py +0 -0
  30. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/base.py +0 -0
  31. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/taxonomy/__init__.py +0 -0
  32. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/taxonomy/cli.py +0 -0
  33. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/__init__.py +0 -0
  34. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/cli.py +0 -0
  35. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/generate.py +0 -0
  36. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/hooks.py +0 -0
  37. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/projection.py +0 -0
  38. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/implement.md +0 -0
  39. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/inbox.md +0 -0
  40. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/plan.md +0 -0
  41. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/postmortem.md +0 -0
  42. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/request.md +0 -0
  43. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/spec.md +0 -0
  44. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/verify.md +0 -0
  45. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/recursion.py +0 -0
  46. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/resolve.py +0 -0
  47. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/templates.py +0 -0
  48. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/SOURCES.txt +0 -0
  49. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/dependency_links.txt +0 -0
  50. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/entry_points.txt +0 -0
  51. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/requires.txt +0 -0
  52. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/top_level.txt +0 -0
  53. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_body_prompt.py +0 -0
  54. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capabilities.py +0 -0
  55. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capabilities_federation.py +0 -0
  56. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capabilities_reset.py +0 -0
  57. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capabilities_sidecar.py +0 -0
  58. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capability_ref_wording.py +0 -0
  59. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_cut_version.py +0 -0
  60. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_documentation_config.py +0 -0
  61. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_documentation_prompt.py +0 -0
  62. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_documentation_sync_wiring.py +0 -0
  63. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_documented_cli_surface.py +0 -0
  64. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_environment_hardness.py +0 -0
  65. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_epic_completable.py +0 -0
  66. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_falsification_rule.py +0 -0
  67. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_generate_hook.py +0 -0
  68. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_inbox_title.py +0 -0
  69. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_baseline.py +0 -0
  70. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_hooks.py +0 -0
  71. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_inert.py +0 -0
  72. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_policy.py +0 -0
  73. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_validation.py +0 -0
  74. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_multiproject.py +0 -0
  75. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_non_git_writes.py +0 -0
  76. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_plugin_manifests.py +0 -0
  77. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_project_overrides.py +0 -0
  78. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_projection.py +0 -0
  79. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_prompt_fallback.py +0 -0
  80. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_qualified_ref.py +0 -0
  81. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_recursion.py +0 -0
  82. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_refs.py +0 -0
  83. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_remote_session_setup.py +0 -0
  84. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_repo_lifecycle.py +0 -0
  85. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_resolve.py +0 -0
  86. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_retention.py +0 -0
  87. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_scaffold.py +0 -0
  88. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve.py +0 -0
  89. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_descendants.py +0 -0
  90. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_projection.py +0 -0
  91. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_resolve.py +0 -0
  92. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_runtime.py +0 -0
  93. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_write.py +0 -0
  94. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_session_bootstrap.py +0 -0
  95. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_shipped_prompts.py +0 -0
  96. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_show_json.py +0 -0
  97. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_skill_flow.py +0 -0
  98. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_skill_lifecycle_parity.py +0 -0
  99. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_smoke.py +0 -0
  100. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_stage_verb.py +0 -0
  101. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_status_parity.py +0 -0
  102. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_stdin.py +0 -0
  103. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_stdin_cli.py +0 -0
  104. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_bounds.py +0 -0
  105. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_editor.py +0 -0
  106. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_nodes.py +0 -0
  107. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_publication.py +0 -0
  108. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_subprocess_stdin.py +0 -0
  109. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_taxonomy.py +0 -0
  110. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_tombstone.py +0 -0
  111. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_unpushed_version_script.py +0 -0
  112. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_validate_target.py +0 -0
  113. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_work.py +0 -0
  114. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_work_autocommit.py +0 -0
  115. {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_work_review.py +0 -0
  116. {tcw_cli-2.0.1 → 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.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tcw-cli"
7
- version = "2.0.1"
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.1
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
@@ -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
@@ -835,3 +835,106 @@ def test_the_override_is_probed_once_per_project(tmp_path, monkeypatch):
835
835
  registry.require_valid()
836
836
  assert calls.count("child-project") == 1
837
837
  assert len(registry.overrides()) == 1
838
+
839
+
840
+ # ── locating a project by the repository it comes from ───────────────────────
841
+ #
842
+ # The question the store-resolution ladder asks when its configured path does
843
+ # not resolve: *is a checkout of this repository already on this disk?* The
844
+ # registry is the only thing that knows, because it is the only thing that has
845
+ # consulted `TCW_PROJECT_<ID>`.
846
+
847
+
848
+ def _workspace(tmp_path: Path, url: str, *, parent_at: str = "orchestration") -> tuple[Path, Path]:
849
+ """A child declaring its parent, and naming the repository the parent is a
850
+ checkout of. Returns `(child_root, parent_root)`.
851
+
852
+ Every axis the lookup branches on is written here rather than defaulted:
853
+ the declared locator, the repository url, and where the parent actually is.
854
+ """
855
+ parent = tmp_path / parent_at
856
+ child = tmp_path / "core"
857
+ config(parent, f"id: orchestration\nconnected-projects:\n children:\n core: {child}\n")
858
+ config(
859
+ child,
860
+ "id: core\n"
861
+ "connected-projects:\n"
862
+ " parent:\n"
863
+ " orchestration:\n"
864
+ f" path: {parent}\n"
865
+ " repository:\n"
866
+ f" url: {url}\n"
867
+ " ref: main\n",
868
+ )
869
+ return child, parent
870
+
871
+
872
+ def test_checkout_of_finds_a_project_declared_from_that_repository(tmp_path):
873
+ url = "https://github.invalid/acme/orchestration.git"
874
+ child, parent = _workspace(tmp_path, url)
875
+
876
+ registry = FsProjectRegistry.open(child)
877
+
878
+ assert registry.checkout_of(url) == parent.resolve()
879
+
880
+
881
+ @pytest.mark.parametrize("asked", [
882
+ "https://github.invalid/acme/orchestration",
883
+ "https://github.invalid/acme/orchestration/",
884
+ "git@github.invalid:acme/orchestration.git",
885
+ ])
886
+ def test_checkout_of_matches_across_url_spellings(tmp_path, asked):
887
+ """The two sides of a connection are written at different times by
888
+ different people, so one spelling is not a safe assumption."""
889
+ child, parent = _workspace(tmp_path, "https://github.invalid/acme/orchestration.git")
890
+
891
+ assert FsProjectRegistry.open(child).checkout_of(asked) == parent.resolve()
892
+
893
+
894
+ def test_checkout_of_returns_none_for_an_unrelated_repository(tmp_path):
895
+ child, _parent = _workspace(tmp_path, "https://github.invalid/acme/orchestration.git")
896
+
897
+ registry = FsProjectRegistry.open(child)
898
+
899
+ assert registry.checkout_of("https://github.invalid/acme/something-else.git") is None
900
+
901
+
902
+ def test_checkout_of_follows_an_override(tmp_path, monkeypatch):
903
+ """The whole point of the item this exists for.
904
+
905
+ The declared locator is right for the nested layout and wrong here; the
906
+ environment says where the parent actually is. A lookup that read the
907
+ declaration instead of the resolved project would answer with a directory
908
+ this machine does not have.
909
+ """
910
+ url = "https://github.invalid/acme/orchestration.git"
911
+ child, declared = _workspace(tmp_path, url)
912
+ elsewhere = tmp_path / "flat" / "orchestration"
913
+ config(elsewhere, f"id: orchestration\nconnected-projects:\n children:\n core: {child}\n")
914
+ monkeypatch.setenv("TCW_PROJECT_ORCHESTRATION", str(elsewhere))
915
+
916
+ found = FsProjectRegistry.open(child).checkout_of(url)
917
+
918
+ assert found == elsewhere.resolve()
919
+ assert found != declared.resolve(), "the declared locator must not win over the override"
920
+
921
+
922
+ def test_checkout_of_ignores_a_project_the_graph_does_not_hold(tmp_path):
923
+ """A declaration whose target is not on this machine names a repository but
924
+ no location. Answering with a path that is not there would send the store
925
+ ladder somewhere worse than nowhere."""
926
+ url = "https://github.invalid/acme/orchestration.git"
927
+ child = tmp_path / "core"
928
+ config(
929
+ child,
930
+ "id: core\n"
931
+ "connected-projects:\n"
932
+ " parent:\n"
933
+ " orchestration:\n"
934
+ f" path: {tmp_path / 'not-here'}\n"
935
+ " repository:\n"
936
+ f" url: {url}\n"
937
+ " ref: main\n",
938
+ )
939
+
940
+ assert FsProjectRegistry.open(child).checkout_of(url) is None