agent-skilltree 0.2.2__tar.gz → 0.4.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 (45) hide show
  1. {agent_skilltree-0.2.2/src/agent_skilltree.egg-info → agent_skilltree-0.4.0}/PKG-INFO +31 -2
  2. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/README.md +30 -1
  3. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/pyproject.toml +1 -1
  4. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0/src/agent_skilltree.egg-info}/PKG-INFO +31 -2
  5. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/SOURCES.txt +4 -1
  6. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/__init__.py +15 -4
  7. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/cli.py +69 -6
  8. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/cohere.py +57 -10
  9. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/exchange.py +13 -7
  10. agent_skilltree-0.4.0/src/skilltree/forest.py +178 -0
  11. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/materialize.py +35 -9
  12. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/model.py +76 -2
  13. agent_skilltree-0.4.0/src/skilltree/tome.py +86 -0
  14. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/validate.py +41 -3
  15. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_cohere.py +1 -1
  16. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_exchange.py +4 -2
  17. agent_skilltree-0.4.0/tests/test_portable.py +109 -0
  18. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_skilltree.py +4 -2
  19. agent_skilltree-0.4.0/tests/test_tome.py +185 -0
  20. agent_skilltree-0.2.2/src/skilltree/forest.py +0 -101
  21. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/LICENSE +0 -0
  22. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/setup.cfg +0 -0
  23. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/dependency_links.txt +0 -0
  24. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/entry_points.txt +0 -0
  25. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/requires.txt +0 -0
  26. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/top_level.txt +0 -0
  27. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/federation.py +0 -0
  28. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/mapper.py +0 -0
  29. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/marketplace.py +0 -0
  30. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/registry.py +0 -0
  31. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/reports.py +0 -0
  32. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/src/skilltree/search.py +0 -0
  33. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_coords.py +0 -0
  34. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_emit_roundtrip.py +0 -0
  35. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_federation.py +0 -0
  36. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_folder_search.py +0 -0
  37. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_forest.py +0 -0
  38. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_glyph_search.py +0 -0
  39. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_mapper.py +0 -0
  40. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_marketplace.py +0 -0
  41. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_notifications.py +0 -0
  42. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_registry.py +0 -0
  43. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_reports.py +0 -0
  44. {agent_skilltree-0.2.2 → agent_skilltree-0.4.0}/tests/test_search.py +0 -0
  45. {agent_skilltree-0.2.2 → agent_skilltree-0.4.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.2
3
+ Version: 0.4.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,8 +82,11 @@ 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). Projecting is an install, so it localizes the tree's breadcrumbs first |
87
+ | `relink <root> [--localize]` | rewrite only the breadcrumb paths: **portable** (the default and the shipped form — relative to each node's folder, no machine's path in any file) or `--localize` (absolute for this machine — the install-time step) |
85
88
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
86
- | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
89
+ | `emit <root> [--root-forest] [--localize]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled); breadcrumbs portable unless `--localize` |
87
90
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
88
91
  | `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
89
92
 
@@ -128,6 +131,32 @@ Two papers document the project:
128
131
 
129
132
  ## Changelog
130
133
 
134
+ ### v0.4.0 — 2026-10-23
135
+ - **Trees ship portable.** Every breadcrumb names its child relative to the node's folder (the folder holding the SKILL.md's `.claude/`), and the manifest stores `skill_src` relative to itself, so a materialized tree carries no machine's path and validates wherever it is copied — with no step. `materialize`, `emit` and `exchange build` take `localize=True` (`--localize`) to write absolute paths for one machine; the new `relink` rewrites only the paths, either way; `project` localizes the tree it projects (a projected tree is read through a symlink, where only an absolute path resolves). `validate` accepts both forms. 80 tests (7 new, in `tests/test_portable.py`).
136
+ - **Developed in one source home and published here.** This repository and PyPI `agent-skilltree` are the published output of SkillTree's source home; changes arrive as releases, each with a version and an entry here.
137
+ - Frontmatter quotes YAML-unsafe scalars (#1), merged after 0.3.0 and not yet released.
138
+ - The figure script writes beside itself instead of to a fixed folder; a test comment names the author by role.
139
+
140
+ ### v0.3.0 — 2026-07-09
141
+ - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
142
+ rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
143
+ What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
144
+ the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
145
+ **`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
146
+ row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
147
+ link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
148
+ (`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
149
+ frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
150
+ **tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
151
+ undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
152
+ tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
153
+ and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
154
+ Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
155
+ **70 passing** (+11 tome tests).
156
+ - Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
157
+ ("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
158
+ keep working (verified against the reference atomizer end-to-end).
159
+
131
160
  ### v0.2.0 — 2026-06-18
132
161
  - **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
162
 
@@ -68,8 +68,11 @@ 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). Projecting is an install, so it localizes the tree's breadcrumbs first |
73
+ | `relink <root> [--localize]` | rewrite only the breadcrumb paths: **portable** (the default and the shipped form — relative to each node's folder, no machine's path in any file) or `--localize` (absolute for this machine — the install-time step) |
71
74
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
72
- | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
75
+ | `emit <root> [--root-forest] [--localize]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled); breadcrumbs portable unless `--localize` |
73
76
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
74
77
  | `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
75
78
 
@@ -114,6 +117,32 @@ Two papers document the project:
114
117
 
115
118
  ## Changelog
116
119
 
120
+ ### v0.4.0 — 2026-10-23
121
+ - **Trees ship portable.** Every breadcrumb names its child relative to the node's folder (the folder holding the SKILL.md's `.claude/`), and the manifest stores `skill_src` relative to itself, so a materialized tree carries no machine's path and validates wherever it is copied — with no step. `materialize`, `emit` and `exchange build` take `localize=True` (`--localize`) to write absolute paths for one machine; the new `relink` rewrites only the paths, either way; `project` localizes the tree it projects (a projected tree is read through a symlink, where only an absolute path resolves). `validate` accepts both forms. 80 tests (7 new, in `tests/test_portable.py`).
122
+ - **Developed in one source home and published here.** This repository and PyPI `agent-skilltree` are the published output of SkillTree's source home; changes arrive as releases, each with a version and an entry here.
123
+ - Frontmatter quotes YAML-unsafe scalars (#1), merged after 0.3.0 and not yet released.
124
+ - The figure script writes beside itself instead of to a fixed folder; a test comment names the author by role.
125
+
126
+ ### v0.3.0 — 2026-07-09
127
+ - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
128
+ rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
129
+ What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
130
+ the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
131
+ **`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
132
+ row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
133
+ link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
134
+ (`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
135
+ frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
136
+ **tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
137
+ undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
138
+ tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
139
+ and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
140
+ Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
141
+ **70 passing** (+11 tome tests).
142
+ - Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
143
+ ("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
144
+ keep working (verified against the reference atomizer end-to-end).
145
+
117
146
  ### v0.2.0 — 2026-06-18
118
147
  - **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
148
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agent-skilltree"
7
- version = "0.2.2"
7
+ version = "0.4.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.2
3
+ Version: 0.4.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,8 +82,11 @@ 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). Projecting is an install, so it localizes the tree's breadcrumbs first |
87
+ | `relink <root> [--localize]` | rewrite only the breadcrumb paths: **portable** (the default and the shipped form — relative to each node's folder, no machine's path in any file) or `--localize` (absolute for this machine — the install-time step) |
85
88
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
86
- | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
89
+ | `emit <root> [--root-forest] [--localize]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled); breadcrumbs portable unless `--localize` |
87
90
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
88
91
  | `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
89
92
 
@@ -128,6 +131,32 @@ Two papers document the project:
128
131
 
129
132
  ## Changelog
130
133
 
134
+ ### v0.4.0 — 2026-10-23
135
+ - **Trees ship portable.** Every breadcrumb names its child relative to the node's folder (the folder holding the SKILL.md's `.claude/`), and the manifest stores `skill_src` relative to itself, so a materialized tree carries no machine's path and validates wherever it is copied — with no step. `materialize`, `emit` and `exchange build` take `localize=True` (`--localize`) to write absolute paths for one machine; the new `relink` rewrites only the paths, either way; `project` localizes the tree it projects (a projected tree is read through a symlink, where only an absolute path resolves). `validate` accepts both forms. 80 tests (7 new, in `tests/test_portable.py`).
136
+ - **Developed in one source home and published here.** This repository and PyPI `agent-skilltree` are the published output of SkillTree's source home; changes arrive as releases, each with a version and an entry here.
137
+ - Frontmatter quotes YAML-unsafe scalars (#1), merged after 0.3.0 and not yet released.
138
+ - The figure script writes beside itself instead of to a fixed folder; a test comment names the author by role.
139
+
140
+ ### v0.3.0 — 2026-07-09
141
+ - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
142
+ rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
143
+ What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
144
+ the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
145
+ **`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
146
+ row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
147
+ link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
148
+ (`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
149
+ frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
150
+ **tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
151
+ undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
152
+ tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
153
+ and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
154
+ Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
155
+ **70 passing** (+11 tome tests).
156
+ - Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
157
+ ("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
158
+ keep working (verified against the reference atomizer end-to-end).
159
+
131
160
  ### v0.2.0 — 2026-06-18
132
161
  - **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
162
 
@@ -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
@@ -32,8 +33,10 @@ tests/test_glyph_search.py
32
33
  tests/test_mapper.py
33
34
  tests/test_marketplace.py
34
35
  tests/test_notifications.py
36
+ tests/test_portable.py
35
37
  tests/test_registry.py
36
38
  tests/test_reports.py
37
39
  tests/test_search.py
38
40
  tests/test_skilltree.py
39
- tests/test_summaries.py
41
+ tests/test_summaries.py
42
+ tests/test_tome.py
@@ -11,14 +11,23 @@ from importlib.metadata import PackageNotFoundError, version as _pkg_version
11
11
  try:
12
12
  __version__ = _pkg_version("agent-skilltree")
13
13
  except PackageNotFoundError: # running from a source checkout that isn't pip-installed
14
- __version__ = "0.2.2"
14
+ __version__ = "0.3.0"
15
15
 
16
16
  from .exchange import Exchange, Member, load_exchange
17
17
  from .exchange import build as build_exchange
18
18
  from .exchange import is_valid as exchange_is_valid
19
19
  from .exchange import validate as validate_exchange
20
20
  from .materialize import materialize
21
- 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
+ )
22
31
  from .registry import (
23
32
  Entry,
24
33
  load_registry,
@@ -27,7 +36,8 @@ from .registry import (
27
36
  validate_registry,
28
37
  )
29
38
  from .registry import search as registry_search
30
- 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
31
41
  from .reports import (
32
42
  list_reports,
33
43
  mark_problem,
@@ -58,7 +68,8 @@ __all__ = [
58
68
  "promote", "registry_search",
59
69
  "walk_federation", "flatten_federation", "validate_federation",
60
70
  "register_child", "local_resolver",
61
- "link_tree", "build_forest", "list_links", "unlink",
71
+ "link_tree", "build_forest", "list_links", "unlink", "project",
72
+ "fold", "tome_table", "find_node", "TOME_HEADING",
62
73
  "assign_coords", "skill_name",
63
74
  "build_index", "search", "search_tree", "search_folder", "DEFAULT_EXTS",
64
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
 
@@ -55,11 +55,14 @@ def cohere_cmd(root: Path) -> None:
55
55
  @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
56
56
  @click.option("--root-forest", is_flag=True, help="tree-ify a bare forest (synthesize + save a root)")
57
57
  @click.option("--name", "forest_name", default=None, help="name for the synthesized root")
58
- def emit_cmd(root: Path, root_forest: bool, forest_name: str | None) -> None:
58
+ @click.option("--localize", is_flag=True,
59
+ help="write breadcrumbs absolute for THIS machine (install time); default is portable")
60
+ def emit_cmd(root: Path, root_forest: bool, forest_name: str | None, localize: bool) -> None:
59
61
  """Re-cohere IN PLACE: reassign coords + rewrite breadcrumbs/index from the
60
- canonical shape (non-destructive). With --root-forest, root a bare forest."""
62
+ canonical shape (non-destructive). With --root-forest, root a bare forest.
63
+ Breadcrumbs are portable (relative to each node's folder) unless --localize."""
61
64
  from .cohere import emit
62
- report = emit(root, root_forest=root_forest, forest_name=forest_name)
65
+ report = emit(root, root_forest=root_forest, forest_name=forest_name, localize=localize)
63
66
  if not report.get("ok"):
64
67
  click.echo(f"✗ {report.get('error')}")
65
68
  raise SystemExit(1)
@@ -70,6 +73,21 @@ def emit_cmd(root: Path, root_forest: bool, forest_name: str | None) -> None:
70
73
  click.echo(f" journal: {report['journal']} (reverse with `skilltree unemit`)")
71
74
 
72
75
 
76
+ @main.command(name="relink")
77
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
78
+ @click.option("--localize", is_flag=True, help="absolute paths for THIS machine (install time)")
79
+ def relink_cmd(root: Path, localize: bool) -> None:
80
+ """Rewrite only the breadcrumb paths, in place: portable (the shipped form, relative to
81
+ each node's folder) or --localize (absolute, the install-time step). Nothing else moves."""
82
+ from .cohere import relink
83
+ report = relink(root, localize=localize)
84
+ if not report.get("ok"):
85
+ click.echo(f"✗ {report.get('error')}")
86
+ raise SystemExit(1)
87
+ form = "localized (absolute)" if localize else "portable (relative)"
88
+ click.echo(f"✓ relinked {report['nodes']} node(s): {form}")
89
+
90
+
73
91
  @main.command(name="unemit")
74
92
  @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
75
93
  def unemit_cmd(root: Path) -> None:
@@ -216,10 +234,12 @@ def reports_cmd(reports_path, kind):
216
234
  @main.command(name="build")
217
235
  @click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
218
236
  @click.argument("root", type=click.Path(path_type=Path))
219
- def build_cmd(manifest: Path, root: Path) -> None:
237
+ @click.option("--localize", is_flag=True,
238
+ help="write breadcrumbs absolute for THIS machine; default is portable")
239
+ def build_cmd(manifest: Path, root: Path, localize: bool) -> None:
220
240
  """Materialize a SkillTree from a JSON MANIFEST into ROOT, then validate."""
221
241
  tree = SkillTree.load(manifest)
222
- materialize(tree, root)
242
+ materialize(tree, root, localize=localize)
223
243
  issues = validate(root)
224
244
  errors = [v for v in issues if v.severity == "error"]
225
245
  click.echo(f"built {len(tree.nodes())} nodes at {root}")
@@ -242,6 +262,49 @@ def validate_cmd(root: Path) -> None:
242
262
  click.echo("✓ valid — every breadcrumb resolves.")
243
263
 
244
264
 
265
+ @main.command(name="fold")
266
+ @click.argument("framework_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
267
+ @click.option("--into", required=True, help="the volume node to bind into (name or coord)")
268
+ @click.option("--tree", "tree_root", required=True,
269
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
270
+ help="the materialized tree root (holds skilltree.json)")
271
+ @click.option("--what-for", default=None, help="the row's purpose text (default: the framework's description)")
272
+ @click.option("--name", default=None, help="the row name (default: the framework's frontmatter name)")
273
+ @click.option("--relative", is_flag=True,
274
+ help="store the row path relative to the volume node (repo-portable: validates/projects from any checkout)")
275
+ def fold_cmd(framework_dir: Path, into: str, tree_root: Path, what_for: str | None,
276
+ name: str | None, relative: bool) -> None:
277
+ """Fold a FRAMEWORK_DIR into a volume node's tome table (idempotent, one row
278
+ per framework). The manifest is updated and the node's SKILL.md regenerated."""
279
+ from .tome import fold
280
+ rep = fold(framework_dir, into=into, tree_root=tree_root, what_for=what_for, name=name,
281
+ relative=relative)
282
+ if not rep.get("ok"):
283
+ click.echo(f"✗ {rep.get('error')}")
284
+ raise SystemExit(1)
285
+ verb = "updated" if rep["updated"] else "added"
286
+ click.echo(f"✓ {verb} row {rep['row']['name']!r} on node {rep['node']!r} "
287
+ f"({rep['rows']} framework(s) held)")
288
+
289
+
290
+ @main.command(name="project")
291
+ @click.argument("tree_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
292
+ @click.argument("target", type=click.Path(path_type=Path))
293
+ @click.option("--policy", type=click.Choice(["progressive", "flat"]), default="progressive",
294
+ help="progressive = root + first-layer branches; flat = a volume's tome rows + holder")
295
+ @click.option("--link", type=click.Choice(["absolute", "relative"]), default="absolute")
296
+ @click.option("--node", default=None, help="(flat) the volume node to project (name or coord; default root)")
297
+ def project_cmd(tree_root: Path, target: Path, policy: str, link: str, node: str | None) -> None:
298
+ """Project TREE_ROOT into TARGET (a skills dir) as symlinks — progressive
299
+ disclosure, or a volume's flat auto-discoverable deployment. Projecting is an install:
300
+ TREE_ROOT's breadcrumbs are localized (absolute) for this machine first."""
301
+ from .forest import project
302
+ links = project(tree_root, target, policy=policy, link=link, node=node)
303
+ click.echo(f"✓ projected {len(links)} link(s) ({policy}, {link}) into {target}")
304
+ for p in links:
305
+ click.echo(f" - {p.name} -> {p.readlink()}")
306
+
307
+
245
308
  @main.group()
246
309
  def exchange() -> None:
247
310
  """Many skill trees in one repo, under a master."""
@@ -24,14 +24,18 @@ 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 (PORTABLE_NOTE, SkillTree, TreeNode, assign_coords, compose_summary, crumb_path,
28
+ skill_name, tome_table)
28
29
 
29
30
  # dirs that are never tree nodes
30
31
  _SKIP = {".claude", "kb", "__pycache__", ".git", "node_modules", ".aios.src"}
31
32
  # a coord-prefixed skill identity: "0.1.2-some-name" → ("0.1.2", "some-name")
32
33
  _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)
34
+ # everything from the first generated section to EOF (materialize appends them last).
35
+ # Generated sections, in emit order: Index summary → Descend (branches), then the
36
+ # `## Frameworks` tome table (any node with frameworks rows) — leftmost heading wins,
37
+ # so a leaf-with-table strips from Frameworks and a branch strips from Index summary.
38
+ _SECT_RE = re.compile(r"\n+## (?:Index summary|Frameworks)\b.*\Z", re.S)
35
39
  _CRUMB_RE = re.compile(r"`([^`]+?)/SKILL\.md`") # the path-before-SKILL.md, verb-agnostic
36
40
  _CRUMB = "- {name} ({kind}): Read `{path}`" # the verb is the Read TOOL, never `cat`
37
41
 
@@ -214,7 +218,7 @@ def cohere(root: str | Path) -> list[Finding]:
214
218
 
215
219
 
216
220
  # ── emit: rewrite coords + breadcrumbs + index IN PLACE (non-destructive) ─────
217
- def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
221
+ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path, localize: bool = False) -> None:
218
222
  """Refresh ONLY the coord prefix + Index/Descend sections of an existing
219
223
  SKILL.md, preserving the hand-written body above them. Creates the file (a
220
224
  stub) if absent (e.g. a freshly synthesized forest root)."""
@@ -242,24 +246,64 @@ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
242
246
  if node.children:
243
247
  crumbs = [_CRUMB.format(
244
248
  name=skill_name(c), kind=c.kind,
245
- path=(node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md").resolve())
249
+ path=crumb_path(node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md",
250
+ node_dir, localize=localize))
246
251
  for c in node.children]
247
252
  tail = (f"\n\n## Index summary\n{compose_summary(node, full=True)}\n\n"
248
253
  f"## Descend — the next layer ({len(node.children)})\n"
249
254
  "Only this layer is loaded now. To descend, use the **Read tool** on a child "
250
255
  "below — that injects the child's layer. A Bash `cat` reads the bytes but loads "
251
- "nothing; you must use the Read tool:\n\n" + "\n".join(crumbs))
256
+ "nothing; you must use the Read tool."
257
+ + ("" if localize else f" {PORTABLE_NOTE}") + "\n\n" + "\n".join(crumbs))
252
258
  else:
253
259
  tail = ""
260
+ if node.frameworks: # regenerate the tome table from the model rows
261
+ tail += f"\n\n{tome_table(node)}"
254
262
  out = f"---\nname: {sname}\ndescription: {desc}\n---\n\n{base}{tail}\n"
255
263
  smd.write_text(out, encoding="utf-8")
256
264
 
257
265
 
266
+ def relink(root: str | Path, *, localize: bool = False) -> dict:
267
+ """Rewrite ONLY the breadcrumb paths of a materialized tree, in place: PORTABLE (relative
268
+ to each node's folder — the shipped form) or LOCALIZED (absolute for this machine — the
269
+ install-time step). Coords, names, bodies and tome tables are untouched."""
270
+ root = Path(root)
271
+ manifest = root / "skilltree.json"
272
+ if not manifest.is_file():
273
+ return {"ok": False, "error": f"no skilltree.json manifest at {root}"}
274
+ tree = SkillTree.load(manifest)
275
+ n = 0
276
+
277
+ def walk(node: TreeNode, node_dir: Path) -> None:
278
+ nonlocal n
279
+ smd = node_dir / ".claude" / "skills" / skill_name(node) / "SKILL.md"
280
+ if node.children and smd.is_file():
281
+ text = smd.read_text(encoding="utf-8")
282
+ for c in node.children:
283
+ child_md = node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md"
284
+ pat = re.compile(r"^(- " + re.escape(skill_name(c)) + r" \(" + re.escape(c.kind)
285
+ + r"\): Read `)[^`]+(`)", re.M)
286
+ path = crumb_path(child_md, node_dir, localize=localize)
287
+ text = pat.sub(lambda m: m.group(1) + path + m.group(2), text)
288
+ text = text.replace("you must use the Read tool:", "you must use the Read tool.")
289
+ text = text.replace(" " + PORTABLE_NOTE, "")
290
+ if not localize:
291
+ text = text.replace("you must use the Read tool.",
292
+ "you must use the Read tool. " + PORTABLE_NOTE, 1)
293
+ smd.write_text(text, encoding="utf-8")
294
+ n += 1
295
+ for c in node.children:
296
+ walk(c, node_dir / c.name)
297
+
298
+ walk(tree.root, root)
299
+ return {"ok": True, "nodes": n, "localized": localize}
300
+
301
+
258
302
  JOURNAL = ".emit-journal.json" # the reversibility receipt written by a tree-ifying emit
259
303
 
260
304
 
261
305
  def emit(root: str | Path, *, root_forest: bool = False,
262
- forest_name: str | None = None) -> dict:
306
+ forest_name: str | None = None, localize: bool = False) -> dict:
263
307
  """Re-cohere the tree IN PLACE: assign coords + rewrite every node's
264
308
  breadcrumbs/index from the canonical shape, without destroying content.
265
309
 
@@ -267,7 +311,10 @@ def emit(root: str | Path, *, root_forest: bool = False,
267
311
  a root, and **tree-ify it**: each flat leaf is MOVED WHOLESALE into its nested
268
312
  node-dir (all its files preserved — *not* a body-only re-render), so it stops
269
313
  auto-loading. Every move is JOURNALED to `.emit-journal.json` so `unemit` can
270
- reverse it exactly (your cache-for-reversibility requirement)."""
314
+ reverse it exactly (your cache-for-reversibility requirement).
315
+
316
+ Breadcrumbs are written PORTABLE (relative to each node's folder) unless `localize=True`,
317
+ which writes them absolute for the place the tree is installed — the install-time step."""
271
318
  root = Path(root)
272
319
  manifest = root / "skilltree.json"
273
320
  synthesized = False
@@ -306,10 +353,10 @@ def emit(root: str | Path, *, root_forest: bool = False,
306
353
  journal.append({"op": "move", "from": str(old_src), "to": str(target_dir),
307
354
  "skill_md": orig})
308
355
  shutil.move(str(old_src), str(target_dir))
309
- _rewrite_node_md(target_dir / "SKILL.md", node, node_dir)
356
+ _rewrite_node_md(target_dir / "SKILL.md", node, node_dir, localize)
310
357
  else:
311
358
  existed = (target_dir / "SKILL.md").exists()
312
- _rewrite_node_md(target_dir / "SKILL.md", node, node_dir)
359
+ _rewrite_node_md(target_dir / "SKILL.md", node, node_dir, localize)
313
360
  if synthesized and not existed: # a freshly-created node (the synth root)
314
361
  journal.append({"op": "create", "path": str(target_dir)})
315
362
  n += 1
@@ -63,30 +63,35 @@ def _front(name: str, description: str, body: str) -> str:
63
63
  return f"---\nname: {name}\ndescription: {description}\n---\n\n{body.rstrip()}\n"
64
64
 
65
65
 
66
- def build(exchange: Exchange, repo_dir: str | Path) -> Path:
67
- """Materialize every member tree + the master. Returns the master SKILL.md path."""
66
+ def build(exchange: Exchange, repo_dir: str | Path, *, localize: bool = False) -> Path:
67
+ """Materialize every member tree + the master. Returns the master SKILL.md path.
68
+ Breadcrumbs are portable (relative to the repo) unless `localize=True`."""
68
69
  repo = Path(repo_dir)
69
70
  members: list[tuple[str, Path]] = [] # (member name, member root SKILL.md)
70
71
  for m in exchange.trees:
71
72
  tree = SkillTree.load((exchange.base / m.manifest).resolve())
72
73
  tree_dir = repo / "trees" / m.name
73
- materialize(tree, tree_dir)
74
+ materialize(tree, tree_dir, localize=localize)
74
75
  members.append((m.name, node_skill_md(tree_dir, tree.root.name).resolve()))
75
76
 
76
77
  master_md = node_skill_md(repo, exchange.master)
77
78
  master_md.parent.mkdir(parents=True, exist_ok=True)
78
- crumbs = [f"- {name} (tree): Read `{root_md}`" for name, root_md in members]
79
+ from .model import PORTABLE_NOTE, crumb_path
80
+ crumbs = [f"- {name} (tree): Read `{crumb_path(root_md, repo, localize=localize)}`"
81
+ for name, root_md in members]
79
82
  body = (f"Master of the **{exchange.name}** exchange — {len(members)} skill tree(s).\n\n"
80
83
  "## Trees — pick one and **Read** (the Read tool) into its root\n"
81
84
  "Only this master is loaded. Read a tree's root below to load it (a Bash `cat` won't); "
82
- "each tree then walks its own breadcrumbs:\n\n"
85
+ "each tree then walks its own breadcrumbs." + ("" if localize else f" {PORTABLE_NOTE}")
86
+ + "\n\n"
83
87
  + "\n".join(crumbs))
84
88
  master_md.write_text(_front(exchange.master, f"master of the {exchange.name} exchange", body),
85
89
  encoding="utf-8")
86
90
 
87
91
  (repo / "exchange.lock.json").write_text(json.dumps(
88
92
  {"name": exchange.name, "master": exchange.master,
89
- "trees": [{"name": n, "root": str(r)} for n, r in members]}, indent=2), encoding="utf-8")
93
+ "trees": [{"name": n, "root": crumb_path(r, repo, localize=localize)} for n, r in members]},
94
+ indent=2), encoding="utf-8")
90
95
  return master_md
91
96
 
92
97
 
@@ -104,7 +109,8 @@ def validate(repo_dir: str | Path, exchange: Exchange) -> list[Violation]:
104
109
  return out
105
110
  if not _has_frontmatter(master_md):
106
111
  out.append(Violation("error", exchange.master, "master SKILL.md lacks frontmatter"))
107
- found = {Path(p).resolve() for p in _CRUMB_RE.findall(master_md.read_text(encoding="utf-8"))}
112
+ found = {(Path(p) if Path(p).is_absolute() else repo / p).resolve()
113
+ for p in _CRUMB_RE.findall(master_md.read_text(encoding="utf-8"))}
108
114
  expected = {
109
115
  node_skill_md(repo / "trees" / m.name, SkillTree.load((exchange.base / m.manifest).resolve()).root.name).resolve()
110
116
  for m in exchange.trees