tcw-cli 0.20.1__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.20.1"
File without changes
@@ -0,0 +1,295 @@
1
+ """`tcw capabilities` — the user stories. Path-addressed folder capabilities."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from tcw.store.base import Capability, RefError
7
+ from tcw.store.base import AmbiguousRef
8
+ from tcw.store.fs import FsCapabilitiesStore, FsTaxonomyStore, find_node, git_root
9
+
10
+ NAME = "capabilities"
11
+ SUBCOMMANDS = {"init", "list", "show", "path", "add", "search", "check", "set", "reset", "extends", "drift"}
12
+ DEFAULT_SUBCOMMAND = "show" # `tcw capabilities <path>` == `tcw capabilities show <path>`
13
+
14
+
15
+ def _init(args: argparse.Namespace) -> int:
16
+ from tcw.cli import run_init # function-local: top-level cli imports this module
17
+ return run_init([NAME], args.id)
18
+
19
+
20
+ def _store() -> FsCapabilitiesStore | None:
21
+ node = find_node(NAME)
22
+ if node is None:
23
+ print("tcw capabilities: no tcw capabilities node here — run `tcw init` in the project folder.",
24
+ file=sys.stderr)
25
+ return None
26
+ return FsCapabilitiesStore.open(node)
27
+
28
+
29
+ def _taxonomy_for(node):
30
+ """The node's taxonomy store, if it has one (for cross-component Subject check)."""
31
+ return FsTaxonomyStore.open(node) if (node / "docs" / "taxonomy").is_dir() else None
32
+
33
+
34
+ def _stdin_body() -> str:
35
+ if sys.stdin.isatty():
36
+ return ""
37
+ try:
38
+ return sys.stdin.read()
39
+ except (OSError, ValueError):
40
+ return ""
41
+
42
+
43
+ def _fmt(v) -> str:
44
+ return ", ".join(v) if isinstance(v, list) else str(v)
45
+
46
+
47
+ def _print_cap(cap: Capability) -> None:
48
+ tag = f" [{cap.origin}]" if cap.origin != "local" else ""
49
+ print(f"## {cap.name} ({cap.qualified}){tag}")
50
+ if cap.id:
51
+ print(f"**id:** {cap.id}")
52
+ for k, v in cap.fields.items():
53
+ print(f"**{k}:** {_fmt(v)}")
54
+ body = cap.body.strip()
55
+ if body:
56
+ print()
57
+ print(body)
58
+
59
+
60
+ def _list(args: argparse.Namespace) -> int:
61
+ st = _store()
62
+ if st is None:
63
+ return 1
64
+ for c in st.list_all(status=args.status, namespace=args.namespace,
65
+ local_only=args.local_only):
66
+ print(f"[{c.status}]\t{c.qualified}\t{c.name}")
67
+ return 0
68
+
69
+
70
+ def _show(args: argparse.Namespace) -> int:
71
+ st = _store()
72
+ if st is None:
73
+ return 1
74
+ try:
75
+ cap = st.get(args.id)
76
+ except (RefError, AmbiguousRef) as e:
77
+ print(f"tcw capabilities show: {e}", file=sys.stderr)
78
+ return 1
79
+ if cap is None:
80
+ print(f"tcw capabilities show: no such capability: {args.id}", file=sys.stderr)
81
+ return 1
82
+ _print_cap(cap)
83
+ return 0
84
+
85
+
86
+ def _path(args: argparse.Namespace) -> int:
87
+ st = _store()
88
+ if st is None:
89
+ return 1
90
+ print(st.root)
91
+ return 0
92
+
93
+
94
+ def _add(args: argparse.Namespace) -> int:
95
+ st = _store()
96
+ if st is None:
97
+ return 1
98
+ try:
99
+ cap = st.add(args.path, name=args.name, status=args.status, body=_stdin_body())
100
+ except (ValueError, RefError) as e:
101
+ print(f"tcw capabilities add: {e}", file=sys.stderr)
102
+ return 1
103
+ print(f"Added capability {cap.path} ({cap.id})")
104
+ return 0
105
+
106
+
107
+ def _set(args: argparse.Namespace) -> int:
108
+ st = _store()
109
+ if st is None:
110
+ return 1
111
+ fields: dict = {}
112
+ if args.status:
113
+ fields["Status"] = args.status
114
+ for kv in (args.field or []):
115
+ if "=" not in kv:
116
+ print(f"tcw capabilities set: --field must be K=V: {kv}", file=sys.stderr)
117
+ return 1
118
+ k, v = kv.split("=", 1)
119
+ fields[k.strip()] = v.strip()
120
+ if not fields:
121
+ print("tcw capabilities set: need --status or at least one --field", file=sys.stderr)
122
+ return 1
123
+ try:
124
+ cap = st.set(args.id, fields)
125
+ except (ValueError, RefError) as e:
126
+ print(f"tcw capabilities set: {e}", file=sys.stderr)
127
+ return 1
128
+ print(f"Set {cap.path}")
129
+ return 0
130
+
131
+
132
+ def _reset(args: argparse.Namespace) -> int:
133
+ st = _store()
134
+ if st is None:
135
+ return 1
136
+ try:
137
+ st.reset(args.id)
138
+ except (ValueError, RefError) as e:
139
+ print(f"tcw capabilities reset: {e}", file=sys.stderr)
140
+ return 1
141
+ print(f"reset {args.id}")
142
+ return 0
143
+
144
+
145
+ def _search(args: argparse.Namespace) -> int:
146
+ st = _store()
147
+ if st is None:
148
+ return 1
149
+ for c in st.search(args.query):
150
+ print(f"{c.qualified}\t{c.name}")
151
+ return 0
152
+
153
+
154
+ def _extends(args: argparse.Namespace) -> int:
155
+ st = _store()
156
+ if st is None:
157
+ return 1
158
+ try:
159
+ if args.rm:
160
+ st.extends_remove(args.project_id)
161
+ print(f"Removed extends project {args.project_id}")
162
+ else:
163
+ st.extends_add(args.project_id)
164
+ print(f"Added extends project {args.project_id}")
165
+ except (ValueError, RefError) as e:
166
+ print(f"tcw capabilities extends: {e}", file=sys.stderr)
167
+ return 1
168
+ return 0
169
+
170
+
171
+ def _drift(args: argparse.Namespace) -> int:
172
+ """Report capabilities that drifted from ground truth: inherited-but-unreviewed
173
+ (status is the master default, never locally ruled on) and local-Missing whose
174
+ Planning doc points to a completed work item (declared, shipped, never flipped).
175
+ Read-only; exits non-zero when any drift is found."""
176
+ node = find_node(NAME)
177
+ if node is None:
178
+ print("tcw capabilities: no tcw capabilities node here — run `tcw init` in the project folder.",
179
+ file=sys.stderr)
180
+ return 1
181
+ st = FsCapabilitiesStore.open(node)
182
+
183
+ unreviewed = st.unreviewed_inherited()
184
+ shipped_missing = _shipped_but_missing(node, st)
185
+
186
+ for c in unreviewed:
187
+ print(f"unreviewed\t{c.qualified}\t(inherited; status is the master default)")
188
+ for path, slug in shipped_missing:
189
+ print(f"shipped-missing\t{path}\t(Planning doc {slug} is completed, still Missing)")
190
+
191
+ n = len(unreviewed) + len(shipped_missing)
192
+ if n:
193
+ print(f"{n} capability(ies) drifted.", file=sys.stderr)
194
+ return 1
195
+ print("no capability drift")
196
+ return 0
197
+
198
+
199
+ def _shipped_but_missing(node, st) -> list[tuple[str, str]]:
200
+ """Local Missing capabilities whose `Planning doc` names a completed work item.
201
+ Read-only follow of an existing capability→work forward pointer; degrades to
202
+ empty when no work node is present (no hard cross-axis dependency)."""
203
+ if not (node / "docs" / "work").is_dir():
204
+ return []
205
+ from tcw.store.fs import FsWorkStore
206
+ work = FsWorkStore.open(node)
207
+ out: list[tuple[str, str]] = []
208
+ for c in st.list_all(local_only=True):
209
+ if c.status != "Missing":
210
+ continue
211
+ slug = c.fields.get("Planning doc")
212
+ if not slug:
213
+ continue
214
+ try:
215
+ item = work.get(str(slug))
216
+ except Exception:
217
+ item = None
218
+ # `completed` alone, deliberately NOT `RESOLVED_STATUSES`: this asks
219
+ # "did it ship?", not "is it closed?". A discarded item's capability is
220
+ # *supposed* to stay Missing (or be marked Omitted) — reporting it as
221
+ # shipped-but-unreconciled would be a false positive, which is exactly
222
+ # what happened before `discarded` existed.
223
+ if item is not None and item.status == "completed":
224
+ out.append((c.path, str(slug)))
225
+ return out
226
+
227
+
228
+ def _check(args: argparse.Namespace) -> int:
229
+ node = find_node(NAME)
230
+ if node is None:
231
+ print("tcw capabilities: no tcw capabilities node here — run `tcw init` in the project folder.",
232
+ file=sys.stderr)
233
+ return 1
234
+ problems = FsCapabilitiesStore.open(node).check(taxonomy=_taxonomy_for(node))
235
+ for p in problems:
236
+ print(p, file=sys.stderr)
237
+ if problems:
238
+ print(f"{len(problems)} problem(s).", file=sys.stderr)
239
+ return 1
240
+ print("capabilities OK")
241
+ return 0
242
+
243
+
244
+ def add_subparser(sub: argparse._SubParsersAction) -> None:
245
+ p = sub.add_parser(NAME, help="the user stories — what a user can do")
246
+ g = p.add_subparsers(dest="cmd", required=True)
247
+
248
+ pi = g.add_parser("init", help="scaffold docs/capabilities/ (mirror of `tcw init capabilities`)")
249
+ pi.add_argument("--id", help="canonical project ID (required for new/legacy nodes)")
250
+ pi.set_defaults(func=_init)
251
+
252
+ pl = g.add_parser("list", help="list capabilities, flagged by status + origin")
253
+ pl.add_argument("--status")
254
+ pl.add_argument("--namespace")
255
+ pl.add_argument("--local-only", action="store_true",
256
+ help="exclude inherited (federated) capabilities")
257
+ pl.set_defaults(func=_list)
258
+
259
+ ps = g.add_parser("show", help="read a capability by path")
260
+ ps.add_argument("id", metavar="path")
261
+ ps.set_defaults(func=_show)
262
+
263
+ g.add_parser("path", help="print the capabilities store folder path").set_defaults(func=_path)
264
+
265
+ pa = g.add_parser("add", help="scaffold a capability folder")
266
+ pa.add_argument("path", metavar="namespace/path")
267
+ pa.add_argument("name", nargs="?")
268
+ pa.add_argument("-s", "--status", default="Missing")
269
+ pa.set_defaults(func=_add)
270
+
271
+ pset = g.add_parser("set", help="update a capability's status/fields in place")
272
+ pset.add_argument("id", metavar="path")
273
+ pset.add_argument("--status", help="shorthand for --field Status=<S>")
274
+ pset.add_argument("--field", action="append", metavar="K=V",
275
+ help="set a metadata field (repeatable; Subject accepts a,b,c)")
276
+ pset.set_defaults(func=_set)
277
+
278
+ prst = g.add_parser("reset", help="drop a local override, re-inheriting upstream")
279
+ prst.add_argument("id", metavar="path")
280
+ prst.set_defaults(func=_reset)
281
+
282
+ pse = g.add_parser("search", help="search names + bodies")
283
+ pse.add_argument("query")
284
+ pse.set_defaults(func=_search)
285
+
286
+ pe = g.add_parser("extends", help="federate another project's capabilities")
287
+ pe.add_argument("project_id")
288
+ pe.add_argument("--rm", action="store_true", help="remove the project instead")
289
+ pe.set_defaults(func=_extends)
290
+
291
+ pc = g.add_parser("check", help="validate paths, subject/feature refs, federation, metadata")
292
+ pc.set_defaults(func=_check)
293
+
294
+ pd = g.add_parser("drift", help="report unreviewed inherited + shipped-but-Missing capabilities")
295
+ pd.set_defaults(func=_drift)
tcw/cli.py ADDED
@@ -0,0 +1,187 @@
1
+ """Top-level `tcw` CLI: dispatches `init | taxonomy | capabilities | work`.
2
+
3
+ Built component groups register their own subparsers; the rest stub to "not yet
4
+ implemented" until their phase lands (capabilities = Phase 3, work = Phase 5).
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from tcw import __version__
12
+ from tcw.capabilities import cli as capabilities_cli
13
+ from tcw.serve import DEFAULT_PORT, serve
14
+ from tcw.store.fs import COMPONENTS, SENTINEL, find_node_root, git_root, init
15
+ from tcw.store.project import FsProjectRegistry
16
+ import yaml
17
+ from tcw.taxonomy import cli as taxonomy_cli
18
+ from tcw.work import cli as work_cli
19
+
20
+ # Component CLI modules (each exposes NAME / SUBCOMMANDS / DEFAULT_SUBCOMMAND /
21
+ # add_subparser). All three components are now built.
22
+ _BUILT = [taxonomy_cli, capabilities_cli, work_cli]
23
+ _STUBBED = [c for c in COMPONENTS if c not in {m.NAME for m in _BUILT}]
24
+
25
+
26
+ def run_init(components: list[str], project_id: str | None = None,
27
+ work_path: str | None = None) -> int:
28
+ """Scaffold `docs/<component>/` trees under the current directory, mark it a
29
+ node, and report. Shared by `tcw init` and each `tcw <component> init`."""
30
+ root = Path.cwd()
31
+ if git_root(root) is None: # returns the repo root for any dir inside it
32
+ print("tcw init: not inside a git repository. Run `git init` first.", file=sys.stderr)
33
+ return 1
34
+ unknown = [c for c in components if c not in COMPONENTS]
35
+ if unknown:
36
+ print(f"tcw init: unknown component(s): {', '.join(unknown)}. "
37
+ f"Choose from: {', '.join(COMPONENTS)}.", file=sys.stderr)
38
+ return 2
39
+ sentinel = root / SENTINEL
40
+ if project_id is None:
41
+ try:
42
+ configured = yaml.safe_load(sentinel.read_text(encoding="utf-8")) if sentinel.exists() else {}
43
+ except yaml.YAMLError as error:
44
+ print(f"tcw init: invalid {SENTINEL}: {error}", file=sys.stderr)
45
+ return 1
46
+ if not isinstance(configured, dict) or not configured.get("id"):
47
+ print(
48
+ "tcw init: new or legacy TCW nodes require `--id <project-id>`; "
49
+ "IDs are not inferred",
50
+ file=sys.stderr,
51
+ )
52
+ return 1
53
+ try:
54
+ created = init(components, root, project_id,
55
+ Path(work_path).expanduser() if work_path is not None else None)
56
+ except (ValueError, OSError) as error:
57
+ print(f"tcw init: {error}", file=sys.stderr)
58
+ return 1
59
+ print(f"Scaffolded {len(created)} dir(s):")
60
+ for p in created:
61
+ try:
62
+ shown = p.relative_to(root)
63
+ except ValueError:
64
+ shown = p
65
+ print(f" {shown}")
66
+ print(f"Node marker: {SENTINEL}") # deterministic across runs
67
+ if "work" in components:
68
+ print(".gitignore: resolved work (completed/, discarded/) stays on disk, "
69
+ "out of the tracked tree")
70
+ return 0
71
+
72
+
73
+ def _cmd_init(args: argparse.Namespace) -> int:
74
+ return run_init(args.components or list(COMPONENTS), args.id, args.work_path)
75
+
76
+
77
+ def _not_yet(name: str):
78
+ def run(args: argparse.Namespace) -> int:
79
+ print(f"tcw {name}: not yet implemented.", file=sys.stderr)
80
+ return 1
81
+ return run
82
+
83
+
84
+ def _cmd_validate(args: argparse.Namespace) -> int:
85
+ node_root = find_node_root()
86
+ if node_root is None:
87
+ print("tcw validate: no tcw node here — run `tcw init` in the project folder.",
88
+ file=sys.stderr)
89
+ return 1
90
+ registry = FsProjectRegistry.open(node_root)
91
+ registry_problems = registry.check()
92
+ if registry_problems:
93
+ for problem in registry_problems:
94
+ print(problem, file=sys.stderr)
95
+ print(f"{len(registry_problems)} project graph problem(s).", file=sys.stderr)
96
+ return 1
97
+ from tcw.validate import validate
98
+ recurse = args.path is None and not args.no_recurse
99
+ projects = [registry.current, *registry.descendants()] if recurse else [registry.current]
100
+ problems: list[str] = []
101
+ for project in projects:
102
+ project_problems = validate(Path(project.locator), args.path)
103
+ if len(projects) > 1:
104
+ project_problems = [f"[{project.id}] {problem}" for problem in project_problems]
105
+ problems.extend(project_problems)
106
+ for p in problems:
107
+ print(p, file=sys.stderr)
108
+ if problems:
109
+ print(f"{len(problems)} problem(s).", file=sys.stderr)
110
+ return 1
111
+ print("validate OK")
112
+ return 0
113
+
114
+
115
+ def _cmd_serve(args: argparse.Namespace) -> int:
116
+ # Descendant node boards are aggregated by default (like
117
+ # `tcw work list --include-descendants`).
118
+ node_root = find_node_root()
119
+ if node_root is None:
120
+ print("tcw serve: no tcw node here — run `tcw init --id <project-id>`.",
121
+ file=sys.stderr)
122
+ return 1
123
+ FsProjectRegistry.open(node_root).require_valid()
124
+ return serve(port=args.port, open_browser=not args.no_open,
125
+ include_descendants=True)
126
+
127
+
128
+ def build_parser() -> argparse.ArgumentParser:
129
+ parser = argparse.ArgumentParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
130
+ parser.add_argument("--version", action="version", version=f"tcw {__version__}")
131
+ sub = parser.add_subparsers(dest="group", required=True)
132
+
133
+ p_init = sub.add_parser("init", help="scaffold component doc trees in this git repo")
134
+ p_init.add_argument("components", nargs="*",
135
+ help=f"any of: {', '.join(COMPONENTS)} (default: all)")
136
+ p_init.add_argument("--id", help="canonical project ID (required for new/legacy nodes)")
137
+ p_init.add_argument("--work-path", help="filesystem location for the work store")
138
+ p_init.set_defaults(func=_cmd_init)
139
+
140
+ p_validate = sub.add_parser(
141
+ "validate", help="check YAML soundness, tcw:// links, and component integrity")
142
+ p_validate.add_argument("path", nargs="?",
143
+ help="narrow the active project scan to one file or directory (disables recursion)")
144
+ p_validate.add_argument(
145
+ "--no-recurse", action="store_true",
146
+ help="validate only the active project, excluding registered descendants",
147
+ )
148
+ p_validate.set_defaults(func=_cmd_validate)
149
+
150
+ p_serve = sub.add_parser("serve", help="serve a local read-only web viewer")
151
+ p_serve.add_argument("--port", type=int, default=DEFAULT_PORT,
152
+ help=f"loopback port to bind (default: {DEFAULT_PORT})")
153
+ p_serve.add_argument("--no-open", action="store_true",
154
+ help="do not open a browser automatically")
155
+ p_serve.set_defaults(func=_cmd_serve)
156
+
157
+ for mod in _BUILT:
158
+ mod.add_subparser(sub)
159
+ for name in _STUBBED:
160
+ p = sub.add_parser(name, help=f"{name} commands (not yet implemented)")
161
+ p.set_defaults(func=_not_yet(name))
162
+
163
+ return parser
164
+
165
+
166
+ def _normalize(argv: list[str]) -> list[str]:
167
+ """Sugar: `tcw <component> <path>` → `tcw <component> show <path>`."""
168
+ if len(argv) >= 2 and not argv[1].startswith("-"):
169
+ for mod in _BUILT:
170
+ default = getattr(mod, "DEFAULT_SUBCOMMAND", None)
171
+ if default and argv[0] == mod.NAME and argv[1] not in mod.SUBCOMMANDS:
172
+ return [argv[0], default, *argv[1:]]
173
+ return argv
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ argv = list(sys.argv[1:] if argv is None else argv)
178
+ args = build_parser().parse_args(_normalize(argv))
179
+ try:
180
+ return args.func(args)
181
+ except ValueError as error:
182
+ print(f"tcw: {error}", file=sys.stderr)
183
+ return 1
184
+
185
+
186
+ if __name__ == "__main__":
187
+ sys.exit(main())
tcw/refs.py ADDED
@@ -0,0 +1,132 @@
1
+ """The `tcw://` reference protocol — a portable way to point at a TCW object.
2
+
3
+ Grammar: ``tcw://[<namespace>/]<axis>/<ref>``
4
+ - ``<axis>`` is ``T`` (Taxonomy), ``C`` (Capabilities), or ``W`` (Work).
5
+ - ``<namespace>`` (optional) locates the object in another project: an
6
+ ``extends`` alias for T/C, a descendant node path for W. Absent = local.
7
+ - ``<ref>`` is the identifier within that axis.
8
+
9
+ `parse_tcw_uri` is a pure, total function (never raises) — the abstract grammar.
10
+ `resolve_tcw_ref` is thin CLI/serve adapter glue: it imports the FS stores and
11
+ dispatches through their existing ``get()`` / ``resolve_qualified_work_ref`` (no
12
+ new store-interface method — litmus-clean), and never propagates a store
13
+ exception to a caller scanning many links.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from urllib.parse import unquote
21
+
22
+ from tcw.store.fs import (
23
+ FsCapabilitiesStore,
24
+ FsTaxonomyStore,
25
+ qualified_work_ref_problem,
26
+ resolve_qualified_work_ref,
27
+ )
28
+
29
+ _SCHEME = "tcw://"
30
+ _AXES = {"T", "C", "W"}
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TcwRef:
35
+ namespace: str # "" = local
36
+ axis: str # normalized upper: "T" | "C" | "W"
37
+ ref: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ResolveResult:
42
+ ok: bool
43
+ axis: str | None
44
+ key: str | None
45
+ reason: str
46
+ project: str = "" # owning project id for a foreign work ref; "" = local
47
+
48
+
49
+ def _segment_ok(seg: str) -> bool:
50
+ """A decoded path segment is safe iff it is not a traversal token and holds
51
+ no control/NUL/backslash chars (mirrors the guard style of `_safe_store_id`)."""
52
+ if seg in (".", ".."):
53
+ return False
54
+ return not any(ord(c) < 0x20 or c == "\x7f" or c == "\\" for c in seg)
55
+
56
+
57
+ def parse_tcw_uri(uri: str) -> TcwRef | None:
58
+ """Parse a ``tcw://`` uri into its (namespace, axis, ref). Total: returns
59
+ None on any malformed input, never raises.
60
+
61
+ Split the remainder on ``/`` FIRST, then percent-decode each segment (so a
62
+ ``%2F`` inside a segment can't inject a spurious separator/axis — matches
63
+ the React client's ``parsePath``). The axis is the first segment whose ``.upper()``
64
+ is one of T/C/W; the first-bare-axis-wins collision (``tcw://T/C/ref``) is a
65
+ documented limitation. Empty segments (multiple slashes) are dropped.
66
+ """
67
+ if not isinstance(uri, str) or not uri.startswith(_SCHEME):
68
+ return None
69
+ raw_segs = [s for s in uri[len(_SCHEME):].split("/") if s] # drop empties
70
+ segs = [unquote(s) for s in raw_segs]
71
+ if not all(_segment_ok(s) for s in segs):
72
+ return None
73
+ axis_idx = next((i for i, s in enumerate(segs) if s.upper() in _AXES), -1)
74
+ if axis_idx == -1:
75
+ return None
76
+ ref = "/".join(segs[axis_idx + 1:])
77
+ if not ref:
78
+ return None
79
+ return TcwRef("/".join(segs[:axis_idx]), segs[axis_idx].upper(), ref)
80
+
81
+
82
+ def resolve_tcw_ref(node_root: Path | None, uri: str) -> ResolveResult:
83
+ """Resolve a ``tcw://`` uri against the node at ``node_root``, returning the
84
+ SPA object key (namespace-qualified where present). Never propagates a store
85
+ exception — a store failure becomes ``ok=False`` with a reason.
86
+
87
+ ``ok`` answers one question only: **does this reference resolve in the
88
+ registered graph?** That is what `tcw validate` asks. Whether a *viewer* can
89
+ open it is a different question with a different answer per server, so it is
90
+ not decided here — a foreign work ref reports its owning project in
91
+ ``project`` and the viewer gates on that (see `tcw serve`'s ``/api/resolve``).
92
+ Conflating the two is what made a valid cross-node link report ``ok`` and then
93
+ dead-end in the SPA: the two spellings of a namespaced work ref took different
94
+ paths, and the one people actually write never reached the gate at all.
95
+
96
+ Both spellings now produce the same qualified key. ``parse_tcw_uri`` treats the
97
+ first T/C/W segment as the axis, so ``tcw://<id>/W/<slug>`` arrives with a
98
+ parsed namespace while ``tcw://W/<id>/<slug>`` arrives as a bare ref whose
99
+ qualifier is resolved by ``resolve_qualified_work_ref``; keying off where the
100
+ ref actually landed, rather than off the spelling, covers both.
101
+ """
102
+ parsed = parse_tcw_uri(uri)
103
+ if parsed is None:
104
+ return ResolveResult(False, None, None, "malformed tcw:// uri")
105
+ if node_root is None:
106
+ return ResolveResult(False, parsed.axis, None, "no tcw node")
107
+ ns_ref = f"{parsed.namespace}/{parsed.ref}" if parsed.namespace else parsed.ref
108
+ try:
109
+ if parsed.axis == "T":
110
+ term = FsTaxonomyStore.open(node_root).get(ns_ref)
111
+ if term is None:
112
+ return ResolveResult(False, "T", None, f"no taxonomy term: {ns_ref}")
113
+ return ResolveResult(True, "T", term.qualified, "")
114
+ if parsed.axis == "C":
115
+ cap = FsCapabilitiesStore.open(node_root).get(ns_ref)
116
+ if cap is None:
117
+ return ResolveResult(False, "C", None, f"no capability: {ns_ref}")
118
+ return ResolveResult(True, "C", cap.qualified, "")
119
+ # axis == "W"
120
+ resolved = resolve_qualified_work_ref(node_root, ns_ref)
121
+ if resolved is None:
122
+ return ResolveResult(
123
+ False, "W", None, qualified_work_ref_problem(node_root, ns_ref))
124
+ store, bare = resolved
125
+ if store.node_root == node_root.resolve(): # landed locally
126
+ return ResolveResult(True, "W", bare, "")
127
+ # Foreign: the qualifier is a project id (a status-path locator is always
128
+ # local), and the SPA keys foreign items the way the board prints them.
129
+ project = ns_ref.partition("/")[0]
130
+ return ResolveResult(True, "W", f"{project}/{bare}", "", project)
131
+ except Exception as e: # store errors (AmbiguousRef, MultipleMatch, IO) -> ok=False
132
+ return ResolveResult(False, parsed.axis, None, str(e))