agent-skilltree 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ """P5.4 — walk the federation: a tree of marketplaces under a root.
2
+
3
+ A child marketplace joins by contributing a `registry`-kind entry to its parent's
4
+ registry.json (pointing at the child's own registry.json). Federation is then a
5
+ walk: from the root registry, resolve each `registry` entry to the child's
6
+ registry and recurse — a tree of marketplaces mirroring the tree of repos.
7
+
8
+ `resolve` is pluggable so this works offline: it maps a `registry` entry to a
9
+ registry dict (a local map for tests; a fetch-by-repo-ref for the real network).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import datetime as _dt
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+ from .registry import load_registry, validate_registry
19
+
20
+ Resolver = Callable[[dict], "dict | None"]
21
+
22
+
23
+ def local_resolver(name_to_registry: dict[str, dict]) -> Resolver:
24
+ """Resolve a `registry` entry to a child registry from an in-memory/local map."""
25
+ def _resolve(entry: dict) -> dict | None:
26
+ target = name_to_registry.get(entry["name"]) or name_to_registry.get(entry.get("repo", ""))
27
+ if isinstance(target, (str, Path)):
28
+ return load_registry(target)
29
+ return target
30
+ return _resolve
31
+
32
+
33
+ def _children(registry: dict) -> list[dict]:
34
+ return [e for e in registry.get("entries", []) if e.get("kind") == "registry"]
35
+
36
+
37
+ def walk_federation(registry: dict, resolve: Resolver, *, _seen: frozenset[str] | None = None) -> dict:
38
+ """Return the nested federation tree rooted at `registry`."""
39
+ _seen = _seen or frozenset()
40
+ name = registry.get("name")
41
+ node = {
42
+ "name": name,
43
+ "entries": [e for e in registry.get("entries", []) if e.get("kind") != "registry"],
44
+ "children": [],
45
+ }
46
+ if name in _seen: # cycle guard
47
+ node["cycle"] = True
48
+ return node
49
+ seen = _seen | {name}
50
+ for entry in _children(registry):
51
+ child = resolve(entry)
52
+ if child is None:
53
+ node["children"].append({"name": entry["name"], "unresolved": True})
54
+ else:
55
+ node["children"].append(walk_federation(child, resolve, _seen=seen))
56
+ return node
57
+
58
+
59
+ def flatten_federation(registry: dict, resolve: Resolver) -> list[dict]:
60
+ """Every leaf entry across the federation, tagged with the registry path it came through."""
61
+ out: list[dict] = []
62
+
63
+ def rec(node: dict, path: tuple[str, ...]) -> None:
64
+ here = path + (node["name"],)
65
+ for e in node.get("entries", []):
66
+ out.append({**e, "_path": list(here)})
67
+ for c in node.get("children", []):
68
+ if not c.get("unresolved") and not c.get("cycle"):
69
+ rec(c, here)
70
+ rec(walk_federation(registry, resolve), ())
71
+ return out
72
+
73
+
74
+ def validate_federation(registry: dict, resolve: Resolver) -> list[str]:
75
+ """Check the federation: child schemas valid, parent backrefs consistent, no cycles."""
76
+ errs: list[str] = []
77
+
78
+ def rec(reg: dict, seen: frozenset[str]) -> None:
79
+ errs.extend(f"{reg.get('name')}: {e}" for e in validate_registry(reg))
80
+ if reg.get("name") in seen:
81
+ errs.append(f"federation cycle at {reg.get('name')!r}")
82
+ return
83
+ s = seen | {reg.get("name")}
84
+ for entry in _children(reg):
85
+ child = resolve(entry)
86
+ if child is None:
87
+ errs.append(f"{reg.get('name')}: unresolved child registry {entry['name']!r}")
88
+ continue
89
+ if child.get("parent") not in (None, reg.get("name")) and "github" not in str(child.get("parent", "")):
90
+ errs.append(f"{child.get('name')!r}: parent backref {child.get('parent')!r} "
91
+ f"does not match {reg.get('name')!r}")
92
+ rec(child, s)
93
+ rec(registry, frozenset())
94
+ return errs
95
+
96
+
97
+ def register_child(parent_path: str | Path, name: str, repo: str, *,
98
+ manifest: str = "registry.json", by: str = "maintainer",
99
+ now: str | None = None) -> dict:
100
+ """Add a `registry`-kind child to a parent registry (federation link, unverified)."""
101
+ p = Path(parent_path)
102
+ data = load_registry(p)
103
+ data.setdefault("entries", []).append({
104
+ "name": name, "kind": "registry", "repo": repo, "manifest": manifest,
105
+ "version": "0.1.0", "trust": "unverified",
106
+ "provenance": {"by": by, "at": now or _dt.datetime.now().isoformat(timespec="seconds")},
107
+ })
108
+ p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
109
+ return data
skilltree/forest.py ADDED
@@ -0,0 +1,101 @@
1
+ """Make a SkillTree (or a forest of them) visible from the TOP — `~/.claude/skills`.
2
+
3
+ The top-level user `.claude/skills` is scanned in every session, but the scan is
4
+ non-recursive WITHIN a `.claude/skills` (a skill can't contain auto-loadable
5
+ sub-skills). So to surface a tree from the top you SYMLINK its entry skill dirs
6
+ into `~/.claude/skills`, and the `cat`-breadcrumbs carry you down from there.
7
+
8
+ - single tree → `link_tree`: symlink the root (and its first-layer branches) up
9
+ - many trees → `build_forest`: one forest-root skill that breadcrumbs to each
10
+ tree's root, symlinked up — a forest view over the top.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from .model import SkillTree, skill_name
17
+
18
+ _CRUMB = "- {name} ({kind}): Read `{path}`" # the Read TOOL injects the layer, not `cat`
19
+
20
+
21
+ def _skill_dir(node_dir: Path, name: str) -> Path:
22
+ return Path(node_dir) / ".claude" / "skills" / name
23
+
24
+
25
+ def _symlink(link: Path, target: Path) -> Path:
26
+ link.parent.mkdir(parents=True, exist_ok=True)
27
+ if link.is_symlink() or link.exists():
28
+ link.unlink()
29
+ link.symlink_to(Path(target).resolve(), target_is_directory=True)
30
+ return link
31
+
32
+
33
+ def link_tree(tree_dir: str | Path, *, user_skills_dir: str | Path,
34
+ manifest: str | Path | None = None,
35
+ include_root: bool = True, branches: bool = True) -> list[Path]:
36
+ """Symlink a single tree's entry points into the top-level user skills dir.
37
+
38
+ The root gives the overview + breadcrumbs; the branches (root's direct
39
+ children) are surfaced too so the first layer is reachable from the top.
40
+ Deeper levels stay behind `cat` (progressive disclosure).
41
+ """
42
+ tree_dir = Path(tree_dir)
43
+ tree = SkillTree.load(manifest or tree_dir / "skilltree.json")
44
+ user = Path(user_skills_dir)
45
+ links: list[Path] = []
46
+ if include_root:
47
+ rn = skill_name(tree.root)
48
+ links.append(_symlink(user / rn, _skill_dir(tree_dir, rn)))
49
+ if branches:
50
+ for child in tree.root.children:
51
+ cn = skill_name(child)
52
+ links.append(_symlink(user / cn, _skill_dir(tree_dir / child.name, cn)))
53
+ return links
54
+
55
+
56
+ def build_forest(name: str, members: list[str | Path], *, user_skills_dir: str | Path,
57
+ forest_dir: str | Path, description: str | None = None) -> Path:
58
+ """Build a forest-root skill over several trees and symlink it to the top.
59
+
60
+ `members` = tree dirs (each with a skilltree.json). The forest root's body
61
+ breadcrumbs to each tree's root; one top-level entry, progressive descent.
62
+ """
63
+ fdir = _skill_dir(Path(forest_dir), name)
64
+ fdir.mkdir(parents=True, exist_ok=True)
65
+ crumbs, roots = [], []
66
+ for tdir in members:
67
+ tdir = Path(tdir)
68
+ tree = SkillTree.load(tdir / "skilltree.json")
69
+ rn = skill_name(tree.root)
70
+ md = _skill_dir(tdir, rn) / "SKILL.md"
71
+ crumbs.append(_CRUMB.format(name=rn, kind=tree.root.kind, path=md.resolve()))
72
+ roots.append(rn)
73
+ desc = description or f"Forest of {len(members)} skill tree(s): {', '.join(roots)}."
74
+ body = (f"Forest **{name}** — {len(members)} skill tree(s). "
75
+ "Pick one and **Read** (the Read tool) into its root to load it, then follow its "
76
+ "breadcrumbs down (`cat` won't load it — only the Read tool does):\n\n" + "\n".join(crumbs))
77
+ (fdir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {desc}\n---\n\n{body}\n", encoding="utf-8")
78
+ return _symlink(Path(user_skills_dir) / name, fdir)
79
+
80
+
81
+ def list_links(user_skills_dir: str | Path) -> list[dict]:
82
+ """List symlinks in the user skills dir and whether they still resolve."""
83
+ user = Path(user_skills_dir)
84
+ out: list[dict] = []
85
+ if user.exists():
86
+ for p in sorted(user.iterdir()):
87
+ if p.is_symlink():
88
+ out.append({"name": p.name, "target": str(p.readlink()), "resolves": p.exists()})
89
+ return out
90
+
91
+
92
+ def unlink(user_skills_dir: str | Path, *names: str) -> list[str]:
93
+ """Remove named symlinks (only if they are symlinks — never deletes real dirs)."""
94
+ user = Path(user_skills_dir)
95
+ removed: list[str] = []
96
+ for n in names:
97
+ p = user / n
98
+ if p.is_symlink():
99
+ p.unlink()
100
+ removed.append(n)
101
+ return removed
skilltree/mapper.py ADDED
@@ -0,0 +1,117 @@
1
+ """skilltree map — render a flat folder into ONE coordinate-addressed CLAUDE.md.
2
+
3
+ Folds the former `skillmap` seedling into skilltree as a module. It mirrors a folder's
4
+ directory nesting as a tree, gives every node a hierarchical coordinate, and writes a single
5
+ CLAUDE.md — a Folder Map + an addressable Index + branch summaries — so an agent can do
6
+ *progressive disclosure* over a flat pile (open a branch, descend to the coordinate it needs)
7
+ instead of loading all of it.
8
+
9
+ It uses skilltree's OWN coordinate code — `assign_coords` / `skill_name` / `compose_summary`
10
+ from `model.py`. There is no vendored copy: one source of truth for the `<coord>-<name>` scheme.
11
+
12
+ skilltree map ~/.claude/skills # print the map
13
+ skilltree map ~/.claude/skills --write # write CLAUDE.md into the folder
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+
20
+ from .model import TreeNode, assign_coords, compose_summary, skill_name
21
+
22
+ _DESC = re.compile(r"^description:\s*(.+)$", re.I | re.M)
23
+
24
+
25
+ def read_description(d: Path) -> str:
26
+ """Best-effort one-line description for a directory node: the `description:` frontmatter of
27
+ SKILL.md/README, else the first real line/heading of any markdown in the dir."""
28
+ for fn in ("SKILL.md", "README.md"):
29
+ f = d / fn
30
+ if f.is_file():
31
+ txt = f.read_text(encoding="utf-8", errors="ignore")
32
+ m = _DESC.search(txt)
33
+ if m:
34
+ return m.group(1).strip().strip("\"'")[:160]
35
+ for line in txt.splitlines():
36
+ s = line.strip()
37
+ if s and not s.startswith("---"):
38
+ return s.lstrip("# ").strip()[:160]
39
+ for f in sorted(d.glob("*.md")):
40
+ for line in f.read_text(encoding="utf-8", errors="ignore").splitlines():
41
+ s = line.strip()
42
+ if s and not s.startswith("---"):
43
+ return s.lstrip("# ").strip()[:160]
44
+ return ""
45
+
46
+
47
+ def build_tree(folder: str | Path) -> TreeNode:
48
+ """Mirror the directory nesting as skilltree `TreeNode`s. A node = a subdirectory; the dir
49
+ path rides in `skill_src`. Hidden dirs (dotfiles) are skipped."""
50
+ root_path = Path(folder)
51
+ root = TreeNode(name=root_path.name, kind="sc", description="(root)", skill_src=str(root_path))
52
+
53
+ def add(parent: TreeNode) -> None:
54
+ for d in sorted(p for p in Path(parent.skill_src).iterdir()
55
+ if p.is_dir() and not p.name.startswith(".")):
56
+ child = TreeNode(name=d.name, kind="skill",
57
+ description=read_description(d) or None, skill_src=str(d))
58
+ parent.children.append(child)
59
+ add(child)
60
+
61
+ add(root)
62
+ return root
63
+
64
+
65
+ def _folder_map(root: TreeNode) -> str:
66
+ lines = [f"{root.coord} {root.name}/"]
67
+
68
+ def rec(node: TreeNode, prefix: str) -> None:
69
+ n = len(node.children)
70
+ for i, c in enumerate(node.children):
71
+ last = i == n - 1
72
+ branch = "└─ " if last else "├─ "
73
+ desc = f" — {c.description}" if c.description else ""
74
+ lines.append(f"{prefix}{branch}{c.coord} {c.name}{desc}")
75
+ rec(c, prefix + (" " if last else "│ "))
76
+
77
+ rec(root, "")
78
+ return "\n".join(lines)
79
+
80
+
81
+ def _index(root: TreeNode) -> str:
82
+ rp = Path(root.skill_src)
83
+ rows = ["| Coord | Skill | Address | What it does | Path |", "|---|---|---|---|---|"]
84
+ for n in root.walk():
85
+ if n is root:
86
+ continue
87
+ rel = Path(n.skill_src).relative_to(rp)
88
+ rows.append(f"| `{n.coord}` | {n.name} | `{skill_name(n)}` | {n.description or '—'} | `{rel}/` |")
89
+ return "\n".join(rows)
90
+
91
+
92
+ def _branches(root: TreeNode) -> str:
93
+ rows = [f"- `{n.coord}` **{n.name}** → {compose_summary(n)}"
94
+ for n in root.walk() if n.children]
95
+ return "\n".join(rows) or "_(flat — every skill is a leaf at depth 1)_"
96
+
97
+
98
+ def build_map(folder: str | Path) -> str:
99
+ """Render the folder as ONE coordinate-addressed CLAUDE.md (Folder Map + Index + Branches)."""
100
+ root = assign_coords(build_tree(folder))
101
+ nodes = list(root.walk())
102
+ n_skills = len(nodes) - 1
103
+ depth = max((n.coord.count(".") for n in nodes), default=0)
104
+ return (f"# {root.name} — skill map\n\n"
105
+ f"*Auto-generated by `skilltree map`. {n_skills} skills, depth {depth}. Each skill has a "
106
+ f"coordinate address — open a branch, descend to the coordinate you need. Don't load the "
107
+ f"whole pile; navigate it.*\n\n"
108
+ f"## Folder Map\n\n```\n{_folder_map(root)}\n```\n\n"
109
+ f"## Index (addressable)\n\n{_index(root)}\n\n"
110
+ f"## Branches (what each opens to)\n\n{_branches(root)}\n")
111
+
112
+
113
+ def write_map(folder: str | Path) -> Path:
114
+ """Write the map to `<folder>/CLAUDE.md`; returns the path."""
115
+ dest = Path(folder) / "CLAUDE.md"
116
+ dest.write_text(build_map(folder), encoding="utf-8")
117
+ return dest
@@ -0,0 +1,86 @@
1
+ """Marketplace — a programmatic registry for skills, trees, and exchanges.
2
+
3
+ The real, local half of the roadmap's marketplace: you `publish()` an artifact to
4
+ a registry (a JSON file), mark it `public` if you want, and `search()` it. The
5
+ registry GROWS as artifacts are added. Whenever something is published public, a
6
+ `notify` hook fires — that hook is where a hosted service ("our system") plugs in.
7
+
8
+ Aspirational (needs a backend, not built here): the hosted marketplace endpoint
9
+ the notify hook would POST to, and cross-user discovery.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import asdict, dataclass, field
14
+ import datetime as _dt
15
+ import json
16
+ from pathlib import Path
17
+ from typing import Callable
18
+
19
+ KINDS = ("skill", "tree", "exchange")
20
+
21
+
22
+ @dataclass
23
+ class Entry:
24
+ name: str
25
+ kind: str
26
+ path: str
27
+ version: str = "0.1.0"
28
+ public: bool = False
29
+ published_at: str = ""
30
+ tags: list[str] = field(default_factory=list)
31
+
32
+
33
+ def _load(registry: Path) -> list[dict]:
34
+ return json.loads(registry.read_text(encoding="utf-8")) if registry.is_file() else []
35
+
36
+
37
+ def _save(registry: Path, rows: list[dict]) -> None:
38
+ registry.parent.mkdir(parents=True, exist_ok=True)
39
+ registry.write_text(json.dumps(rows, indent=2), encoding="utf-8")
40
+
41
+
42
+ def publish(
43
+ registry: str | Path,
44
+ name: str,
45
+ path: str | Path,
46
+ *,
47
+ kind: str = "skill",
48
+ public: bool = False,
49
+ version: str = "0.1.0",
50
+ tags: list[str] | None = None,
51
+ notify: Callable[[Entry], None] | None = None,
52
+ now: str | None = None,
53
+ ) -> Entry:
54
+ """Add (or update) an artifact in the registry. Fires `notify` if it's public."""
55
+ if kind not in KINDS:
56
+ raise ValueError(f"kind must be one of {KINDS}, got {kind!r}")
57
+ registry = Path(registry)
58
+ entry = Entry(name=name, kind=kind, path=str(Path(path)), version=version, public=public,
59
+ published_at=now or _dt.datetime.now().isoformat(timespec="seconds"),
60
+ tags=tags or [])
61
+ rows = [r for r in _load(registry) if r.get("name") != name] # upsert by name
62
+ rows.append(asdict(entry))
63
+ _save(registry, rows)
64
+ if public and notify is not None:
65
+ notify(entry) # ← where the hosted "notify our system" service plugs in
66
+ return entry
67
+
68
+
69
+ def search(registry: str | Path, query: str | None = None, *, public_only: bool = False) -> list[dict]:
70
+ rows = _load(Path(registry))
71
+ if public_only:
72
+ rows = [r for r in rows if r.get("public")]
73
+ if query:
74
+ q = query.lower()
75
+ rows = [r for r in rows if q in r["name"].lower() or any(q in t.lower() for t in r.get("tags", []))]
76
+ return rows
77
+
78
+
79
+ def log_notify(log_path: str | Path) -> Callable[[Entry], None]:
80
+ """A local stand-in for the hosted notifier: append public publishes to a log."""
81
+ def _notify(entry: Entry) -> None:
82
+ p = Path(log_path)
83
+ p.parent.mkdir(parents=True, exist_ok=True)
84
+ with p.open("a", encoding="utf-8") as fh:
85
+ fh.write(json.dumps(asdict(entry)) + "\n")
86
+ return _notify
@@ -0,0 +1,88 @@
1
+ """Materialize a SkillTree as nested dirs wired by `cat`-breadcrumbs.
2
+
3
+ The auto-loader only ever loads the ROOT (it won't descend a nested `.claude`).
4
+ So each node's SKILL.md body carries the `cat` commands that tell you how to read
5
+ its children — the breadcrumb links. The tree is nested dirs; traversal is manual
6
+ `cat` following the breadcrumbs. Layout for a node N with children C1..Ck:
7
+
8
+ <N_dir>/.claude/skills/<N>/SKILL.md # N's skill + `cat` links to each child
9
+ <N_dir>/<C1>/.claude/skills/<C1>/SKILL.md # child dir is a SIBLING of N's .claude
10
+ <N_dir>/<C2>/...
11
+
12
+ The root node's dir IS the tree root; root skill at <root>/.claude/skills/<root>/.
13
+ Leaves carry the actual skill content (from skill_src); no further breadcrumbs.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ import shutil
19
+
20
+ from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name
21
+
22
+ # A breadcrumb line is parseable by validate.py: `- <name> (<kind>): Read `<abspath>``
23
+ # The verb is **Read** (the Read tool), NOT `cat`: only the Read tool injects a dir's
24
+ # .claude layer — a Bash `cat` reads the bytes but loads nothing (verified 2026-06-18).
25
+ _CRUMB = "- {name} ({kind}): Read `{path}`"
26
+
27
+
28
+ def node_skill_md(node_dir: Path, node_name: str) -> Path:
29
+ return node_dir / ".claude" / "skills" / node_name / "SKILL.md"
30
+
31
+
32
+ def _front(name: str, description: str, body: str) -> str:
33
+ return f"---\nname: {name}\ndescription: {description}\n---\n\n{body.rstrip()}\n"
34
+
35
+
36
+ def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
37
+ sname = skill_name(node) # coord-prefixed identity (or plain name)
38
+ skill_md = node_skill_md(node_dir, sname)
39
+ skill_md.parent.mkdir(parents=True, exist_ok=True)
40
+
41
+ # base body: the node's own skill content (from src) or a stub
42
+ if node.skill_src and (Path(node.skill_src) / "SKILL.md").exists():
43
+ src = (Path(node.skill_src) / "SKILL.md").read_text(encoding="utf-8")
44
+ # strip frontmatter from the source body; we re-emit our own
45
+ base = src.split("---", 2)[-1].strip() if src.lstrip().startswith("---") else src.strip()
46
+ desc = node.description or f"{node.kind} node {node.name}"
47
+ else:
48
+ base = f"SkillTree {node.kind} node `{node.name}`."
49
+ desc = node.description or f"{node.kind} node {node.name} in a SkillTree."
50
+ if node.children:
51
+ # index (branch) node: a deterministic subtree summary makes it retrievable
52
+ # by any descendant's terms (the RAPTOR win, no LLM).
53
+ if not node.description:
54
+ desc = compose_summary(node, full=False)
55
+ summary = compose_summary(node, full=True)
56
+ crumbs = [_CRUMB.format(name=skill_name(c), kind=c.kind,
57
+ path=node_skill_md(node_dir / c.name, skill_name(c)).resolve())
58
+ for c in node.children]
59
+ body = (f"{base}\n\n## Index summary\n{summary}\n\n"
60
+ f"## Descend — the next layer ({len(node.children)})\n"
61
+ "Only this layer is loaded now. To descend, use the **Read tool** on a child "
62
+ "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:\n\n"
64
+ + "\n".join(crumbs))
65
+ else:
66
+ body = base + "\n\n_(leaf — this is an actual skill.)_"
67
+
68
+ if node.coord:
69
+ desc = f"[{node.coord}] {desc}"
70
+
71
+ skill_md.write_text(_front(sname, desc, body), encoding="utf-8")
72
+
73
+ # recurse: each child's dir is a sibling of this node's .claude (tree-path uses plain name)
74
+ for child in node.children:
75
+ _write_node(child, node_dir / child.name, root)
76
+
77
+
78
+ def materialize(tree: SkillTree, root: str | Path, *, overwrite: bool = True,
79
+ coords: bool = False, base: str = "0") -> Path:
80
+ root = Path(root)
81
+ if coords:
82
+ assign_coords(tree.root, base) # address every node before writing
83
+ if overwrite and root.exists():
84
+ shutil.rmtree(root)
85
+ root.mkdir(parents=True, exist_ok=True)
86
+ _write_node(tree.root, root, root) # root node's dir IS the tree root
87
+ tree.save(root / "skilltree.json")
88
+ return root
skilltree/model.py ADDED
@@ -0,0 +1,114 @@
1
+ """SkillTree model — a tree of skill dirs wired by LINKS, not nesting.
2
+
3
+ Every node is a skill dir (`<name>/SKILL.md`) of any type (ac/cor/sc/skill).
4
+ Edges are links: each node lives in its OWN `.claude/skills/` root, and a node's
5
+ direct children are symlinked into that root. Auto-load reaches a root + its
6
+ direct children only — never deeper (a skill dir won't auto-load a nested
7
+ `.claude`). To descend, you REDIRECT the active skills root to the child's root.
8
+
9
+ Because the platform won't traverse this, the tree's integrity is not guaranteed
10
+ by the filesystem — it must be validated programmatically (see validate.py).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ import json
16
+ from pathlib import Path
17
+
18
+ KINDS = ("ac", "cor", "sc", "skill")
19
+
20
+
21
+ @dataclass
22
+ class TreeNode:
23
+ name: str # slug; also the tree-path dir name
24
+ kind: str = "skill" # ac | cor | sc | skill
25
+ skill_src: str | None = None # existing `<name>/SKILL.md` dir to carry as content (else a stub)
26
+ description: str | None = None # SKILL.md description (so it auto-loads / is searchable)
27
+ coord: str | None = None # hierarchical address (e.g. "0.1.2"); set by assign_coords
28
+ children: list["TreeNode"] = field(default_factory=list)
29
+
30
+ def walk(self):
31
+ yield self
32
+ for child in self.children:
33
+ yield from child.walk()
34
+
35
+ def edges(self):
36
+ for child in self.children:
37
+ yield (self.name, child.name)
38
+ yield from child.edges()
39
+
40
+
41
+ def skill_name(node: "TreeNode") -> str:
42
+ """The skill's identity (frontmatter name + skill-dir name).
43
+
44
+ With a coord, it is `<coord>-<name>` — so the flat skills list is coord-sorted,
45
+ reveals the tree, and each node is addressable by its coordinate. The tree-PATH
46
+ dir stays the plain `name`; only the skill identity carries the coord.
47
+ """
48
+ return f"{node.coord}-{node.name}" if node.coord else node.name
49
+
50
+
51
+ def compose_summary(node: "TreeNode", *, full: bool = False) -> str:
52
+ """A DETERMINISTIC semantic summary of an index (branch) node — a template
53
+ filled with its coordinate, children, and reachable descendants.
54
+
55
+ This is the RAPTOR "internal nodes carry a summary" retrieval-win, gotten for
56
+ free (no LLM): the branch's body becomes dense with its subtree's vocabulary,
57
+ so a query about any descendant matches the branch that leads to it. `full`
58
+ adds the flattened reachable-leaf list (for the indexed body); the short form
59
+ (for the description) lists just the immediate children.
60
+ """
61
+ kids = ", ".join(f"{c.name} ({c.kind})" for c in node.children) or "(none)"
62
+ short = f"opens to {len(node.children)} branch(es): {kids}"
63
+ if not full:
64
+ return short
65
+ reachable = [d.name for d in node.walk() if d is not node]
66
+ return (f"[{node.coord or '?'}] {node.name} — a {node.kind} index node; {short}. "
67
+ f"Reachable below: {', '.join(reachable)}.")
68
+
69
+
70
+ def assign_coords(root: "TreeNode", base: str = "0") -> "TreeNode":
71
+ """Give every node a hierarchical coordinate: root=base, child i (1-based)=parent.i."""
72
+ def walk(n: "TreeNode", coord: str) -> None:
73
+ n.coord = coord
74
+ for i, child in enumerate(n.children, 1):
75
+ walk(child, f"{coord}.{i}")
76
+ walk(root, base)
77
+ return root
78
+
79
+
80
+ @dataclass
81
+ class SkillTree:
82
+ root: TreeNode
83
+
84
+ # ---- manifest (source of truth for validation) ----
85
+ def to_manifest(self) -> dict:
86
+ def enc(n: TreeNode) -> dict:
87
+ return {"name": n.name, "kind": n.kind, "skill_src": n.skill_src,
88
+ "description": n.description, "coord": n.coord,
89
+ "children": [enc(c) for c in n.children]}
90
+ return {"skilltree": enc(self.root)}
91
+
92
+ @staticmethod
93
+ def from_manifest(data: dict) -> "SkillTree":
94
+ def dec(d: dict) -> TreeNode:
95
+ return TreeNode(name=d["name"], kind=d.get("kind", "skill"),
96
+ skill_src=d.get("skill_src"), description=d.get("description"),
97
+ coord=d.get("coord"),
98
+ children=[dec(c) for c in d.get("children", [])])
99
+ return SkillTree(dec(data["skilltree"]))
100
+
101
+ def save(self, path: str | Path) -> Path:
102
+ p = Path(path)
103
+ p.write_text(json.dumps(self.to_manifest(), indent=2), encoding="utf-8")
104
+ return p
105
+
106
+ @staticmethod
107
+ def load(path: str | Path) -> "SkillTree":
108
+ return SkillTree.from_manifest(json.loads(Path(path).read_text(encoding="utf-8")))
109
+
110
+ def nodes(self) -> list[TreeNode]:
111
+ return list(self.root.walk())
112
+
113
+ def edges(self) -> list[tuple[str, str]]:
114
+ return list(self.root.edges())