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,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-skilltree
3
+ Version: 0.2.0
4
+ Summary: A coordinate-addressed tree of agent skill dirs wired by cat/Read breadcrumbs, with programmatic validation.
5
+ Author: Isaac Wostrel-Rubin
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sancovp/skilltree
8
+ Keywords: skills,agents,skill-tree,claude,progressive-disclosure,coordinate-addressing
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: click
13
+ Dynamic: license-file
14
+
15
+ # skilltree
16
+
17
+ Turn a **flat** folder of skills (or any `.claude` directory) into a **navigable tree** that a model can walk one layer at a time — and keep it coherent.
18
+
19
+ Claude Code auto-loads `~/.claude/skills` (and a project's `.claude/skills`) **one layer deep**: every skill directory loads at once, and a `.claude` nested inside another `.claude` never loads. At a handful of skills that is fine; at a hundred it is *melt* — the whole pile lands in context every turn, and tool selection degrades as the set grows. The substrate ships the nodes and forbids the edge.
20
+
21
+ `skilltree` imposes the missing structure on that flat substrate, using the only three levers the platform leaves open:
22
+
23
+ 1. **placement** — each node is a plain directory carrying its own one-skill `.claude/`; the path *is* the coordinate (`0` → `0.1` → `0.1.1`);
24
+ 2. **the inert-nested-`.claude` boundary** — only the top layer auto-loads, so deeper nodes stay out of context until entered;
25
+ 3. **breadcrumbs** — each node's `SKILL.md` ends with an index summary of its subtree and explicit instructions to **Read** its children.
26
+
27
+ You load one layer and *walk*; you never load the pile.
28
+
29
+ ## The load mechanism it exploits (verified against the runtime)
30
+
31
+ The design rests on one fact about Claude Code, which `skilltree` verifies rather than assumes:
32
+
33
+ > Your context + Skill-tool menu = `~/.claude` (always, one layer) **+ every project directory you _Read into_** (its `CLAUDE.md` + `.claude/rules` + `.claude/skills`, dynamically, one layer). Descendants don't load until Read; out-of-project directories don't trigger it; **the trigger is the Read tool, not `cat`** (a shell `cat` of the same file injects nothing).
34
+
35
+ So descending the tree is a **Read into the next node** — which loads exactly that node's layer and no more. That is why the breadcrumbs say **Read**, not `cat`: a `cat` reads the bytes but loads nothing.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install -e . # from a clone
41
+ # or: pip install skilltree
42
+ ```
43
+
44
+ This installs the `skilltree` CLI.
45
+
46
+ ## Quickstart
47
+
48
+ ```bash
49
+ # 1. See the tree that is actually on disk
50
+ skilltree discover ~/my-skills
51
+
52
+ # 2. Check coherence — is it a tree, or a bare forest with stale wiring?
53
+ skilltree cohere ~/my-skills # exits non-zero on drift
54
+
55
+ # 3. Tree-ify a flat forest IN PLACE (lossless: whole dirs moved, baggage kept,
56
+ # symlinks de-symlinked; every move journaled to .emit-journal.json)
57
+ skilltree emit ~/my-skills --root-forest --name my-skills
58
+
59
+ # 4. ...and undo it exactly, byte-for-byte, any time
60
+ skilltree unemit ~/my-skills
61
+
62
+ # 5. Search the tree (BM25), scoped to a coordinate subtree
63
+ skilltree search ~/my-skills "deploy rollback" --scope 0.1
64
+ ```
65
+
66
+ Programmatic use mirrors the CLI:
67
+
68
+ ```python
69
+ from skilltree import discover, cohere, emit, materialize, SkillTree, TreeNode
70
+
71
+ findings = cohere("~/my-skills") # list[Finding] — the drift report
72
+ emit("~/my-skills", root_forest=True) # tree-ify, journaled
73
+ materialize(SkillTree(TreeNode("root", "sc", children=[...])), "out/", coords=True)
74
+ ```
75
+
76
+ ## Subcommands
77
+
78
+ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
79
+
80
+ | Command | What it does |
81
+ |---|---|
82
+ | `tree <root>` | show the coordinate tree on disk (every node by its `<coord>-<name>` address) |
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
+ | `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
+ | `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) |
87
+ | `unemit <root>` | reverse the last `emit`, byte-for-byte |
88
+ | `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
89
+
90
+ `map` and `search` **fold the former standalone `skillmap` / `foldersearch` / `skillsearch` seedlings into one product**: `map` uses skilltree's own coordinate code (`assign_coords` / `skill_name`, no vendored copy), and `search` is one FTS5/BM25 engine where `--scope` is the only difference between coordinate-free and tree-aware search. The `<coord>-<name>` frontmatter and the Read-breadcrumb format are preserved for backward compatibility.
91
+
92
+ ## Self-management (it keeps itself coherent)
93
+
94
+ A structure the platform won't maintain decays back to the flat forest it replaced — so `skilltree` maintains itself:
95
+
96
+ - **`discover`** reconstructs the on-disk tree from reality, independent of any manifest.
97
+ - **`cohere`** reports drift between reality and the engineered shape — a bare (unrooted) forest, a stale breadcrumb, a coordinate that no longer matches, a skill dropped in flat.
98
+ - **`emit`** re-coheres in place; given a flat forest it tree-ifies it, **moving each skill directory whole** (all of its files preserved — no body-only re-render, no `rmtree`) and **de-symlinking** any symlink'd skill, journaling every move so **`unemit`** restores the prior state byte-for-byte.
99
+ - A scheduled check (`skilltree watch <root>`) writes its verdict into a single managed rule, so a decohered tree announces itself in every subsequent session.
100
+
101
+ ## How a tree looks on disk
102
+
103
+ ```
104
+ domain/.claude/skills/0-domain/SKILL.md # root menu — the only node that auto-loads
105
+ domain/A/.claude/skills/0.1-A/SKILL.md # child A — loaded only when Read
106
+ domain/A/A1/.claude/skills/0.1.1-A1/SKILL.md # grandchild — one level deeper
107
+ domain/B/.claude/skills/0.2-B/SKILL.md
108
+ ```
109
+
110
+ The root menu carries an index summary (so a query about `A1` still finds the branch that leads to it) and the descend breadcrumbs (the out-edges, expressed as the Read that loads them). A coordinate stands in for identity; a breadcrumb stands in for an edge — emulations of what a graph would hold natively.
111
+
112
+ ## Roadmap
113
+
114
+ - **v1 — Claude Code** *(shipping)* — the tree, the front half (`discover`/`cohere`/`emit`/`unemit`), the decoherence watch. The descent mechanic is Claude-Code-specific by design.
115
+ - **v2 — Cross-format adapters** — per-format, **not** symlinks: the descent mechanic doesn't generalize (Codex resolves once at cwd; Gemini eager-loads the whole tree). `SKILL.md` + `.agents/skills` is the portable foundation.
116
+ - **v3 — Chaining** — compose skills into validated chains over the tree.
117
+
118
+ Full detail in **[ROADMAP.md](ROADMAP.md)** — generated from [`roadmap.json`](roadmap.json) (the single source); the [site](site/index.html) renders it live. Run `python3 scripts/update_site.py` after editing the roadmap or changelog.
119
+
120
+ ## Papers
121
+
122
+ Two papers document the project:
123
+
124
+ - **"Flat versus Tree: Why Agent Skills Need a Graph"** — *the why.* The structural argument: a flat skill substrate starves the faculty agents actually run on; the correction is a tree traversed in `O(depth)`, not loaded in `O(#tools)`. → [`papers/flat-vs-tree.md`](papers/flat-vs-tree.md)
125
+ - **"Coordinate-Addressed Skill Trees: Progressive Disclosure on a Flat Filesystem"** — *the how.* The coordinate scheme, the Read-breadcrumb tree, the `discover`/`cohere`/`emit`/`unemit` self-coherence loop, and the runtime-verified load mechanic. → [`papers/skilltree-paper.md`](papers/skilltree-paper.md)
126
+
127
+ > arXiv submission is **pending an endorser**.
128
+
129
+ ## Changelog
130
+
131
+ ### v0.2.0 — 2026-06-18
132
+ - **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
+
134
+ ### v0.1.0 — 2026-06-18
135
+ - Initial release. The Read-breadcrumb tree (`materialize` + the traversability `validate` gate); the front half — `discover` (fs→tree), `cohere` (drift report), `emit` (lossless, journaled tree-ify of a forest), `unemit` (exact undo); the decoherence `watch` writing a self-managed notification rule; the CLI; the site + Dev Log. The auto-load mechanic — Read-into-a-dir injects its layer, `cat` does not, nested doesn't load — was verified against the runtime, not asserted. 56 tests · Python 3.11+ · MIT.
136
+
137
+ ## License
138
+
139
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,20 @@
1
+ agent_skilltree-0.2.0.dist-info/licenses/LICENSE,sha256=ffOw8Kmv_QBeCF_DLeFnCZ-t36WSZJ7ePU8nsibXqZ8,1064
2
+ skilltree/__init__.py,sha256=DMs2CMCKOdQVYkrlHw183WFU7xAvj1PaCc77fLFg4TM,2385
3
+ skilltree/cli.py,sha256=v1GYpuOenm6aj76LIxJzg2zbYTcQMgRrYfVY6htjgxM,15143
4
+ skilltree/cohere.py,sha256=MTE1ggPr5GMKYkakjWhb7v-bcibwjEJSsMqaiDr8F00,22259
5
+ skilltree/exchange.py,sha256=TrDbP8z63GHv_w2OuUn4vEARdkzWNma9qCu9_QLc3lI,4775
6
+ skilltree/federation.py,sha256=pyJvkq0RidJjUlVHg5NwGuI_NWL4GOHtqJ8Vzi9rvUg,4492
7
+ skilltree/forest.py,sha256=c8soqP5M-hVYhDBFIC8v1A7NXKRS9xgrvrF0vk8HdY0,4316
8
+ skilltree/mapper.py,sha256=tatXqx3zBpz3fiimUa7yUHvNTRpie0GwVH4c5lGDpB0,4929
9
+ skilltree/marketplace.py,sha256=EXVm_dlH6wQnn_BX8PYon65prcBzygsiykLT6XaRg8w,3047
10
+ skilltree/materialize.py,sha256=IpUsYqHpsSDmoD0_oVTBIgTUh4pDh8gnt806ZHs90Z0,4234
11
+ skilltree/model.py,sha256=IhUXJor0FjdI7LLlnSnp2sVTVh3p94W6CjqcS28xAJo,4721
12
+ skilltree/registry.py,sha256=vRvAgo24EClESdbB5Rxvbf3TDgx7MTQY6Z6_7u3YsMg,4996
13
+ skilltree/reports.py,sha256=l_UwQM80IMq6mZfcz6NTC4NV6-t_XelXTpd-qVUH9tk,3755
14
+ skilltree/search.py,sha256=VF-zS6H30Ead1oZORGluVk2d4YP19kpZrG4kmkWj7NM,7368
15
+ skilltree/validate.py,sha256=AL_e-AAHKxhHFTA9nB1vC9ZUensUcIjXNMplDKYX_bM,3683
16
+ agent_skilltree-0.2.0.dist-info/METADATA,sha256=Rt_JM8vEUPVqIAe3PPNOtarluIjAuXvdDitJYjctvcw,9531
17
+ agent_skilltree-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
18
+ agent_skilltree-0.2.0.dist-info/entry_points.txt,sha256=E9xX8lbuUs-o3GhRzvOeoAXi60P4Z1nmGMRqZCTjKpQ,49
19
+ agent_skilltree-0.2.0.dist-info/top_level.txt,sha256=K8HsBfiKMS6rOxiBlrCIsHB7_VbWDhc-qGiutVY6D3o,10
20
+ agent_skilltree-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skilltree = skilltree.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sancovp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ skilltree
skilltree/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """SkillTree — a tree of skill dirs wired by links, with programmatic validation.
2
+
3
+ The substrate won't auto-traverse it (no nested-.claude auto-load), so the
4
+ validator is the system. Nodes are skill dirs of any type (ac/cor/sc/skill);
5
+ edges are symlinks; descending a level = redirecting the active skills root.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.2.0"
10
+
11
+ from .exchange import Exchange, Member, load_exchange
12
+ from .exchange import build as build_exchange
13
+ from .exchange import is_valid as exchange_is_valid
14
+ from .exchange import validate as validate_exchange
15
+ from .materialize import materialize
16
+ from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name, assign_coords, skill_name
17
+ from .registry import (
18
+ Entry,
19
+ load_registry,
20
+ promote,
21
+ validate_contribution,
22
+ validate_registry,
23
+ )
24
+ from .registry import search as registry_search
25
+ from .forest import build_forest, link_tree, list_links, unlink
26
+ from .reports import (
27
+ list_reports,
28
+ mark_problem,
29
+ report_missed,
30
+ resolve,
31
+ summary as reports_summary,
32
+ )
33
+ from .search import build_index, search, search_tree, search_folder, DEFAULT_EXTS
34
+ from .mapper import build_map, write_map
35
+ from .cohere import (
36
+ Finding, cohere, discover, emit, unemit,
37
+ render_notifications, write_notifications, watch, NOTIFY_RULE,
38
+ )
39
+ from .federation import (
40
+ flatten_federation,
41
+ local_resolver,
42
+ register_child,
43
+ validate_federation,
44
+ walk_federation,
45
+ )
46
+ from .validate import Violation, is_valid, validate
47
+
48
+ __all__ = [
49
+ "SkillTree", "TreeNode", "materialize", "validate", "is_valid", "Violation",
50
+ "Exchange", "Member", "load_exchange", "build_exchange", "validate_exchange",
51
+ "exchange_is_valid",
52
+ "Entry", "load_registry", "validate_registry", "validate_contribution",
53
+ "promote", "registry_search",
54
+ "walk_federation", "flatten_federation", "validate_federation",
55
+ "register_child", "local_resolver",
56
+ "link_tree", "build_forest", "list_links", "unlink",
57
+ "assign_coords", "skill_name",
58
+ "build_index", "search", "search_tree", "search_folder", "DEFAULT_EXTS",
59
+ "build_map", "write_map",
60
+ "discover", "cohere", "emit", "unemit", "Finding",
61
+ "render_notifications", "write_notifications", "watch", "NOTIFY_RULE",
62
+ "report_missed", "mark_problem", "list_reports", "resolve", "reports_summary",
63
+ "__version__",
64
+ ]
skilltree/cli.py ADDED
@@ -0,0 +1,315 @@
1
+ """skilltree — build a `cat`-breadcrumb tree of skill dirs from JSON, and validate it."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import tempfile
6
+
7
+ import click
8
+
9
+ from .materialize import materialize, node_skill_md
10
+ from .model import SkillTree, TreeNode
11
+ from .validate import validate
12
+
13
+
14
+ @click.group()
15
+ @click.version_option(message="skilltree %(version)s")
16
+ def main() -> None:
17
+ """SkillTree — a nested tree of skill dirs wired by `cat`-breadcrumbs (validated)."""
18
+
19
+
20
+ @main.command(name="discover")
21
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
22
+ def discover_cmd(root: Path) -> None:
23
+ """Read the live filesystem and print the tree that is ACTUALLY there
24
+ (every dir with a .claude/skills, nested by plain-dir path)."""
25
+ from .cohere import discover
26
+ from .model import skill_name
27
+
28
+ tree = discover(root)
29
+
30
+ def show(node, depth=0):
31
+ click.echo(" " * depth + f"{skill_name(node)} ({node.kind})")
32
+ for c in node.children:
33
+ show(c, depth + 1)
34
+ show(tree.root)
35
+
36
+
37
+ @main.command(name="cohere")
38
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
39
+ def cohere_cmd(root: Path) -> None:
40
+ """Report DECOHERENCE: how the on-disk tree drifted from its engineered shape
41
+ (bare forest, stale breadcrumbs, coord drift, strays). Exit 1 if any drift —
42
+ so a cron can notify on a non-zero exit."""
43
+ from .cohere import cohere
44
+ findings = cohere(root)
45
+ if not findings:
46
+ click.echo("✓ coherent — the tree shape and the breadcrumbs agree")
47
+ return
48
+ click.echo(f"⚠ {len(findings)} decoherence finding(s):")
49
+ for f in findings:
50
+ click.echo(f" - {f}")
51
+ raise SystemExit(1)
52
+
53
+
54
+ @main.command(name="emit")
55
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
56
+ @click.option("--root-forest", is_flag=True, help="tree-ify a bare forest (synthesize + save a root)")
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:
59
+ """Re-cohere IN PLACE: reassign coords + rewrite breadcrumbs/index from the
60
+ canonical shape (non-destructive). With --root-forest, root a bare forest."""
61
+ from .cohere import emit
62
+ report = emit(root, root_forest=root_forest, forest_name=forest_name)
63
+ if not report.get("ok"):
64
+ click.echo(f"✗ {report.get('error')}")
65
+ raise SystemExit(1)
66
+ extra = " (synthesized root)" if report.get("synthesized_root") else ""
67
+ moves = f", {report.get('moves')} moved" if report.get("moves") else ""
68
+ click.echo(f"✓ emitted {report['nodes']} node(s) under {report['root']}{extra}{moves}")
69
+ if report.get("journal"):
70
+ click.echo(f" journal: {report['journal']} (reverse with `skilltree unemit`)")
71
+
72
+
73
+ @main.command(name="unemit")
74
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
75
+ def unemit_cmd(root: Path) -> None:
76
+ """Reverse the last tree-ifying `emit`: replay `.emit-journal.json` backwards —
77
+ move every relocated skill dir back, restore its SKILL.md, drop the synthesized
78
+ root + manifest. Lossless undo."""
79
+ from .cohere import unemit
80
+ rep = unemit(root)
81
+ if not rep.get("ok"):
82
+ click.echo(f"✗ {rep.get('error')}")
83
+ raise SystemExit(1)
84
+ click.echo(f"✓ reversed: {rep['moved_back']} dir(s) moved back, {rep['removed']} synthesized node(s) removed")
85
+
86
+
87
+ _DEFAULT_RULES = Path.home() / ".claude" / "rules"
88
+
89
+
90
+ @main.command(name="notify")
91
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
92
+ @click.option("--rules-dir", type=click.Path(path_type=Path), default=None,
93
+ help="where to write the managed rule (default ~/.claude/rules)")
94
+ def notify_cmd(root: Path, rules_dir: Path | None) -> None:
95
+ """One decoherence check → (re)write the self-managed `system_notifications`
96
+ rule. READ-ONLY on the tree; only the rule file changes. Exit 1 on any drift."""
97
+ from .cohere import write_notifications
98
+ rep = write_notifications(root, rules_dir=rules_dir or _DEFAULT_RULES)
99
+ state = "nominal" if rep["nominal"] else f"{rep['errors']} error(s), {rep['warnings']} warning(s)"
100
+ wrote = "rewrote" if rep["changed"] else "unchanged"
101
+ click.echo(f"[SKILLTREE] {state} — {wrote} {rep['path']}")
102
+ raise SystemExit(0 if rep["nominal"] else 1)
103
+
104
+
105
+ @main.command(name="watch")
106
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
107
+ @click.option("--rules-dir", type=click.Path(path_type=Path), default=None,
108
+ help="where to write the managed rule (default ~/.claude/rules)")
109
+ @click.option("--interval", default=300, type=float, help="seconds between checks (default 300)")
110
+ @click.option("--once", is_flag=True, help="run a single check and exit (for testing)")
111
+ def watch_cmd(root: Path, rules_dir: Path | None, interval: float, once: bool) -> None:
112
+ """The decoherence cron: every --interval seconds, re-check the tree and refresh
113
+ the `system_notifications` rule. Start as a background process. READ-ONLY on the
114
+ tree (it only writes the rule); the FIX is agent-initiated via the skilltree skill."""
115
+ from .cohere import watch
116
+ rd = rules_dir or _DEFAULT_RULES
117
+ click.echo(f"[SKILLTREE] watching {root} every {interval:.0f}s → {rd}")
118
+ watch(root, rules_dir=rd, interval=interval, iterations=1 if once else None)
119
+
120
+
121
+ @main.command(name="tree")
122
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
123
+ def tree_cmd(root: Path) -> None:
124
+ """Show the coordinate tree on disk — every node with its `<coord>-<name>` address —
125
+ by reading the live filesystem (the materialized / skilltree-mapped structure)."""
126
+ from .cohere import discover
127
+ from .model import skill_name
128
+ root_node = discover(root).root
129
+
130
+ def show(node, depth: int = 0) -> None:
131
+ click.echo(" " * depth + f"{skill_name(node)} ({node.kind})")
132
+ for c in node.children:
133
+ show(c, depth + 1)
134
+ show(root_node)
135
+
136
+
137
+ @main.command(name="map")
138
+ @click.argument("folder", type=click.Path(exists=True, file_okay=False, path_type=Path))
139
+ @click.option("--write", is_flag=True, help="write CLAUDE.md into the folder (default: print to stdout)")
140
+ def map_cmd(folder: Path, write: bool) -> None:
141
+ """Render a flat folder into ONE coordinate-addressed CLAUDE.md — a Folder Map + an
142
+ addressable Index + branch summaries — for progressive disclosure (open a branch, descend
143
+ to the coordinate you need). Uses skilltree's own coordinate scheme."""
144
+ from .mapper import build_map, write_map
145
+ if write:
146
+ click.echo(f"wrote {write_map(folder)}")
147
+ else:
148
+ click.echo(build_map(folder))
149
+
150
+
151
+ @main.command(name="search")
152
+ @click.argument("folder", type=click.Path(exists=True, file_okay=False, path_type=Path))
153
+ @click.argument("query")
154
+ @click.option("--scope", "scope_coord", default=None,
155
+ help="restrict to a coordinate subtree, e.g. 0.1 (when the folder carries skilltree coordinates)")
156
+ @click.option("--ext", default=None, help="comma-separated extensions (default: .md,.txt,.mdx,.rst)")
157
+ @click.option("--limit", default=10, type=int)
158
+ def search_cmd(folder: Path, query: str, scope_coord: str | None, ext: str | None, limit: int) -> None:
159
+ """FTS5/BM25 search over ANY folder. `--scope` restricts to a coordinate subtree when the
160
+ folder carries skilltree coordinates; on a plain folder it is coordinate-free search."""
161
+ from .search import search_folder, DEFAULT_EXTS
162
+ exts = (tuple(e if e.startswith(".") else "." + e for e in ext.split(",")) if ext else DEFAULT_EXTS)
163
+ hits = search_folder(folder, query, scope_coord=scope_coord, exts=exts, limit=limit)
164
+ where = f" in {scope_coord}" if scope_coord else ""
165
+ if not hits:
166
+ click.echo(f"(no matches{where} for: {query})")
167
+ return
168
+ click.echo(f"(top {len(hits)}{where} for: {query})\n")
169
+ for i, h in enumerate(hits, 1):
170
+ coord = f"[{h['coord']}] " if h["coord"] else ""
171
+ desc = f" — {h['description']}" if h["description"] else ""
172
+ click.echo(f"{i}. {coord}{h['name']}{desc}")
173
+ click.echo(f" {h['path']}")
174
+
175
+
176
+ @main.command(name="report-missed")
177
+ @click.option("--needed", required=True, help="the skill/capability that was needed")
178
+ @click.option("--happened", required=True, help="what you did instead / what went wrong")
179
+ @click.option("--suggests", default=None, help="proposed skill name + one-line purpose")
180
+ @click.option("--by", default="agent")
181
+ @click.option("--reports", "reports_path", default=None, help="reports store (default ~/.claude/skill-reports.json)")
182
+ def report_missed_cmd(needed, happened, suggests, by, reports_path):
183
+ """File a missed-skill report (a needed skill didn't exist)."""
184
+ from .reports import DEFAULT_REPORTS, report_missed
185
+ e = report_missed(reports_path or DEFAULT_REPORTS, needed=needed, happened=happened, suggests=suggests, by=by)
186
+ click.echo(f"✓ filed {e['id']} (missed_skill) — needed: {needed!r}")
187
+
188
+
189
+ @main.command(name="mark-problem")
190
+ @click.option("--skill", required=True, help="the skill that should have fired")
191
+ @click.option("--expected", required=True, help="what you expected it to do")
192
+ @click.option("--happened", required=True, help="what happened instead")
193
+ @click.option("--by", default="user")
194
+ @click.option("--reports", "reports_path", default=None)
195
+ def mark_problem_cmd(skill, expected, happened, by, reports_path):
196
+ """Mark 'expected this skill to be used, but it wasn't'."""
197
+ from .reports import DEFAULT_REPORTS, mark_problem
198
+ e = mark_problem(reports_path or DEFAULT_REPORTS, skill=skill, expected=expected, happened=happened, by=by)
199
+ click.echo(f"✓ filed {e['id']} (expected_not_used) — skill: {skill!r}")
200
+
201
+
202
+ @main.command(name="reports")
203
+ @click.option("--reports", "reports_path", default=None)
204
+ @click.option("--kind", default=None)
205
+ def reports_cmd(reports_path, kind):
206
+ """Show the open skill reports (the improver's queue)."""
207
+ from .reports import DEFAULT_REPORTS, list_reports, summary
208
+ rp = reports_path or DEFAULT_REPORTS
209
+ s = summary(rp)
210
+ click.echo(f"reports: {s['open']} open / {s['total']} total {s['open_by_kind']}")
211
+ for r in list_reports(rp, kind=kind):
212
+ head = r.get("needed") or r.get("skill") or "?"
213
+ click.echo(f" {r['id']} [{r['kind']}] by {r['by']}: {head}")
214
+
215
+
216
+ @main.command(name="build")
217
+ @click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
218
+ @click.argument("root", type=click.Path(path_type=Path))
219
+ def build_cmd(manifest: Path, root: Path) -> None:
220
+ """Materialize a SkillTree from a JSON MANIFEST into ROOT, then validate."""
221
+ tree = SkillTree.load(manifest)
222
+ materialize(tree, root)
223
+ issues = validate(root)
224
+ errors = [v for v in issues if v.severity == "error"]
225
+ click.echo(f"built {len(tree.nodes())} nodes at {root}")
226
+ for v in issues:
227
+ click.echo(f" {'✗' if v.severity == 'error' else '⚠'} [{v.where}] {v.message}")
228
+ if errors:
229
+ raise SystemExit(f"✗ INVALID — {len(errors)} error(s)")
230
+ click.echo(f"✓ valid — root: cat {node_skill_md(root, tree.root.name)}")
231
+
232
+
233
+ @main.command(name="validate")
234
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
235
+ def validate_cmd(root: Path) -> None:
236
+ """Validate a materialized SkillTree at ROOT."""
237
+ issues = validate(root)
238
+ for v in issues:
239
+ click.echo(f" {'✗' if v.severity == 'error' else '⚠'} [{v.where}] {v.message}")
240
+ if any(v.severity == "error" for v in issues):
241
+ raise SystemExit("✗ INVALID")
242
+ click.echo("✓ valid — every breadcrumb resolves.")
243
+
244
+
245
+ @main.group()
246
+ def exchange() -> None:
247
+ """Many skill trees in one repo, under a master."""
248
+
249
+
250
+ @exchange.command(name="build")
251
+ @click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
252
+ @click.argument("repo", type=click.Path(path_type=Path))
253
+ def exchange_build(manifest: Path, repo: Path) -> None:
254
+ """Materialize every member tree + the master from an exchange MANIFEST."""
255
+ from .exchange import build, load_exchange, validate
256
+ ex = load_exchange(manifest)
257
+ master = build(ex, repo)
258
+ issues = validate(repo, ex)
259
+ click.echo(f"built exchange '{ex.name}' — {len(ex.trees)} tree(s) + master at {master}")
260
+ for v in issues:
261
+ click.echo(f" {'✗' if v.severity == 'error' else '⚠'} [{v.where}] {v.message}")
262
+ if any(v.severity == "error" for v in issues):
263
+ raise SystemExit("✗ INVALID")
264
+ click.echo("✓ valid — master + every member tree resolve.")
265
+
266
+
267
+ @exchange.command(name="validate")
268
+ @click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
269
+ @click.argument("repo", type=click.Path(exists=True, file_okay=False, path_type=Path))
270
+ def exchange_validate(manifest: Path, repo: Path) -> None:
271
+ """Validate a built exchange REPO against its MANIFEST."""
272
+ from .exchange import load_exchange, validate
273
+ for v in validate(repo, load_exchange(manifest)):
274
+ click.echo(f" {'✗' if v.severity == 'error' else '⚠'} [{v.where}] {v.message}")
275
+
276
+
277
+ @main.command()
278
+ def demo() -> None:
279
+ """Build a breadcrumb tree, walk it by `cat`, validate, then break a breadcrumb."""
280
+ tree = SkillTree(TreeNode("cc-skill-tree", "sc", description="root of the cognition skill tree", children=[
281
+ TreeNode("debug", "cor", description="how to debug", children=[
282
+ TreeNode("symptom-attn", "ac", description="attend to the symptom"),
283
+ ]),
284
+ TreeNode("explain", "cor", description="how to explain", children=[
285
+ TreeNode("simplify-attn", "ac", description="attend to the simplest form"),
286
+ ]),
287
+ ]))
288
+ with tempfile.TemporaryDirectory() as tmp:
289
+ root = Path(tmp) / "cc_tree_test"
290
+ materialize(tree, root)
291
+
292
+ click.echo("══ 1. only the ROOT auto-loads; everything else is reached by `cat` ══")
293
+ rootmd = node_skill_md(root, "cc-skill-tree")
294
+ click.echo(f" auto-loaded: {rootmd}")
295
+ click.echo(" ── its body (the breadcrumbs) ──")
296
+ click.echo("\n".join(" " + l for l in rootmd.read_text().splitlines() if l.strip()))
297
+
298
+ click.echo("\n══ 2. follow a breadcrumb down to a leaf ══")
299
+ debugmd = node_skill_md(root / "debug", "debug")
300
+ leafmd = node_skill_md(root / "debug" / "symptom-attn", "symptom-attn")
301
+ click.echo(f" cat {debugmd} → then → cat {leafmd}")
302
+
303
+ click.echo("\n══ 3. validate (the harness) ══")
304
+ click.echo(f" {'✓ valid' if not validate(root) else 'issues:'}")
305
+ for v in validate(root):
306
+ click.echo(f" {v.severity}: [{v.where}] {v.message}")
307
+
308
+ click.echo("\n══ 4. corrupt a breadcrumb (rename a child dir); substrate stays silent ══")
309
+ (root / "debug" / "symptom-attn").rename(root / "debug" / "renamed-away")
310
+ for v in validate(root):
311
+ click.echo(f" ✗ [{v.where}] {v.message}")
312
+
313
+
314
+ if __name__ == "__main__":
315
+ main()