agent-skilltree 0.2.1__tar.gz → 0.3.0__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 (44) hide show
  1. {agent_skilltree-0.2.1/src/agent_skilltree.egg-info → agent_skilltree-0.3.0}/PKG-INFO +23 -1
  2. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/README.md +22 -0
  3. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/pyproject.toml +1 -1
  4. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0/src/agent_skilltree.egg-info}/PKG-INFO +23 -1
  5. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/SOURCES.txt +3 -1
  6. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/__init__.py +20 -4
  7. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/cli.py +43 -1
  8. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/cohere.py +8 -3
  9. agent_skilltree-0.3.0/src/skilltree/forest.py +170 -0
  10. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/materialize.py +7 -1
  11. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/model.py +39 -0
  12. agent_skilltree-0.3.0/src/skilltree/tome.py +86 -0
  13. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/validate.py +36 -1
  14. agent_skilltree-0.3.0/tests/test_tome.py +185 -0
  15. agent_skilltree-0.2.1/src/skilltree/forest.py +0 -101
  16. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/LICENSE +0 -0
  17. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/setup.cfg +0 -0
  18. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/dependency_links.txt +0 -0
  19. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/entry_points.txt +0 -0
  20. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/requires.txt +0 -0
  21. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/top_level.txt +0 -0
  22. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/exchange.py +0 -0
  23. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/federation.py +0 -0
  24. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/mapper.py +0 -0
  25. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/marketplace.py +0 -0
  26. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/registry.py +0 -0
  27. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/reports.py +0 -0
  28. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/src/skilltree/search.py +0 -0
  29. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_cohere.py +0 -0
  30. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_coords.py +0 -0
  31. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_emit_roundtrip.py +0 -0
  32. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_exchange.py +0 -0
  33. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_federation.py +0 -0
  34. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_folder_search.py +0 -0
  35. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_forest.py +0 -0
  36. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_glyph_search.py +0 -0
  37. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_mapper.py +0 -0
  38. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_marketplace.py +0 -0
  39. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_notifications.py +0 -0
  40. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_registry.py +0 -0
  41. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_reports.py +0 -0
  42. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_search.py +0 -0
  43. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_skilltree.py +0 -0
  44. {agent_skilltree-0.2.1 → agent_skilltree-0.3.0}/tests/test_summaries.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-skilltree
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: A coordinate-addressed tree of agent skill dirs wired by Read-tool breadcrumbs, with programmatic validation.
5
5
  Author: Isaac Wostrel-Rubin
6
6
  License: MIT
@@ -82,6 +82,8 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
82
82
  | `tree <root>` | show the coordinate tree on disk (every node by its `<coord>-<name>` address) |
83
83
  | `map <folder> [--write]` | render a flat folder into **one** coordinate-addressed `CLAUDE.md` (Folder Map + addressable Index + branch summaries) — progressive disclosure over a flat pile |
84
84
  | `search <folder> <query> [--scope <coord>]` | FTS5/BM25 over **any** folder; `--scope` restricts to a coordinate subtree when the folder carries skilltree coordinates (a plain folder = coordinate-free search) |
85
+ | `fold <framework_dir> --into <node> --tree <root>` | **bind** a framework (its skill dir) into a volume node's `## Frameworks` tome table — idempotent, one row per framework; the manifest is the source of truth and the node's SKILL.md is regenerated from it |
86
+ | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize) |
85
87
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
86
88
  | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
87
89
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
@@ -128,6 +130,26 @@ Two papers document the project:
128
130
 
129
131
  ## Changelog
130
132
 
133
+ ### v0.3.0 — 2026-07-09
134
+ - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
135
+ rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
136
+ What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
137
+ the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
138
+ **`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
139
+ row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
140
+ link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
141
+ (`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
142
+ frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
143
+ **tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
144
+ undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
145
+ tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
146
+ and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
147
+ Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
148
+ **70 passing** (+11 tome tests).
149
+ - Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
150
+ ("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
151
+ keep working (verified against the reference atomizer end-to-end).
152
+
131
153
  ### v0.2.0 — 2026-06-18
132
154
  - **Folded the `skillmap` / `foldersearch` / `skillsearch` seedlings into skilltree as subcommands — one product.** `skilltree map <folder> [--write]` renders a flat folder into one coordinate-addressed `CLAUDE.md`, using skilltree's **own** coordinate code (`assign_coords`/`skill_name` from `model.py`; the vendored copy is gone). `skilltree search <folder> <query> [--scope <coord>]` is **one** FTS5/BM25 engine over any folder — coordinate-free on a plain folder, coordinate-subtree-scoped when the folder carries skilltree coordinates (`foldersearch` + `skillsearch` unified; the `--scope` flag is the only difference). Added the `tree` view command. The `<coord>-<name>` frontmatter and the Read-breadcrumb format are preserved (backward compatible). Suite grew 56 → **62 tests**.
133
155
 
@@ -68,6 +68,8 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
68
68
  | `tree <root>` | show the coordinate tree on disk (every node by its `<coord>-<name>` address) |
69
69
  | `map <folder> [--write]` | render a flat folder into **one** coordinate-addressed `CLAUDE.md` (Folder Map + addressable Index + branch summaries) — progressive disclosure over a flat pile |
70
70
  | `search <folder> <query> [--scope <coord>]` | FTS5/BM25 over **any** folder; `--scope` restricts to a coordinate subtree when the folder carries skilltree coordinates (a plain folder = coordinate-free search) |
71
+ | `fold <framework_dir> --into <node> --tree <root>` | **bind** a framework (its skill dir) into a volume node's `## Frameworks` tome table — idempotent, one row per framework; the manifest is the source of truth and the node's SKILL.md is regenerated from it |
72
+ | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize) |
71
73
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
72
74
  | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
73
75
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
@@ -114,6 +116,26 @@ Two papers document the project:
114
116
 
115
117
  ## Changelog
116
118
 
119
+ ### v0.3.0 — 2026-07-09
120
+ - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
121
+ rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
122
+ What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
123
+ the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
124
+ **`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
125
+ row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
126
+ link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
127
+ (`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
128
+ frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
129
+ **tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
130
+ undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
131
+ tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
132
+ and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
133
+ Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
134
+ **70 passing** (+11 tome tests).
135
+ - Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
136
+ ("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
137
+ keep working (verified against the reference atomizer end-to-end).
138
+
117
139
  ### v0.2.0 — 2026-06-18
118
140
  - **Folded the `skillmap` / `foldersearch` / `skillsearch` seedlings into skilltree as subcommands — one product.** `skilltree map <folder> [--write]` renders a flat folder into one coordinate-addressed `CLAUDE.md`, using skilltree's **own** coordinate code (`assign_coords`/`skill_name` from `model.py`; the vendored copy is gone). `skilltree search <folder> <query> [--scope <coord>]` is **one** FTS5/BM25 engine over any folder — coordinate-free on a plain folder, coordinate-subtree-scoped when the folder carries skilltree coordinates (`foldersearch` + `skillsearch` unified; the `--scope` flag is the only difference). Added the `tree` view command. The `<coord>-<name>` frontmatter and the Read-breadcrumb format are preserved (backward compatible). Suite grew 56 → **62 tests**.
119
141
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agent-skilltree"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "A coordinate-addressed tree of agent skill dirs wired by Read-tool breadcrumbs, with programmatic validation."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-skilltree
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: A coordinate-addressed tree of agent skill dirs wired by Read-tool breadcrumbs, with programmatic validation.
5
5
  Author: Isaac Wostrel-Rubin
6
6
  License: MIT
@@ -82,6 +82,8 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
82
82
  | `tree <root>` | show the coordinate tree on disk (every node by its `<coord>-<name>` address) |
83
83
  | `map <folder> [--write]` | render a flat folder into **one** coordinate-addressed `CLAUDE.md` (Folder Map + addressable Index + branch summaries) — progressive disclosure over a flat pile |
84
84
  | `search <folder> <query> [--scope <coord>]` | FTS5/BM25 over **any** folder; `--scope` restricts to a coordinate subtree when the folder carries skilltree coordinates (a plain folder = coordinate-free search) |
85
+ | `fold <framework_dir> --into <node> --tree <root>` | **bind** a framework (its skill dir) into a volume node's `## Frameworks` tome table — idempotent, one row per framework; the manifest is the source of truth and the node's SKILL.md is regenerated from it |
86
+ | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize) |
85
87
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
86
88
  | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
87
89
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
@@ -128,6 +130,26 @@ Two papers document the project:
128
130
 
129
131
  ## Changelog
130
132
 
133
+ ### v0.3.0 — 2026-07-09
134
+ - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
135
+ rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
136
+ What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
137
+ the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
138
+ **`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
139
+ row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
140
+ link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
141
+ (`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
142
+ frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
143
+ **tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
144
+ undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
145
+ tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
146
+ and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
147
+ Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
148
+ **70 passing** (+11 tome tests).
149
+ - Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
150
+ ("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
151
+ keep working (verified against the reference atomizer end-to-end).
152
+
131
153
  ### v0.2.0 — 2026-06-18
132
154
  - **Folded the `skillmap` / `foldersearch` / `skillsearch` seedlings into skilltree as subcommands — one product.** `skilltree map <folder> [--write]` renders a flat folder into one coordinate-addressed `CLAUDE.md`, using skilltree's **own** coordinate code (`assign_coords`/`skill_name` from `model.py`; the vendored copy is gone). `skilltree search <folder> <query> [--scope <coord>]` is **one** FTS5/BM25 engine over any folder — coordinate-free on a plain folder, coordinate-subtree-scoped when the folder carries skilltree coordinates (`foldersearch` + `skillsearch` unified; the `--scope` flag is the only difference). Added the `tree` view command. The `<coord>-<name>` frontmatter and the Read-breadcrumb format are preserved (backward compatible). Suite grew 56 → **62 tests**.
133
155
 
@@ -20,6 +20,7 @@ src/skilltree/model.py
20
20
  src/skilltree/registry.py
21
21
  src/skilltree/reports.py
22
22
  src/skilltree/search.py
23
+ src/skilltree/tome.py
23
24
  src/skilltree/validate.py
24
25
  tests/test_cohere.py
25
26
  tests/test_coords.py
@@ -36,4 +37,5 @@ tests/test_registry.py
36
37
  tests/test_reports.py
37
38
  tests/test_search.py
38
39
  tests/test_skilltree.py
39
- tests/test_summaries.py
40
+ tests/test_summaries.py
41
+ tests/test_tome.py
@@ -6,14 +6,28 @@ edges are symlinks; descending a level = redirecting the active skills root.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
- __version__ = "0.2.0"
9
+ # single source of truth = the installed dist (pyproject); can't drift from a literal again
10
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
11
+ try:
12
+ __version__ = _pkg_version("agent-skilltree")
13
+ except PackageNotFoundError: # running from a source checkout that isn't pip-installed
14
+ __version__ = "0.3.0"
10
15
 
11
16
  from .exchange import Exchange, Member, load_exchange
12
17
  from .exchange import build as build_exchange
13
18
  from .exchange import is_valid as exchange_is_valid
14
19
  from .exchange import validate as validate_exchange
15
20
  from .materialize import materialize
16
- from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name, assign_coords, skill_name
21
+ from .model import (
22
+ TOME_HEADING,
23
+ SkillTree,
24
+ TreeNode,
25
+ assign_coords,
26
+ compose_summary,
27
+ find_node,
28
+ skill_name,
29
+ tome_table,
30
+ )
17
31
  from .registry import (
18
32
  Entry,
19
33
  load_registry,
@@ -22,7 +36,8 @@ from .registry import (
22
36
  validate_registry,
23
37
  )
24
38
  from .registry import search as registry_search
25
- from .forest import build_forest, link_tree, list_links, unlink
39
+ from .forest import build_forest, link_tree, list_links, project, unlink
40
+ from .tome import fold
26
41
  from .reports import (
27
42
  list_reports,
28
43
  mark_problem,
@@ -53,7 +68,8 @@ __all__ = [
53
68
  "promote", "registry_search",
54
69
  "walk_federation", "flatten_federation", "validate_federation",
55
70
  "register_child", "local_resolver",
56
- "link_tree", "build_forest", "list_links", "unlink",
71
+ "link_tree", "build_forest", "list_links", "unlink", "project",
72
+ "fold", "tome_table", "find_node", "TOME_HEADING",
57
73
  "assign_coords", "skill_name",
58
74
  "build_index", "search", "search_tree", "search_folder", "DEFAULT_EXTS",
59
75
  "build_map", "write_map",
@@ -12,7 +12,7 @@ from .validate import validate
12
12
 
13
13
 
14
14
  @click.group()
15
- @click.version_option(message="skilltree %(version)s")
15
+ @click.version_option(package_name="agent-skilltree", message="skilltree %(version)s")
16
16
  def main() -> None:
17
17
  """SkillTree — a nested tree of skill dirs wired by `cat`-breadcrumbs (validated)."""
18
18
 
@@ -242,6 +242,48 @@ def validate_cmd(root: Path) -> None:
242
242
  click.echo("✓ valid — every breadcrumb resolves.")
243
243
 
244
244
 
245
+ @main.command(name="fold")
246
+ @click.argument("framework_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
247
+ @click.option("--into", required=True, help="the volume node to bind into (name or coord)")
248
+ @click.option("--tree", "tree_root", required=True,
249
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
250
+ help="the materialized tree root (holds skilltree.json)")
251
+ @click.option("--what-for", default=None, help="the row's purpose text (default: the framework's description)")
252
+ @click.option("--name", default=None, help="the row name (default: the framework's frontmatter name)")
253
+ @click.option("--relative", is_flag=True,
254
+ help="store the row path relative to the volume node (repo-portable: validates/projects from any checkout)")
255
+ def fold_cmd(framework_dir: Path, into: str, tree_root: Path, what_for: str | None,
256
+ name: str | None, relative: bool) -> None:
257
+ """Fold a FRAMEWORK_DIR into a volume node's tome table (idempotent, one row
258
+ per framework). The manifest is updated and the node's SKILL.md regenerated."""
259
+ from .tome import fold
260
+ rep = fold(framework_dir, into=into, tree_root=tree_root, what_for=what_for, name=name,
261
+ relative=relative)
262
+ if not rep.get("ok"):
263
+ click.echo(f"✗ {rep.get('error')}")
264
+ raise SystemExit(1)
265
+ verb = "updated" if rep["updated"] else "added"
266
+ click.echo(f"✓ {verb} row {rep['row']['name']!r} on node {rep['node']!r} "
267
+ f"({rep['rows']} framework(s) held)")
268
+
269
+
270
+ @main.command(name="project")
271
+ @click.argument("tree_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
272
+ @click.argument("target", type=click.Path(path_type=Path))
273
+ @click.option("--policy", type=click.Choice(["progressive", "flat"]), default="progressive",
274
+ help="progressive = root + first-layer branches; flat = a volume's tome rows + holder")
275
+ @click.option("--link", type=click.Choice(["absolute", "relative"]), default="absolute")
276
+ @click.option("--node", default=None, help="(flat) the volume node to project (name or coord; default root)")
277
+ def project_cmd(tree_root: Path, target: Path, policy: str, link: str, node: str | None) -> None:
278
+ """Project TREE_ROOT into TARGET (a skills dir) as symlinks — progressive
279
+ disclosure, or a volume's flat auto-discoverable deployment."""
280
+ from .forest import project
281
+ links = project(tree_root, target, policy=policy, link=link, node=node)
282
+ click.echo(f"✓ projected {len(links)} link(s) ({policy}, {link}) into {target}")
283
+ for p in links:
284
+ click.echo(f" - {p.name} -> {p.readlink()}")
285
+
286
+
245
287
  @main.group()
246
288
  def exchange() -> None:
247
289
  """Many skill trees in one repo, under a master."""
@@ -24,14 +24,17 @@ import shutil
24
24
  from dataclasses import dataclass, field
25
25
  from pathlib import Path
26
26
 
27
- from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name
27
+ from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name, tome_table
28
28
 
29
29
  # dirs that are never tree nodes
30
30
  _SKIP = {".claude", "kb", "__pycache__", ".git", "node_modules", ".aios.src"}
31
31
  # a coord-prefixed skill identity: "0.1.2-some-name" → ("0.1.2", "some-name")
32
32
  _COORD_RE = re.compile(r"^(\d+(?:\.\d+)*)-(.+)$")
33
- # everything from the first generated section to EOF (materialize appends them last)
34
- _SECT_RE = re.compile(r"\n+## Index summary\b.*\Z", re.S)
33
+ # everything from the first generated section to EOF (materialize appends them last).
34
+ # Generated sections, in emit order: Index summary → Descend (branches), then the
35
+ # `## Frameworks` tome table (any node with frameworks rows) — leftmost heading wins,
36
+ # so a leaf-with-table strips from Frameworks and a branch strips from Index summary.
37
+ _SECT_RE = re.compile(r"\n+## (?:Index summary|Frameworks)\b.*\Z", re.S)
35
38
  _CRUMB_RE = re.compile(r"`([^`]+?)/SKILL\.md`") # the path-before-SKILL.md, verb-agnostic
36
39
  _CRUMB = "- {name} ({kind}): Read `{path}`" # the verb is the Read TOOL, never `cat`
37
40
 
@@ -251,6 +254,8 @@ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
251
254
  "nothing; you must use the Read tool:\n\n" + "\n".join(crumbs))
252
255
  else:
253
256
  tail = ""
257
+ if node.frameworks: # regenerate the tome table from the model rows
258
+ tail += f"\n\n{tome_table(node)}"
254
259
  out = f"---\nname: {sname}\ndescription: {desc}\n---\n\n{base}{tail}\n"
255
260
  smd.write_text(out, encoding="utf-8")
256
261
 
@@ -0,0 +1,170 @@
1
+ """Make a SkillTree (or a forest of them) visible from the TOP — `~/.claude/skills`.
2
+
3
+ The top-level user `.claude/skills` is scanned in every session, but the scan is
4
+ non-recursive WITHIN a `.claude/skills` (a skill can't contain auto-loadable
5
+ sub-skills). So to surface a tree from the top you SYMLINK its entry skill dirs
6
+ into `~/.claude/skills`, and the `cat`-breadcrumbs carry you down from there.
7
+
8
+ ONE projector, two policies (`project`):
9
+ - **progressive** → symlink the root (and its first-layer branches) up; deeper
10
+ levels stay behind Read-breadcrumbs (progressive disclosure). `link_tree` is
11
+ the thin compatibility wrapper.
12
+ - **flat** → project a volume node's tome-table rows (its held frameworks) +
13
+ the holder node itself as top-level skill dirs, so agents auto-discover every
14
+ framework (the nomicon-atomize semantics: the organized tree stays the source
15
+ of truth; the flat dir is only symlinks — edits propagate, nothing duplicates).
16
+ - many trees → `build_forest`: one forest-root skill that breadcrumbs to each
17
+ tree's root, symlinked up — a forest view over the top.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from pathlib import Path
23
+
24
+ from .model import SkillTree, find_node, skill_name
25
+
26
+ _CRUMB = "- {name} ({kind}): Read `{path}`" # the Read TOOL injects the layer, not `cat`
27
+
28
+
29
+ def _skill_dir(node_dir: Path, name: str) -> Path:
30
+ return Path(node_dir) / ".claude" / "skills" / name
31
+
32
+
33
+ def _symlink(link: Path, target: Path, *, relative: bool = False) -> Path | None:
34
+ """Idempotently (re)create `link` → `target` (a dir). Self-clobber guard: if the
35
+ link path IS the target (same path, symlinks not followed), do nothing and return
36
+ None — never unlink the source onto itself. `relative` makes the pair portable
37
+ when moved together (the doc-mirror vision-hyperedge pattern)."""
38
+ target_abs = Path(os.path.abspath(target))
39
+ if Path(os.path.abspath(link)) == target_abs:
40
+ return None
41
+ link.parent.mkdir(parents=True, exist_ok=True)
42
+ if link.is_symlink() or link.exists():
43
+ link.unlink()
44
+ dst = os.path.relpath(target_abs, os.path.abspath(link.parent)) if relative else target_abs
45
+ link.symlink_to(dst, target_is_directory=True)
46
+ return link
47
+
48
+
49
+ def project(tree_dir: str | Path, target: str | Path, *,
50
+ policy: str = "progressive", link: str = "absolute",
51
+ node: str | None = None, manifest: str | Path | None = None,
52
+ include_root: bool = True, branches: bool = True) -> list[Path]:
53
+ """Project a tree into a skills dir — the ONE deployment op (Move 1).
54
+
55
+ policy="progressive" surface the root (+ first-layer branches) as symlinks;
56
+ deeper levels stay behind Read-breadcrumbs.
57
+ policy="flat" project the selected node's tome-table rows (each held
58
+ framework's skill dir) + the holder node itself, flat —
59
+ every framework auto-discovers as a top-level skill
60
+ (née nomicon-atomize). `node` selects the volume node by
61
+ name or coord (default: the root).
62
+ link="absolute"|"relative" symlink form (relative = portable-if-moved-together).
63
+
64
+ The tree stays the source of truth; the target holds only symlinks. Re-running
65
+ overwrites cleanly (idempotent). Self-clobber-guarded skips return no link.
66
+ """
67
+ tree_dir = Path(tree_dir)
68
+ tree = SkillTree.load(manifest or tree_dir / "skilltree.json")
69
+ target = Path(target)
70
+ rel = link == "relative"
71
+ links: list[Path] = []
72
+
73
+ if policy == "progressive":
74
+ if include_root:
75
+ rn = skill_name(tree.root)
76
+ p = _symlink(target / rn, _skill_dir(tree_dir, rn), relative=rel)
77
+ if p:
78
+ links.append(p)
79
+ if branches:
80
+ for child in tree.root.children:
81
+ cn = skill_name(child)
82
+ p = _symlink(target / cn, _skill_dir(tree_dir / child.name, cn), relative=rel)
83
+ if p:
84
+ links.append(p)
85
+ return links
86
+
87
+ if policy != "flat":
88
+ raise ValueError(f"unknown policy {policy!r} (use 'flat' or 'progressive')")
89
+
90
+ hit = find_node(tree.root, node or tree.root.name)
91
+ if hit is None:
92
+ raise ValueError(f"no node named/coord {node!r} in the tree")
93
+ vol, path = hit
94
+ node_dir = tree_dir
95
+ for nm in path[1:]:
96
+ node_dir = node_dir / nm
97
+
98
+ holder = _skill_dir(node_dir, skill_name(vol))
99
+ for row in vol.frameworks: # each held framework, flat
100
+ rp = Path(row["skill_path"])
101
+ if not rp.is_absolute(): # relative rows resolve against the holder dir
102
+ rp = holder / rp
103
+ p = _symlink(target / row["name"], rp.parent, relative=rel)
104
+ if p:
105
+ links.append(p)
106
+ # the holder itself (the volume's search/router skill) — skip when the target
107
+ # sits inside the holder's own dir (a symlink loop; the atomize guard)
108
+ if not os.path.abspath(target).startswith(os.path.abspath(node_dir) + os.sep):
109
+ p = _symlink(target / skill_name(vol), _skill_dir(node_dir, skill_name(vol)),
110
+ relative=rel)
111
+ if p:
112
+ links.append(p)
113
+ return links
114
+
115
+
116
+ def link_tree(tree_dir: str | Path, *, user_skills_dir: str | Path,
117
+ manifest: str | Path | None = None,
118
+ include_root: bool = True, branches: bool = True) -> list[Path]:
119
+ """Symlink a single tree's entry points into the top-level user skills dir.
120
+ (Thin wrapper over `project(policy="progressive", link="absolute")`.)"""
121
+ return project(tree_dir, user_skills_dir, policy="progressive", link="absolute",
122
+ manifest=manifest, include_root=include_root, branches=branches)
123
+
124
+
125
+ def build_forest(name: str, members: list[str | Path], *, user_skills_dir: str | Path,
126
+ forest_dir: str | Path, description: str | None = None) -> Path:
127
+ """Build a forest-root skill over several trees and symlink it to the top.
128
+
129
+ `members` = tree dirs (each with a skilltree.json). The forest root's body
130
+ breadcrumbs to each tree's root; one top-level entry, progressive descent.
131
+ """
132
+ fdir = _skill_dir(Path(forest_dir), name)
133
+ fdir.mkdir(parents=True, exist_ok=True)
134
+ crumbs, roots = [], []
135
+ for tdir in members:
136
+ tdir = Path(tdir)
137
+ tree = SkillTree.load(tdir / "skilltree.json")
138
+ rn = skill_name(tree.root)
139
+ md = _skill_dir(tdir, rn) / "SKILL.md"
140
+ crumbs.append(_CRUMB.format(name=rn, kind=tree.root.kind, path=md.resolve()))
141
+ roots.append(rn)
142
+ desc = description or f"Forest of {len(members)} skill tree(s): {', '.join(roots)}."
143
+ body = (f"Forest **{name}** — {len(members)} skill tree(s). "
144
+ "Pick one and **Read** (the Read tool) into its root to load it, then follow its "
145
+ "breadcrumbs down (`cat` won't load it — only the Read tool does):\n\n" + "\n".join(crumbs))
146
+ (fdir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {desc}\n---\n\n{body}\n", encoding="utf-8")
147
+ return _symlink(Path(user_skills_dir) / name, fdir)
148
+
149
+
150
+ def list_links(user_skills_dir: str | Path) -> list[dict]:
151
+ """List symlinks in the user skills dir and whether they still resolve."""
152
+ user = Path(user_skills_dir)
153
+ out: list[dict] = []
154
+ if user.exists():
155
+ for p in sorted(user.iterdir()):
156
+ if p.is_symlink():
157
+ out.append({"name": p.name, "target": str(p.readlink()), "resolves": p.exists()})
158
+ return out
159
+
160
+
161
+ def unlink(user_skills_dir: str | Path, *names: str) -> list[str]:
162
+ """Remove named symlinks (only if they are symlinks — never deletes real dirs)."""
163
+ user = Path(user_skills_dir)
164
+ removed: list[str] = []
165
+ for n in names:
166
+ p = user / n
167
+ if p.is_symlink():
168
+ p.unlink()
169
+ removed.append(n)
170
+ return removed
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
  from pathlib import Path
18
18
  import shutil
19
19
 
20
- from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name
20
+ from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name, tome_table
21
21
 
22
22
  # A breadcrumb line is parseable by validate.py: `- <name> (<kind>): Read `<abspath>``
23
23
  # The verb is **Read** (the Read tool), NOT `cat`: only the Read tool injects a dir's
@@ -65,6 +65,12 @@ def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
65
65
  else:
66
66
  body = base + "\n\n_(leaf — this is an actual skill.)_"
67
67
 
68
+ # tome pointer-table (SkillVolume binding): generated LAST from the model rows, so
69
+ # cohere's in-place rewrite (which regenerates from the first generated heading to
70
+ # EOF) re-emits it instead of wiping it. See model.tome_table / tome.fold.
71
+ if node.frameworks:
72
+ body += f"\n\n{tome_table(node)}"
73
+
68
74
  if node.coord:
69
75
  desc = f"[{node.coord}] {desc}"
70
76
 
@@ -26,6 +26,10 @@ class TreeNode:
26
26
  description: str | None = None # SKILL.md description (so it auto-loads / is searchable)
27
27
  coord: str | None = None # hierarchical address (e.g. "0.1.2"); set by assign_coords
28
28
  children: list["TreeNode"] = field(default_factory=list)
29
+ # tome pointer-table rows (the SkillVolume binding): each row registers a framework
30
+ # this node HOLDS — {"name", "what_for", "skill_path" (abs path to its SKILL.md)}.
31
+ # Rendered as the `## Frameworks` table by tome_table(); added by tome.fold().
32
+ frameworks: list[dict] = field(default_factory=list)
29
33
 
30
34
  def walk(self):
31
35
  yield self
@@ -67,6 +71,39 @@ def compose_summary(node: "TreeNode", *, full: bool = False) -> str:
67
71
  f"Reachable below: {', '.join(reachable)}.")
68
72
 
69
73
 
74
+ TOME_HEADING = "## Frameworks"
75
+
76
+
77
+ def tome_table(node: "TreeNode") -> str:
78
+ """Render a node's tome pointer-table (the `## Frameworks` registry) from the model.
79
+
80
+ The table is GENERATED — the manifest's `frameworks` rows are the source of truth,
81
+ and both materialize and cohere's in-place rewrite re-emit it (so a re-cohere can
82
+ never wipe or drift a folded row). The 'Framework skill' cell is `name (<abs path
83
+ to SKILL.md>)` — the exact shape the flat projector (`project(policy="flat")`, née
84
+ nomicon-atomize) parses. Columns grow Plugin/Chapter in Move 2.
85
+ """
86
+ lines = [TOME_HEADING, "",
87
+ "| Framework | What it's for | Framework skill |",
88
+ "|---|---|---|"]
89
+ for r in node.frameworks:
90
+ lines.append(f"| {r['name']} | {r.get('what_for', '')} | {r['name']} ({r['skill_path']}) |")
91
+ return "\n".join(lines)
92
+
93
+
94
+ def find_node(root: "TreeNode", selector: str) -> tuple["TreeNode", list[str]] | None:
95
+ """Locate a node by NAME or COORD; returns (node, tree-path names root→node)."""
96
+ def walk(n: "TreeNode", path: list[str]):
97
+ if n.name == selector or (n.coord and n.coord == selector):
98
+ return n, path
99
+ for c in n.children:
100
+ hit = walk(c, path + [c.name])
101
+ if hit:
102
+ return hit
103
+ return None
104
+ return walk(root, [root.name])
105
+
106
+
70
107
  def assign_coords(root: "TreeNode", base: str = "0") -> "TreeNode":
71
108
  """Give every node a hierarchical coordinate: root=base, child i (1-based)=parent.i."""
72
109
  def walk(n: "TreeNode", coord: str) -> None:
@@ -86,6 +123,7 @@ class SkillTree:
86
123
  def enc(n: TreeNode) -> dict:
87
124
  return {"name": n.name, "kind": n.kind, "skill_src": n.skill_src,
88
125
  "description": n.description, "coord": n.coord,
126
+ "frameworks": [dict(r) for r in n.frameworks],
89
127
  "children": [enc(c) for c in n.children]}
90
128
  return {"skilltree": enc(self.root)}
91
129
 
@@ -95,6 +133,7 @@ class SkillTree:
95
133
  return TreeNode(name=d["name"], kind=d.get("kind", "skill"),
96
134
  skill_src=d.get("skill_src"), description=d.get("description"),
97
135
  coord=d.get("coord"),
136
+ frameworks=[dict(r) for r in d.get("frameworks", [])],
98
137
  children=[dec(c) for c in d.get("children", [])])
99
138
  return SkillTree(dec(data["skilltree"]))
100
139
 
@@ -0,0 +1,86 @@
1
+ """tome.py — the SkillVolume/SkillTome bind ops (fold a framework into a volume).
2
+
3
+ A **SkillVolume** is a SkillTree branch that HOLDS frameworks: its node carries
4
+ `frameworks` rows in the manifest, rendered as the `## Frameworks` pointer-table
5
+ (see model.tome_table). A **SkillTome** is the overall corpus — the set of volumes
6
+ + everything made with them. ("nomicon" is the legacy alias for both.)
7
+
8
+ `fold(framework_dir, into=...)` is the bind operator: register ONE framework
9
+ (a chapter — its skill dir) as a row on a volume node. Idempotent — one row per
10
+ framework name; re-folding the same framework updates its row in place. The
11
+ manifest is the source of truth; the node's SKILL.md table is regenerated from it
12
+ surgically (only that node is rewritten — never a destructive re-materialize).
13
+
14
+ The flat deployment of a folded volume is `forest.project(policy="flat")`.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path
20
+
21
+ from .model import SkillTree, find_node, skill_name
22
+
23
+
24
+ def fold(framework_dir: str | Path, *, into: str, tree_root: str | Path,
25
+ what_for: str | None = None, name: str | None = None,
26
+ relative: bool = False) -> dict:
27
+ """Fold a framework's skill dir into a volume node's tome table — one row,
28
+ idempotent by row name. Returns a report dict ({"ok": False, "error": ...} on
29
+ a bad target — no partial writes happen in that case).
30
+
31
+ framework_dir the framework's skill dir (must contain SKILL.md)
32
+ into the volume node, by name or coordinate
33
+ tree_root the materialized tree root (holds skilltree.json)
34
+ what_for the row's purpose text (default: the framework's description)
35
+ name the row name (default: the framework's frontmatter name, else dir name)
36
+ relative store the row's skill_path RELATIVE to the volume node's skill dir
37
+ (the holder SKILL.md's dir — the base every table parser normalizes
38
+ against). Use for a repo-committed tome: the tree then validates and
39
+ projects from ANY checkout location, not just this machine.
40
+ """
41
+ from .cohere import _front, _rewrite_node_md # frontmatter parse + surgical rewrite
42
+
43
+ tree_root = Path(tree_root)
44
+ manifest = tree_root / "skilltree.json"
45
+ if not manifest.is_file():
46
+ return {"ok": False, "error": f"no skilltree.json manifest at {tree_root}"}
47
+ fdir = Path(framework_dir)
48
+ smd = fdir / "SKILL.md"
49
+ if not smd.is_file():
50
+ return {"ok": False, "error": f"framework dir has no SKILL.md: {fdir}"}
51
+
52
+ tree = SkillTree.load(manifest)
53
+ hit = find_node(tree.root, into)
54
+ if hit is None:
55
+ return {"ok": False, "error": f"no node named/coord {into!r} in {manifest}"}
56
+ node, path = hit
57
+
58
+ node_dir = tree_root
59
+ for nm in path[1:]:
60
+ node_dir = node_dir / nm
61
+ holder_dir = node_dir / ".claude" / "skills" / skill_name(node)
62
+
63
+ front = _front(smd)
64
+ row_name = name or front.get("name") or fdir.name
65
+ skill_path = (os.path.relpath(smd.resolve(), holder_dir.resolve()) if relative
66
+ else str(smd.resolve()))
67
+ row = {"name": row_name,
68
+ "what_for": what_for or front.get("description", ""),
69
+ "skill_path": skill_path}
70
+ updated = False
71
+ for i, r in enumerate(node.frameworks):
72
+ if r.get("name") == row_name: # idempotent: one row per framework
73
+ node.frameworks[i] = row
74
+ updated = True
75
+ break
76
+ if not updated:
77
+ node.frameworks.append(row)
78
+
79
+ tree.save(manifest)
80
+
81
+ # Surgical in-place rewrite of ONLY the folded node's SKILL.md (regenerates its
82
+ # generated tail — table included — from the model; hand-written body preserved).
83
+ _rewrite_node_md(holder_dir / "SKILL.md", node, node_dir)
84
+
85
+ return {"ok": True, "node": node.name, "coord": node.coord, "row": row,
86
+ "updated": updated, "rows": len(node.frameworks)}
@@ -8,6 +8,9 @@ the things the filesystem won't:
8
8
  - every non-leaf node's body has a Read breadcrumb for EACH of its children
9
9
  - every breadcrumb path actually resolves to a file (no dead ends)
10
10
  - sibling names are unique (their dirs collide otherwise)
11
+ - tome-table round-trip: a node with `frameworks` rows carries the `## Frameworks`
12
+ table, every manifest row appears in it, and every row's target SKILL.md resolves
13
+ (an undeclared table row is a warning — the manifest is the source of truth)
11
14
 
12
15
  A non-empty error list is a kill-criterion: someone walking the tree hits a dead
13
16
  end the platform would never warn about.
@@ -18,11 +21,13 @@ from dataclasses import dataclass
18
21
  from pathlib import Path
19
22
  import re
20
23
 
21
- from .model import KINDS, SkillTree, TreeNode, skill_name
24
+ from .model import KINDS, TOME_HEADING, SkillTree, TreeNode, skill_name
22
25
 
23
26
  # a breadcrumb is `- <name> (<kind>): Read `<abspath>`` — match the backticked SKILL.md path
24
27
  # (verb-agnostic, so it survives wording tweaks; the verb is the Read tool, never `cat`)
25
28
  _CRUMB_RE = re.compile(r"`([^`]+/SKILL\.md)`")
29
+ # a tome-table data row: `| name | what-for | name (path) |` (skip header/separator rows)
30
+ _TOME_ROW_RE = re.compile(r"^\|\s*([^|]+?)\s*\|[^|]*\|[^|]*\|\s*$", re.M)
26
31
 
27
32
 
28
33
  @dataclass
@@ -69,10 +74,40 @@ def _walk(node: TreeNode, node_dir: Path, out: list[Violation]) -> None:
69
74
  elif p not in expected:
70
75
  out.append(Violation("warning", node.name, f"breadcrumb `{p}` is not a declared child"))
71
76
 
77
+ if node.frameworks:
78
+ _check_tome(node, skill_md, out)
79
+
72
80
  for child in node.children:
73
81
  _walk(child, node_dir / child.name, out)
74
82
 
75
83
 
84
+ def _check_tome(node: TreeNode, skill_md: Path, out: list[Violation]) -> None:
85
+ """Tome-table round-trip: the manifest rows are the source of truth; the table
86
+ in SKILL.md must carry every one, and every row's target must resolve. An
87
+ undeclared table row is only a warning (fix by folding it or re-emitting)."""
88
+ text = skill_md.read_text(encoding="utf-8")
89
+ if TOME_HEADING not in text:
90
+ out.append(Violation("error", node.name,
91
+ "frameworks rows declared but no ## Frameworks table in SKILL.md (re-emit the node)"))
92
+ return
93
+ section = re.split(r"\n#{1,6} ", text.split(TOME_HEADING, 1)[1])[0]
94
+ in_table = {n for n in _TOME_ROW_RE.findall(section)
95
+ if n != "Framework" and not re.fullmatch(r":?-+:?", n)}
96
+ for row in node.frameworks:
97
+ if row["name"] not in in_table:
98
+ out.append(Violation("error", node.name,
99
+ f"tome row {row['name']!r} declared in manifest but missing from the ## Frameworks table"))
100
+ target = Path(row.get("skill_path", ""))
101
+ if not target.is_absolute(): # relative rows resolve against the holder dir
102
+ target = skill_md.parent / target
103
+ if not target.is_file():
104
+ out.append(Violation("error", node.name,
105
+ f"tome row {row['name']!r}: target does not resolve ({target})"))
106
+ for extra in sorted(in_table - {r["name"] for r in node.frameworks}):
107
+ out.append(Violation("warning", node.name,
108
+ f"table row {extra!r} is not declared in the manifest"))
109
+
110
+
76
111
  def validate(root: str | Path, tree: SkillTree | None = None) -> list[Violation]:
77
112
  root = Path(root)
78
113
  if tree is None:
@@ -0,0 +1,185 @@
1
+ """Tome bind-op tests (Move 1): fold, the generated ## Frameworks table, the
2
+ validate round-trip, and project(policy="flat"/"progressive")."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from skilltree import (
9
+ SkillTree,
10
+ TreeNode,
11
+ fold,
12
+ is_valid,
13
+ link_tree,
14
+ materialize,
15
+ project,
16
+ validate,
17
+ )
18
+ from skilltree.cohere import emit
19
+ from skilltree.materialize import node_skill_md
20
+
21
+
22
+ def _framework(tmp_path: Path, name: str, desc: str = "does the thing") -> Path:
23
+ d = tmp_path / f"fw-{name}"
24
+ d.mkdir()
25
+ (d / "SKILL.md").write_text(
26
+ f"---\nname: {name}\ndescription: {desc}\n---\n\nThe {name} framework.\n")
27
+ return d
28
+
29
+
30
+ def _volume_tree(tmp_path: Path) -> Path:
31
+ tree = SkillTree(TreeNode("my-tome", "sc", description="the tome root", children=[
32
+ TreeNode("patterns", "sc", description="the pattern volume"),
33
+ ]))
34
+ root = tmp_path / "tome_tree"
35
+ materialize(tree, root)
36
+ return root
37
+
38
+
39
+ def test_fold_adds_row_and_emits_table(tmp_path: Path):
40
+ root = _volume_tree(tmp_path)
41
+ fw = _framework(tmp_path, "alpha", "greek things")
42
+ rep = fold(fw, into="patterns", tree_root=root)
43
+ assert rep["ok"] and not rep["updated"] and rep["rows"] == 1
44
+ # manifest carries the row
45
+ tree = SkillTree.load(root / "skilltree.json")
46
+ node = next(n for n in tree.nodes() if n.name == "patterns")
47
+ assert node.frameworks[0]["name"] == "alpha"
48
+ assert node.frameworks[0]["skill_path"].endswith("SKILL.md")
49
+ # SKILL.md carries the generated table with the nomicon-atomize-parseable cell
50
+ body = node_skill_md(root / "patterns", "patterns").read_text()
51
+ assert "## Frameworks" in body
52
+ assert "| Framework | What it's for | Framework skill |" in body
53
+ assert re.search(r"\| alpha \| greek things \| alpha \(/.*?/SKILL\.md\) \|", body)
54
+ assert is_valid(root)
55
+
56
+
57
+ def test_fold_is_idempotent_one_row_per_framework(tmp_path: Path):
58
+ root = _volume_tree(tmp_path)
59
+ fw = _framework(tmp_path, "alpha")
60
+ assert fold(fw, into="patterns", tree_root=root)["rows"] == 1
61
+ rep = fold(fw, into="patterns", tree_root=root, what_for="updated purpose")
62
+ assert rep["updated"] and rep["rows"] == 1 # updated in place, not duplicated
63
+ body = node_skill_md(root / "patterns", "patterns").read_text()
64
+ assert body.count("| alpha |") == 1
65
+ assert "updated purpose" in body
66
+
67
+
68
+ def test_fold_bad_targets_are_reported(tmp_path: Path):
69
+ root = _volume_tree(tmp_path)
70
+ fw = _framework(tmp_path, "alpha")
71
+ assert not fold(fw, into="nope", tree_root=root)["ok"]
72
+ empty = tmp_path / "not-a-framework"
73
+ empty.mkdir()
74
+ assert not fold(empty, into="patterns", tree_root=root)["ok"]
75
+
76
+
77
+ def test_emit_regenerates_table_from_model(tmp_path: Path):
78
+ # a re-cohere (emit) must RE-EMIT the folded table, never wipe it
79
+ root = _volume_tree(tmp_path)
80
+ fold(_framework(tmp_path, "alpha"), into="patterns", tree_root=root)
81
+ rep = emit(root)
82
+ assert rep["ok"]
83
+ tree = SkillTree.load(root / "skilltree.json")
84
+ node = next(n for n in tree.nodes() if n.name == "patterns")
85
+ smd = root / "patterns" / ".claude" / "skills" / f"{node.coord}-patterns" / "SKILL.md"
86
+ body = smd.read_text()
87
+ assert body.count("## Frameworks") == 1 # regenerated once, not doubled
88
+ assert "| alpha |" in body
89
+
90
+
91
+ def test_validate_round_trip_catches_drift(tmp_path: Path):
92
+ root = _volume_tree(tmp_path)
93
+ fw = _framework(tmp_path, "alpha")
94
+ fold(fw, into="patterns", tree_root=root)
95
+ assert is_valid(root)
96
+ # dead target: the framework dir vanishes → error
97
+ (fw / "SKILL.md").rename(fw / "GONE.md")
98
+ errs = [v for v in validate(root) if v.severity == "error"]
99
+ assert any("does not resolve" in v.message for v in errs)
100
+ (fw / "GONE.md").rename(fw / "SKILL.md")
101
+ # missing table: SKILL.md hand-stripped of the section → error
102
+ smd = node_skill_md(root / "patterns", "patterns")
103
+ smd.write_text(re.sub(r"\n+## Frameworks\b.*\Z", "\n", smd.read_text(), flags=re.S))
104
+ errs = [v for v in validate(root) if v.severity == "error"]
105
+ assert any("no ## Frameworks table" in v.message for v in errs)
106
+
107
+
108
+ def test_validate_warns_on_undeclared_table_row(tmp_path: Path):
109
+ root = _volume_tree(tmp_path)
110
+ fold(_framework(tmp_path, "alpha"), into="patterns", tree_root=root)
111
+ smd = node_skill_md(root / "patterns", "patterns")
112
+ smd.write_text(smd.read_text() + "| ghost | not in manifest | ghost (/nowhere/SKILL.md) |\n")
113
+ warns = [v for v in validate(root) if v.severity == "warning"]
114
+ assert any("ghost" in v.message and "not declared" in v.message for v in warns)
115
+
116
+
117
+ def test_project_flat_symlinks_rows_and_holder(tmp_path: Path):
118
+ root = _volume_tree(tmp_path)
119
+ fw_a = _framework(tmp_path, "alpha")
120
+ fw_b = _framework(tmp_path, "beta")
121
+ fold(fw_a, into="patterns", tree_root=root)
122
+ fold(fw_b, into="patterns", tree_root=root)
123
+ target = tmp_path / "flat_skills"
124
+ links = project(root, target, policy="flat", node="patterns")
125
+ names = {p.name for p in links}
126
+ assert names == {"alpha", "beta", "patterns"} # every row + the holder
127
+ for p in links:
128
+ assert p.is_symlink() and (p / "SKILL.md").is_file()
129
+ # the originals stay the source of truth (symlinks, not copies)
130
+ assert (target / "alpha").resolve() == fw_a.resolve()
131
+
132
+
133
+ def test_project_flat_relative_links_and_reruns_cleanly(tmp_path: Path):
134
+ root = _volume_tree(tmp_path)
135
+ fold(_framework(tmp_path, "alpha"), into="patterns", tree_root=root)
136
+ target = tmp_path / "flat_skills"
137
+ project(root, target, policy="flat", node="patterns", link="relative")
138
+ raw = (target / "alpha").readlink()
139
+ assert not raw.is_absolute() # relative = portable together
140
+ # idempotent re-run
141
+ links = project(root, target, policy="flat", node="patterns", link="relative")
142
+ assert {p.name for p in links} == {"alpha", "patterns"}
143
+
144
+
145
+ def test_project_flat_self_clobber_guard(tmp_path: Path):
146
+ # projecting INTO the holder's own dir must never destroy the source
147
+ root = _volume_tree(tmp_path)
148
+ fold(_framework(tmp_path, "alpha"), into="patterns", tree_root=root)
149
+ inside = root / "patterns" / ".claude" / "skills"
150
+ links = project(root, inside, policy="flat", node="patterns")
151
+ assert "patterns" not in {p.name for p in links} # holder self-link skipped
152
+ assert node_skill_md(root / "patterns", "patterns").is_file() # source intact
153
+
154
+
155
+ def test_relative_fold_survives_relocation(tmp_path: Path):
156
+ # a repo-committed tome: relative rows must validate + project from ANY checkout path
157
+ site_a = tmp_path / "checkout_a"
158
+ site_a.mkdir()
159
+ root = _volume_tree(site_a)
160
+ fw = _framework(site_a, "alpha")
161
+ rep = fold(fw, into="patterns", tree_root=root, relative=True)
162
+ assert rep["ok"] and not Path(rep["row"]["skill_path"]).is_absolute()
163
+ assert is_valid(root)
164
+ # relocate the WHOLE checkout (tree + framework move together, like a fresh CI clone)
165
+ site_b = tmp_path / "checkout_b"
166
+ site_a.rename(site_b)
167
+ root_b = site_b / "tome_tree"
168
+ # breadcrumbs are absolute by design → a moved tree needs the localize step first:
169
+ # emit re-coheres at the NEW location from the manifest (the portable canon). The
170
+ # relative tome rows need no rewrite — that is the point of --relative.
171
+ assert emit(root_b)["ok"]
172
+ assert is_valid(root_b) # emit + validate = the CI gate flow
173
+ links = project(root_b, tmp_path / "flat_b", policy="flat", node="patterns")
174
+ assert (tmp_path / "flat_b" / "alpha").resolve() == (site_b / "fw-alpha").resolve()
175
+ # emit coordinatized the tree, so the holder projects under its coord identity
176
+ assert {p.name for p in links} == {"alpha", "0.1-patterns"}
177
+
178
+
179
+ def test_project_progressive_matches_link_tree(tmp_path: Path):
180
+ root = _volume_tree(tmp_path)
181
+ a = tmp_path / "user_a"
182
+ b = tmp_path / "user_b"
183
+ via_project = {p.name for p in project(root, a, policy="progressive")}
184
+ via_link_tree = {p.name for p in link_tree(root, user_skills_dir=b)}
185
+ assert via_project == via_link_tree == {"my-tome", "patterns"}
@@ -1,101 +0,0 @@
1
- """Make a SkillTree (or a forest of them) visible from the TOP — `~/.claude/skills`.
2
-
3
- The top-level user `.claude/skills` is scanned in every session, but the scan is
4
- non-recursive WITHIN a `.claude/skills` (a skill can't contain auto-loadable
5
- sub-skills). So to surface a tree from the top you SYMLINK its entry skill dirs
6
- into `~/.claude/skills`, and the `cat`-breadcrumbs carry you down from there.
7
-
8
- - single tree → `link_tree`: symlink the root (and its first-layer branches) up
9
- - many trees → `build_forest`: one forest-root skill that breadcrumbs to each
10
- tree's root, symlinked up — a forest view over the top.
11
- """
12
- from __future__ import annotations
13
-
14
- from pathlib import Path
15
-
16
- from .model import SkillTree, skill_name
17
-
18
- _CRUMB = "- {name} ({kind}): Read `{path}`" # the Read TOOL injects the layer, not `cat`
19
-
20
-
21
- def _skill_dir(node_dir: Path, name: str) -> Path:
22
- return Path(node_dir) / ".claude" / "skills" / name
23
-
24
-
25
- def _symlink(link: Path, target: Path) -> Path:
26
- link.parent.mkdir(parents=True, exist_ok=True)
27
- if link.is_symlink() or link.exists():
28
- link.unlink()
29
- link.symlink_to(Path(target).resolve(), target_is_directory=True)
30
- return link
31
-
32
-
33
- def link_tree(tree_dir: str | Path, *, user_skills_dir: str | Path,
34
- manifest: str | Path | None = None,
35
- include_root: bool = True, branches: bool = True) -> list[Path]:
36
- """Symlink a single tree's entry points into the top-level user skills dir.
37
-
38
- The root gives the overview + breadcrumbs; the branches (root's direct
39
- children) are surfaced too so the first layer is reachable from the top.
40
- Deeper levels stay behind `cat` (progressive disclosure).
41
- """
42
- tree_dir = Path(tree_dir)
43
- tree = SkillTree.load(manifest or tree_dir / "skilltree.json")
44
- user = Path(user_skills_dir)
45
- links: list[Path] = []
46
- if include_root:
47
- rn = skill_name(tree.root)
48
- links.append(_symlink(user / rn, _skill_dir(tree_dir, rn)))
49
- if branches:
50
- for child in tree.root.children:
51
- cn = skill_name(child)
52
- links.append(_symlink(user / cn, _skill_dir(tree_dir / child.name, cn)))
53
- return links
54
-
55
-
56
- def build_forest(name: str, members: list[str | Path], *, user_skills_dir: str | Path,
57
- forest_dir: str | Path, description: str | None = None) -> Path:
58
- """Build a forest-root skill over several trees and symlink it to the top.
59
-
60
- `members` = tree dirs (each with a skilltree.json). The forest root's body
61
- breadcrumbs to each tree's root; one top-level entry, progressive descent.
62
- """
63
- fdir = _skill_dir(Path(forest_dir), name)
64
- fdir.mkdir(parents=True, exist_ok=True)
65
- crumbs, roots = [], []
66
- for tdir in members:
67
- tdir = Path(tdir)
68
- tree = SkillTree.load(tdir / "skilltree.json")
69
- rn = skill_name(tree.root)
70
- md = _skill_dir(tdir, rn) / "SKILL.md"
71
- crumbs.append(_CRUMB.format(name=rn, kind=tree.root.kind, path=md.resolve()))
72
- roots.append(rn)
73
- desc = description or f"Forest of {len(members)} skill tree(s): {', '.join(roots)}."
74
- body = (f"Forest **{name}** — {len(members)} skill tree(s). "
75
- "Pick one and **Read** (the Read tool) into its root to load it, then follow its "
76
- "breadcrumbs down (`cat` won't load it — only the Read tool does):\n\n" + "\n".join(crumbs))
77
- (fdir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {desc}\n---\n\n{body}\n", encoding="utf-8")
78
- return _symlink(Path(user_skills_dir) / name, fdir)
79
-
80
-
81
- def list_links(user_skills_dir: str | Path) -> list[dict]:
82
- """List symlinks in the user skills dir and whether they still resolve."""
83
- user = Path(user_skills_dir)
84
- out: list[dict] = []
85
- if user.exists():
86
- for p in sorted(user.iterdir()):
87
- if p.is_symlink():
88
- out.append({"name": p.name, "target": str(p.readlink()), "resolves": p.exists()})
89
- return out
90
-
91
-
92
- def unlink(user_skills_dir: str | Path, *names: str) -> list[str]:
93
- """Remove named symlinks (only if they are symlinks — never deletes real dirs)."""
94
- user = Path(user_skills_dir)
95
- removed: list[str] = []
96
- for n in names:
97
- p = user / n
98
- if p.is_symlink():
99
- p.unlink()
100
- removed.append(n)
101
- return removed
File without changes