agent-skilltree 0.3.0__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.
- {agent_skilltree-0.3.0/src/agent_skilltree.egg-info → agent_skilltree-0.4.0}/PKG-INFO +10 -3
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/README.md +9 -2
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/pyproject.toml +1 -1
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0/src/agent_skilltree.egg-info}/PKG-INFO +10 -3
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/SOURCES.txt +1 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/cli.py +27 -6
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/cohere.py +50 -8
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/exchange.py +13 -7
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/forest.py +9 -1
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/materialize.py +29 -9
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/model.py +37 -2
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/validate.py +5 -2
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_cohere.py +1 -1
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_exchange.py +4 -2
- agent_skilltree-0.4.0/tests/test_portable.py +109 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_skilltree.py +4 -2
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/LICENSE +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/setup.cfg +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/dependency_links.txt +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/entry_points.txt +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/requires.txt +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/top_level.txt +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/__init__.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/federation.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/mapper.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/marketplace.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/registry.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/reports.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/search.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/tome.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_coords.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_emit_roundtrip.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_federation.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_folder_search.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_forest.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_glyph_search.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_mapper.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_marketplace.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_notifications.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_registry.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_reports.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_search.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_summaries.py +0 -0
- {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_tome.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-skilltree
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -83,9 +83,10 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
|
|
|
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
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) |
|
|
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) |
|
|
87
88
|
| `cohere <root>` | report drift between the on-disk tree and its coherent shape |
|
|
88
|
-
| `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` |
|
|
89
90
|
| `unemit <root>` | reverse the last `emit`, byte-for-byte |
|
|
90
91
|
| `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
|
|
91
92
|
|
|
@@ -130,6 +131,12 @@ Two papers document the project:
|
|
|
130
131
|
|
|
131
132
|
## Changelog
|
|
132
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
|
+
|
|
133
140
|
### v0.3.0 — 2026-07-09
|
|
134
141
|
- **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
|
|
135
142
|
rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
|
|
@@ -69,9 +69,10 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
|
|
|
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
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) |
|
|
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) |
|
|
73
74
|
| `cohere <root>` | report drift between the on-disk tree and its coherent shape |
|
|
74
|
-
| `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` |
|
|
75
76
|
| `unemit <root>` | reverse the last `emit`, byte-for-byte |
|
|
76
77
|
| `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
|
|
77
78
|
|
|
@@ -116,6 +117,12 @@ Two papers document the project:
|
|
|
116
117
|
|
|
117
118
|
## Changelog
|
|
118
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
|
+
|
|
119
126
|
### v0.3.0 — 2026-07-09
|
|
120
127
|
- **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
|
|
121
128
|
rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agent-skilltree"
|
|
7
|
-
version = "0.
|
|
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.
|
|
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
|
|
@@ -83,9 +83,10 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
|
|
|
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
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) |
|
|
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) |
|
|
87
88
|
| `cohere <root>` | report drift between the on-disk tree and its coherent shape |
|
|
88
|
-
| `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` |
|
|
89
90
|
| `unemit <root>` | reverse the last `emit`, byte-for-byte |
|
|
90
91
|
| `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
|
|
91
92
|
|
|
@@ -130,6 +131,12 @@ Two papers document the project:
|
|
|
130
131
|
|
|
131
132
|
## Changelog
|
|
132
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
|
+
|
|
133
140
|
### v0.3.0 — 2026-07-09
|
|
134
141
|
- **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
|
|
135
142
|
rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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}")
|
|
@@ -276,7 +296,8 @@ def fold_cmd(framework_dir: Path, into: str, tree_root: Path, what_for: str | No
|
|
|
276
296
|
@click.option("--node", default=None, help="(flat) the volume node to project (name or coord; default root)")
|
|
277
297
|
def project_cmd(tree_root: Path, target: Path, policy: str, link: str, node: str | None) -> None:
|
|
278
298
|
"""Project TREE_ROOT into TARGET (a skills dir) as symlinks — progressive
|
|
279
|
-
disclosure, or a volume's flat auto-discoverable deployment.
|
|
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."""
|
|
280
301
|
from .forest import project
|
|
281
302
|
links = project(tree_root, target, policy=policy, link=link, node=node)
|
|
282
303
|
click.echo(f"✓ projected {len(links)} link(s) ({policy}, {link}) into {target}")
|
|
@@ -24,7 +24,8 @@ 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,
|
|
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"}
|
|
@@ -217,7 +218,7 @@ def cohere(root: str | Path) -> list[Finding]:
|
|
|
217
218
|
|
|
218
219
|
|
|
219
220
|
# ── emit: rewrite coords + breadcrumbs + index IN PLACE (non-destructive) ─────
|
|
220
|
-
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:
|
|
221
222
|
"""Refresh ONLY the coord prefix + Index/Descend sections of an existing
|
|
222
223
|
SKILL.md, preserving the hand-written body above them. Creates the file (a
|
|
223
224
|
stub) if absent (e.g. a freshly synthesized forest root)."""
|
|
@@ -245,13 +246,15 @@ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
|
|
|
245
246
|
if node.children:
|
|
246
247
|
crumbs = [_CRUMB.format(
|
|
247
248
|
name=skill_name(c), kind=c.kind,
|
|
248
|
-
path=(node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md"
|
|
249
|
+
path=crumb_path(node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md",
|
|
250
|
+
node_dir, localize=localize))
|
|
249
251
|
for c in node.children]
|
|
250
252
|
tail = (f"\n\n## Index summary\n{compose_summary(node, full=True)}\n\n"
|
|
251
253
|
f"## Descend — the next layer ({len(node.children)})\n"
|
|
252
254
|
"Only this layer is loaded now. To descend, use the **Read tool** on a child "
|
|
253
255
|
"below — that injects the child's layer. A Bash `cat` reads the bytes but loads "
|
|
254
|
-
"nothing; you must use the Read tool
|
|
256
|
+
"nothing; you must use the Read tool."
|
|
257
|
+
+ ("" if localize else f" {PORTABLE_NOTE}") + "\n\n" + "\n".join(crumbs))
|
|
255
258
|
else:
|
|
256
259
|
tail = ""
|
|
257
260
|
if node.frameworks: # regenerate the tome table from the model rows
|
|
@@ -260,11 +263,47 @@ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
|
|
|
260
263
|
smd.write_text(out, encoding="utf-8")
|
|
261
264
|
|
|
262
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
|
+
|
|
263
302
|
JOURNAL = ".emit-journal.json" # the reversibility receipt written by a tree-ifying emit
|
|
264
303
|
|
|
265
304
|
|
|
266
305
|
def emit(root: str | Path, *, root_forest: bool = False,
|
|
267
|
-
forest_name: str | None = None) -> dict:
|
|
306
|
+
forest_name: str | None = None, localize: bool = False) -> dict:
|
|
268
307
|
"""Re-cohere the tree IN PLACE: assign coords + rewrite every node's
|
|
269
308
|
breadcrumbs/index from the canonical shape, without destroying content.
|
|
270
309
|
|
|
@@ -272,7 +311,10 @@ def emit(root: str | Path, *, root_forest: bool = False,
|
|
|
272
311
|
a root, and **tree-ify it**: each flat leaf is MOVED WHOLESALE into its nested
|
|
273
312
|
node-dir (all its files preserved — *not* a body-only re-render), so it stops
|
|
274
313
|
auto-loading. Every move is JOURNALED to `.emit-journal.json` so `unemit` can
|
|
275
|
-
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."""
|
|
276
318
|
root = Path(root)
|
|
277
319
|
manifest = root / "skilltree.json"
|
|
278
320
|
synthesized = False
|
|
@@ -311,10 +353,10 @@ def emit(root: str | Path, *, root_forest: bool = False,
|
|
|
311
353
|
journal.append({"op": "move", "from": str(old_src), "to": str(target_dir),
|
|
312
354
|
"skill_md": orig})
|
|
313
355
|
shutil.move(str(old_src), str(target_dir))
|
|
314
|
-
_rewrite_node_md(target_dir / "SKILL.md", node, node_dir)
|
|
356
|
+
_rewrite_node_md(target_dir / "SKILL.md", node, node_dir, localize)
|
|
315
357
|
else:
|
|
316
358
|
existed = (target_dir / "SKILL.md").exists()
|
|
317
|
-
_rewrite_node_md(target_dir / "SKILL.md", node, node_dir)
|
|
359
|
+
_rewrite_node_md(target_dir / "SKILL.md", node, node_dir, localize)
|
|
318
360
|
if synthesized and not existed: # a freshly-created node (the synth root)
|
|
319
361
|
journal.append({"op": "create", "path": str(target_dir)})
|
|
320
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
|
-
|
|
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
|
|
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":
|
|
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).
|
|
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
|
|
@@ -65,6 +65,13 @@ def project(tree_dir: str | Path, target: str | Path, *,
|
|
|
65
65
|
overwrites cleanly (idempotent). Self-clobber-guarded skips return no link.
|
|
66
66
|
"""
|
|
67
67
|
tree_dir = Path(tree_dir)
|
|
68
|
+
# A projected tree is read THROUGH a symlink, where only an absolute breadcrumb resolves —
|
|
69
|
+
# so projecting is an install: localize the tree's breadcrumbs for this machine first.
|
|
70
|
+
# (A committed copy localized this way names this machine until `skilltree emit` is run
|
|
71
|
+
# again without --localize; the publishing gate refuses machine paths.)
|
|
72
|
+
if (tree_dir / "skilltree.json").is_file():
|
|
73
|
+
from .cohere import relink
|
|
74
|
+
relink(tree_dir, localize=True)
|
|
68
75
|
tree = SkillTree.load(manifest or tree_dir / "skilltree.json")
|
|
69
76
|
target = Path(target)
|
|
70
77
|
rel = link == "relative"
|
|
@@ -143,7 +150,8 @@ def build_forest(name: str, members: list[str | Path], *, user_skills_dir: str |
|
|
|
143
150
|
body = (f"Forest **{name}** — {len(members)} skill tree(s). "
|
|
144
151
|
"Pick one and **Read** (the Read tool) into its root to load it, then follow its "
|
|
145
152
|
"breadcrumbs down (`cat` won't load it — only the Read tool does):\n\n" + "\n".join(crumbs))
|
|
146
|
-
|
|
153
|
+
from .materialize import _yaml_scalar as _ys
|
|
154
|
+
(fdir / "SKILL.md").write_text(f"---\nname: {_ys(name)}\ndescription: {_ys(desc)}\n---\n\n{body}\n", encoding="utf-8")
|
|
147
155
|
return _symlink(Path(user_skills_dir) / name, fdir)
|
|
148
156
|
|
|
149
157
|
|
|
@@ -14,12 +14,15 @@ Leaves carry the actual skill content (from skill_src); no further breadcrumbs.
|
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import re
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
import shutil
|
|
19
20
|
|
|
20
|
-
from .model import SkillTree, TreeNode, assign_coords, compose_summary,
|
|
21
|
+
from .model import (PORTABLE_NOTE, SkillTree, TreeNode, assign_coords, compose_summary, crumb_path,
|
|
22
|
+
skill_name, tome_table)
|
|
21
23
|
|
|
22
|
-
# A breadcrumb line is parseable by validate.py: `- <name> (<kind>): Read `<
|
|
24
|
+
# A breadcrumb line is parseable by validate.py: `- <name> (<kind>): Read `<path>`` — the path
|
|
25
|
+
# portable (relative to the node's folder) or localized (absolute); see model.crumb_path.
|
|
23
26
|
# The verb is **Read** (the Read tool), NOT `cat`: only the Read tool injects a dir's
|
|
24
27
|
# .claude layer — a Bash `cat` reads the bytes but loads nothing (verified 2026-06-18).
|
|
25
28
|
_CRUMB = "- {name} ({kind}): Read `{path}`"
|
|
@@ -29,11 +32,24 @@ def node_skill_md(node_dir: Path, node_name: str) -> Path:
|
|
|
29
32
|
return node_dir / ".claude" / "skills" / node_name / "SKILL.md"
|
|
30
33
|
|
|
31
34
|
|
|
35
|
+
def _yaml_scalar(v: str) -> str:
|
|
36
|
+
"""Quote a frontmatter value when bare YAML would misparse it — e.g. a
|
|
37
|
+
coord-prefixed description `[0.8.4] …` reads as a flow sequence and
|
|
38
|
+
strict parsers reject the whole file (issue #1). json.dumps yields a
|
|
39
|
+
valid YAML double-quoted scalar."""
|
|
40
|
+
import json as _json
|
|
41
|
+
if re.match(r"[\[\{&*!|>%@`'\"]", v) or ": " in v or v.startswith("- "):
|
|
42
|
+
return _json.dumps(v)
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
|
|
32
46
|
def _front(name: str, description: str, body: str) -> str:
|
|
33
|
-
return f"---\nname: {name
|
|
47
|
+
return (f"---\nname: {_yaml_scalar(name)}\n"
|
|
48
|
+
f"description: {_yaml_scalar(description)}\n---\n\n"
|
|
49
|
+
f"{body.rstrip()}\n")
|
|
34
50
|
|
|
35
51
|
|
|
36
|
-
def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
|
|
52
|
+
def _write_node(node: TreeNode, node_dir: Path, root: Path, localize: bool = False) -> None:
|
|
37
53
|
sname = skill_name(node) # coord-prefixed identity (or plain name)
|
|
38
54
|
skill_md = node_skill_md(node_dir, sname)
|
|
39
55
|
skill_md.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -54,13 +70,15 @@ def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
|
|
|
54
70
|
desc = compose_summary(node, full=False)
|
|
55
71
|
summary = compose_summary(node, full=True)
|
|
56
72
|
crumbs = [_CRUMB.format(name=skill_name(c), kind=c.kind,
|
|
57
|
-
path=node_skill_md(node_dir / c.name, skill_name(c))
|
|
73
|
+
path=crumb_path(node_skill_md(node_dir / c.name, skill_name(c)),
|
|
74
|
+
node_dir, localize=localize))
|
|
58
75
|
for c in node.children]
|
|
59
76
|
body = (f"{base}\n\n## Index summary\n{summary}\n\n"
|
|
60
77
|
f"## Descend — the next layer ({len(node.children)})\n"
|
|
61
78
|
"Only this layer is loaded now. To descend, use the **Read tool** on a child "
|
|
62
79
|
"below — that injects the child's layer (its persona + skills). A Bash `cat` "
|
|
63
|
-
"reads the bytes but loads nothing; you must use the Read tool
|
|
80
|
+
"reads the bytes but loads nothing; you must use the Read tool."
|
|
81
|
+
+ ("" if localize else f" {PORTABLE_NOTE}") + "\n\n"
|
|
64
82
|
+ "\n".join(crumbs))
|
|
65
83
|
else:
|
|
66
84
|
body = base + "\n\n_(leaf — this is an actual skill.)_"
|
|
@@ -78,17 +96,19 @@ def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
|
|
|
78
96
|
|
|
79
97
|
# recurse: each child's dir is a sibling of this node's .claude (tree-path uses plain name)
|
|
80
98
|
for child in node.children:
|
|
81
|
-
_write_node(child, node_dir / child.name, root)
|
|
99
|
+
_write_node(child, node_dir / child.name, root, localize)
|
|
82
100
|
|
|
83
101
|
|
|
84
102
|
def materialize(tree: SkillTree, root: str | Path, *, overwrite: bool = True,
|
|
85
|
-
coords: bool = False, base: str = "0") -> Path:
|
|
103
|
+
coords: bool = False, base: str = "0", localize: bool = False) -> Path:
|
|
104
|
+
"""Write the tree as nested dirs. Breadcrumbs are PORTABLE by default (relative to each
|
|
105
|
+
node's folder — no machine's path in any file); `localize=True` writes them absolute."""
|
|
86
106
|
root = Path(root)
|
|
87
107
|
if coords:
|
|
88
108
|
assign_coords(tree.root, base) # address every node before writing
|
|
89
109
|
if overwrite and root.exists():
|
|
90
110
|
shutil.rmtree(root)
|
|
91
111
|
root.mkdir(parents=True, exist_ok=True)
|
|
92
|
-
_write_node(tree.root, root, root)
|
|
112
|
+
_write_node(tree.root, root, root, localize) # root node's dir IS the tree root
|
|
93
113
|
tree.save(root / "skilltree.json")
|
|
94
114
|
return root
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
import json
|
|
16
|
+
import os
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
18
19
|
KINDS = ("ac", "cor", "sc", "skill")
|
|
@@ -71,6 +72,21 @@ def compose_summary(node: "TreeNode", *, full: bool = False) -> str:
|
|
|
71
72
|
f"Reachable below: {', '.join(reachable)}.")
|
|
72
73
|
|
|
73
74
|
|
|
75
|
+
# A breadcrumb names its child's SKILL.md one of two ways. PORTABLE (the default): relative to
|
|
76
|
+
# the node's folder — the folder that holds this SKILL.md's `.claude/` — so a tree carries no
|
|
77
|
+
# machine's path and validates wherever it is copied. LOCALIZED: absolute, for a tree that is
|
|
78
|
+
# installed or projected (read through a symlink, where only an absolute path resolves).
|
|
79
|
+
PORTABLE_NOTE = ("Each path below is relative to this node's folder — the folder that holds this "
|
|
80
|
+
"file's `.claude/` — so join it to that folder before you Read it.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def crumb_path(child_md: Path, node_dir: Path, *, localize: bool = False) -> str:
|
|
84
|
+
"""The path a breadcrumb names (see PORTABLE_NOTE)."""
|
|
85
|
+
if localize:
|
|
86
|
+
return str(Path(child_md).resolve())
|
|
87
|
+
return Path(os.path.relpath(os.path.abspath(child_md), os.path.abspath(node_dir))).as_posix()
|
|
88
|
+
|
|
89
|
+
|
|
74
90
|
TOME_HEADING = "## Frameworks"
|
|
75
91
|
|
|
76
92
|
|
|
@@ -138,13 +154,32 @@ class SkillTree:
|
|
|
138
154
|
return SkillTree(dec(data["skilltree"]))
|
|
139
155
|
|
|
140
156
|
def save(self, path: str | Path) -> Path:
|
|
157
|
+
"""Write the manifest. Every `skill_src` is stored RELATIVE to the manifest's own
|
|
158
|
+
folder, so a committed manifest names no machine's path; `load` resolves it back."""
|
|
141
159
|
p = Path(path)
|
|
142
|
-
|
|
160
|
+
data = self.to_manifest()
|
|
161
|
+
base = os.path.abspath(p.parent)
|
|
162
|
+
|
|
163
|
+
def rel(d: dict) -> None:
|
|
164
|
+
s = d.get("skill_src")
|
|
165
|
+
if s and os.path.isabs(s):
|
|
166
|
+
d["skill_src"] = Path(os.path.relpath(os.path.abspath(s), base)).as_posix()
|
|
167
|
+
for c in d.get("children", []):
|
|
168
|
+
rel(c)
|
|
169
|
+
rel(data["skilltree"])
|
|
170
|
+
p.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
143
171
|
return p
|
|
144
172
|
|
|
145
173
|
@staticmethod
|
|
146
174
|
def load(path: str | Path) -> "SkillTree":
|
|
147
|
-
|
|
175
|
+
"""Read a manifest; a relative `skill_src` resolves against the manifest's folder."""
|
|
176
|
+
p = Path(path)
|
|
177
|
+
tree = SkillTree.from_manifest(json.loads(p.read_text(encoding="utf-8")))
|
|
178
|
+
base = os.path.abspath(p.parent)
|
|
179
|
+
for n in tree.root.walk():
|
|
180
|
+
if n.skill_src and not os.path.isabs(n.skill_src):
|
|
181
|
+
n.skill_src = os.path.normpath(os.path.join(base, n.skill_src))
|
|
182
|
+
return tree
|
|
148
183
|
|
|
149
184
|
def nodes(self) -> list[TreeNode]:
|
|
150
185
|
return list(self.root.walk())
|
|
@@ -23,7 +23,8 @@ import re
|
|
|
23
23
|
|
|
24
24
|
from .model import KINDS, TOME_HEADING, SkillTree, TreeNode, skill_name
|
|
25
25
|
|
|
26
|
-
# a breadcrumb is `- <name> (<kind>): Read `<
|
|
26
|
+
# a breadcrumb is `- <name> (<kind>): Read `<path>`` — match the backticked SKILL.md path;
|
|
27
|
+
# the path is relative to the node's folder (portable) or absolute (localized)
|
|
27
28
|
# (verb-agnostic, so it survives wording tweaks; the verb is the Read tool, never `cat`)
|
|
28
29
|
_CRUMB_RE = re.compile(r"`([^`]+/SKILL\.md)`")
|
|
29
30
|
# a tome-table data row: `| name | what-for | name (path) |` (skip header/separator rows)
|
|
@@ -64,7 +65,9 @@ def _walk(node: TreeNode, node_dir: Path, out: list[Violation]) -> None:
|
|
|
64
65
|
|
|
65
66
|
if node.children:
|
|
66
67
|
text = skill_md.read_text(encoding="utf-8")
|
|
67
|
-
|
|
68
|
+
# a portable breadcrumb is relative to the node's folder; a localized one is absolute
|
|
69
|
+
found = {(Path(m) if Path(m).is_absolute() else node_dir / m).resolve()
|
|
70
|
+
for m in _CRUMB_RE.findall(text)}
|
|
68
71
|
expected = {_skill_md(node_dir / c.name, skill_name(c)).resolve() for c in node.children}
|
|
69
72
|
for missing in expected - found:
|
|
70
73
|
out.append(Violation("error", node.name, f"no Read breadcrumb for child → {missing}"))
|
|
@@ -107,7 +107,7 @@ def test_mixed_coord_flat_forest_is_still_a_bare_forest(tmp_path):
|
|
|
107
107
|
def test_emit_is_lossless_and_reversible(tmp_path):
|
|
108
108
|
"""emit moves the WHOLE skill dir (baggage preserved, unlike the old body-only
|
|
109
109
|
re-render) and journals it; unemit replays the journal backwards for an exact
|
|
110
|
-
round-trip. (
|
|
110
|
+
round-trip. (the author's 'if we move it we have to cache what happened' requirement.)"""
|
|
111
111
|
d = tmp_path / "proj"
|
|
112
112
|
cs = d / ".claude" / "skills"
|
|
113
113
|
(cs / "alpha").mkdir(parents=True)
|
|
@@ -35,8 +35,10 @@ def test_build_exchange_master_and_members(tmp_path: Path):
|
|
|
35
35
|
assert exchange_is_valid(repo, ex)
|
|
36
36
|
# master breadcrumbs into each member tree's root
|
|
37
37
|
body = master.read_text()
|
|
38
|
-
|
|
39
|
-
assert
|
|
38
|
+
# portable by default: relative to the master's folder (the repo), no machine path
|
|
39
|
+
assert "trees/cognition/.claude/skills/cognition/SKILL.md" in body
|
|
40
|
+
assert "trees/writing/.claude/skills/writing/SKILL.md" in body
|
|
41
|
+
assert str(tmp_path) not in body
|
|
40
42
|
# each member tree is materialized + valid on its own
|
|
41
43
|
assert node_skill_md(repo / "trees" / "cognition" / "reason", "reason").is_file()
|
|
42
44
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""A tree ships PORTABLE — no machine's path in any file — and is LOCALIZED at install.
|
|
2
|
+
|
|
3
|
+
Portable breadcrumbs are relative to each node's folder; the manifest stores `skill_src`
|
|
4
|
+
relative to itself. A portable tree validates wherever it is copied, with no step at all;
|
|
5
|
+
`localize=True` (materialize / emit) writes absolute breadcrumbs for one machine, and
|
|
6
|
+
projecting a tree (an install read through symlinks) localizes it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from skilltree import SkillTree, TreeNode, materialize, validate
|
|
15
|
+
from skilltree.cohere import emit, relink
|
|
16
|
+
from skilltree.forest import project
|
|
17
|
+
from skilltree.materialize import node_skill_md
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _src(tmp_path: Path, name: str) -> Path:
|
|
21
|
+
d = tmp_path / "src" / name
|
|
22
|
+
d.mkdir(parents=True)
|
|
23
|
+
(d / "SKILL.md").write_text(f"---\nname: {name}\ndescription: the {name} skill\n---\n\nbody of {name}\n")
|
|
24
|
+
return d
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _tree(tmp_path: Path) -> SkillTree:
|
|
28
|
+
return SkillTree(TreeNode("root", "sc", description="the root", children=[
|
|
29
|
+
TreeNode("a", "cor", description="branch a", children=[
|
|
30
|
+
TreeNode("a1", "ac", skill_src=str(_src(tmp_path, "a1")))]),
|
|
31
|
+
TreeNode("b", "skill", skill_src=str(_src(tmp_path, "b")))]))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _all_text(root: Path) -> str:
|
|
35
|
+
return "\n".join(p.read_text(encoding="utf-8") for p in root.rglob("*")
|
|
36
|
+
if p.is_file() and p.suffix in (".md", ".json"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _errors(root: Path) -> list:
|
|
40
|
+
return [v for v in validate(root) if v.severity == "error"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_default_tree_names_no_machine_path_and_validates(tmp_path: Path):
|
|
44
|
+
root = tmp_path / "tree"
|
|
45
|
+
materialize(_tree(tmp_path), root, coords=True)
|
|
46
|
+
assert str(tmp_path) not in _all_text(root) # no absolute path anywhere
|
|
47
|
+
assert _errors(root) == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_portable_tree_validates_after_a_move_with_no_step(tmp_path: Path):
|
|
51
|
+
root = tmp_path / "tree"
|
|
52
|
+
materialize(_tree(tmp_path), root, coords=True)
|
|
53
|
+
moved = tmp_path / "elsewhere" / "deep" / "tree"
|
|
54
|
+
shutil.copytree(root, moved)
|
|
55
|
+
shutil.rmtree(root) # the original place is gone
|
|
56
|
+
assert _errors(moved) == []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_manifest_stores_skill_src_relative_and_load_resolves_it(tmp_path: Path):
|
|
60
|
+
root = tmp_path / "tree"
|
|
61
|
+
materialize(_tree(tmp_path), root)
|
|
62
|
+
raw = json.loads((root / "skilltree.json").read_text())
|
|
63
|
+
leaf = raw["skilltree"]["children"][1]
|
|
64
|
+
assert not Path(leaf["skill_src"]).is_absolute()
|
|
65
|
+
loaded = SkillTree.load(root / "skilltree.json")
|
|
66
|
+
assert Path(loaded.root.children[1].skill_src).resolve() == (tmp_path / "src" / "b").resolve()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_localize_writes_absolute_and_still_validates(tmp_path: Path):
|
|
70
|
+
root = tmp_path / "tree"
|
|
71
|
+
materialize(_tree(tmp_path), root, localize=True)
|
|
72
|
+
body = node_skill_md(root, "root").read_text()
|
|
73
|
+
assert str(node_skill_md(root / "a", "a").resolve()) in body
|
|
74
|
+
assert _errors(root) == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_emit_switches_between_forms(tmp_path: Path):
|
|
78
|
+
root = tmp_path / "tree"
|
|
79
|
+
materialize(_tree(tmp_path), root, coords=True)
|
|
80
|
+
assert emit(root, localize=True)["ok"]
|
|
81
|
+
assert str(root.resolve()) in node_skill_md(root, "0-root").read_text()
|
|
82
|
+
assert _errors(root) == []
|
|
83
|
+
assert emit(root)["ok"] # default: portable again
|
|
84
|
+
assert str(tmp_path) not in _all_text(root)
|
|
85
|
+
assert _errors(root) == []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_project_localizes_the_projected_tree(tmp_path: Path):
|
|
89
|
+
root = tmp_path / "tree"
|
|
90
|
+
materialize(_tree(tmp_path), root, coords=True)
|
|
91
|
+
skills = tmp_path / "user" / "skills"
|
|
92
|
+
links = project(root, skills, policy="progressive")
|
|
93
|
+
assert links and all(p.exists() for p in links)
|
|
94
|
+
seen = (skills / "0-root" / "SKILL.md").read_text() # read THROUGH the symlink
|
|
95
|
+
crumb = str(node_skill_md(root / "a", "0.1-a").resolve())
|
|
96
|
+
assert crumb in seen and Path(crumb).is_file()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_relink_switches_paths_and_touches_nothing_else(tmp_path: Path):
|
|
100
|
+
root = tmp_path / "tree"
|
|
101
|
+
materialize(_tree(tmp_path), root) # no coords: names must stay plain
|
|
102
|
+
before = sorted(str(p.relative_to(root)) for p in root.rglob("SKILL.md"))
|
|
103
|
+
assert relink(root, localize=True)["ok"]
|
|
104
|
+
assert str(root.resolve()) in node_skill_md(root, "root").read_text()
|
|
105
|
+
assert _errors(root) == []
|
|
106
|
+
assert relink(root)["ok"]
|
|
107
|
+
assert str(tmp_path) not in _all_text(root)
|
|
108
|
+
assert sorted(str(p.relative_to(root)) for p in root.rglob("SKILL.md")) == before
|
|
109
|
+
assert _errors(root) == []
|
|
@@ -30,8 +30,10 @@ def test_root_body_has_cat_breadcrumbs_for_each_child(tmp_path: Path):
|
|
|
30
30
|
materialize(_tree(), root)
|
|
31
31
|
body = node_skill_md(root, "cc-skill-tree").read_text()
|
|
32
32
|
assert "cat" in body
|
|
33
|
-
|
|
34
|
-
assert
|
|
33
|
+
# portable by default: each child's path relative to this node's folder, no machine path
|
|
34
|
+
assert "Read `a/.claude/skills/a/SKILL.md`" in body
|
|
35
|
+
assert "Read `b/.claude/skills/b/SKILL.md`" in body
|
|
36
|
+
assert str(tmp_path) not in body
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
def test_dead_breadcrumb_is_caught(tmp_path: Path):
|
|
File without changes
|
|
File without changes
|
{agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|