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.
- agent_skilltree-0.2.0.dist-info/METADATA +139 -0
- agent_skilltree-0.2.0.dist-info/RECORD +20 -0
- agent_skilltree-0.2.0.dist-info/WHEEL +5 -0
- agent_skilltree-0.2.0.dist-info/entry_points.txt +2 -0
- agent_skilltree-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_skilltree-0.2.0.dist-info/top_level.txt +1 -0
- skilltree/__init__.py +64 -0
- skilltree/cli.py +315 -0
- skilltree/cohere.py +463 -0
- skilltree/exchange.py +121 -0
- skilltree/federation.py +109 -0
- skilltree/forest.py +101 -0
- skilltree/mapper.py +117 -0
- skilltree/marketplace.py +86 -0
- skilltree/materialize.py +88 -0
- skilltree/model.py +114 -0
- skilltree/registry.py +120 -0
- skilltree/reports.py +95 -0
- skilltree/search.py +148 -0
- skilltree/validate.py +89 -0
skilltree/cohere.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""cohere.py — the FRONT half of skilltree: discover → cohere → emit (in place).
|
|
2
|
+
|
|
3
|
+
`materialize` only goes one way (tree → disk, destructively). This module is the
|
|
4
|
+
missing inverse + the drift gate the rest of the system needs to *use itself*:
|
|
5
|
+
|
|
6
|
+
discover(root) → read the LIVE filesystem and reconstruct the tree that is
|
|
7
|
+
actually there (reality), independent of any manifest.
|
|
8
|
+
cohere(root) → diff reality against the ENGINEERED shape (skilltree.json,
|
|
9
|
+
the canonical tree). Decoherence = the breadcrumbs/coords on
|
|
10
|
+
disk no longer match the tree's real shape. This is what a
|
|
11
|
+
cron runs: "you decohered X — look at the tree shape."
|
|
12
|
+
emit(root) → rewrite each node's coord + `cat`-breadcrumbs + index summary
|
|
13
|
+
IN PLACE (no rmtree), so reality matches the engineered shape
|
|
14
|
+
again — and, for a bare forest, ROOT it (the forest→tree fix).
|
|
15
|
+
|
|
16
|
+
The law (rule 01): a bare forest is the bug. `cohere` finds it; `emit` roots it.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name
|
|
28
|
+
|
|
29
|
+
# dirs that are never tree nodes
|
|
30
|
+
_SKIP = {".claude", "kb", "__pycache__", ".git", "node_modules", ".aios.src"}
|
|
31
|
+
# a coord-prefixed skill identity: "0.1.2-some-name" → ("0.1.2", "some-name")
|
|
32
|
+
_COORD_RE = re.compile(r"^(\d+(?:\.\d+)*)-(.+)$")
|
|
33
|
+
# everything from the first generated section to EOF (materialize appends them last)
|
|
34
|
+
_SECT_RE = re.compile(r"\n+## Index summary\b.*\Z", re.S)
|
|
35
|
+
_CRUMB_RE = re.compile(r"`([^`]+?)/SKILL\.md`") # the path-before-SKILL.md, verb-agnostic
|
|
36
|
+
_CRUMB = "- {name} ({kind}): Read `{path}`" # the verb is the Read TOOL, never `cat`
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_ident(sdir_name: str) -> tuple[str | None, str]:
|
|
40
|
+
m = _COORD_RE.match(sdir_name)
|
|
41
|
+
return (m.group(1), m.group(2)) if m else (None, sdir_name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _front(skill_md: Path) -> dict:
|
|
45
|
+
"""Parse the YAML-ish frontmatter of a SKILL.md (name/description lines)."""
|
|
46
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
47
|
+
out: dict[str, str] = {}
|
|
48
|
+
if text.lstrip().startswith("---"):
|
|
49
|
+
for line in text.split("---", 2)[1].splitlines():
|
|
50
|
+
if ":" in line:
|
|
51
|
+
k, v = line.split(":", 1)
|
|
52
|
+
out[k.strip()] = v.strip()
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _skills_dir(node_dir: Path) -> Path:
|
|
57
|
+
"""Where a node keeps its skills. A `.claude` dir (the USER level, `~/.claude`)
|
|
58
|
+
holds them at `<.claude>/skills`; a project/branch dir at `<dir>/.claude/skills`."""
|
|
59
|
+
return node_dir / "skills" if node_dir.name == ".claude" else node_dir / ".claude" / "skills"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _skill_dirs_in(node_dir: Path) -> list[Path]:
|
|
63
|
+
cs = _skills_dir(node_dir)
|
|
64
|
+
if not cs.exists():
|
|
65
|
+
return []
|
|
66
|
+
return sorted(d for d in cs.iterdir() if d.is_dir() and (d / "SKILL.md").exists())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _child_dirs(node_dir: Path) -> list[Path]:
|
|
70
|
+
"""Plain sub-dirs that are themselves tree nodes (carry their own .claude/skills)."""
|
|
71
|
+
return sorted(d for d in node_dir.iterdir()
|
|
72
|
+
if d.is_dir() and d.name not in _SKIP and (d / ".claude" / "skills").exists())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── discover: live filesystem → SkillTree (reality) ──────────────────────────
|
|
76
|
+
def _discover_node(node_dir: Path) -> tuple[list[TreeNode], list[str]]:
|
|
77
|
+
"""Reconstruct the node(s) rooted at `node_dir`. Returns (nodes, strays):
|
|
78
|
+
`strays` = names of extra skills sharing a node's .claude/skills (a decoherence
|
|
79
|
+
smell — a proper node owns exactly one skill in its own root)."""
|
|
80
|
+
sdirs = _skill_dirs_in(node_dir)
|
|
81
|
+
strays: list[str] = []
|
|
82
|
+
|
|
83
|
+
# children come from the plain sub-dirs that are nodes
|
|
84
|
+
children: list[TreeNode] = []
|
|
85
|
+
for cd in _child_dirs(node_dir):
|
|
86
|
+
kids, kstray = _discover_node(cd)
|
|
87
|
+
children.extend(kids)
|
|
88
|
+
strays.extend(kstray)
|
|
89
|
+
|
|
90
|
+
if not sdirs:
|
|
91
|
+
return children, strays # a plain container dir (e.g. the discover root)
|
|
92
|
+
|
|
93
|
+
# an UNCOORDINATED skill ranks LAST (it can't be the root of a coordinated subtree).
|
|
94
|
+
def _key(d: Path):
|
|
95
|
+
c, _ = _parse_ident(d.name)
|
|
96
|
+
return (c.count(".") if c else 999, c or "~", d.name)
|
|
97
|
+
|
|
98
|
+
def _leaf(sd: Path) -> TreeNode:
|
|
99
|
+
coord, name = _parse_ident(sd.name)
|
|
100
|
+
desc = re.sub(r"^\[[\d.]+\]\s*", "", _front(sd / "SKILL.md").get("description", ""))
|
|
101
|
+
# skill_src = the CURRENT on-disk location (so emit can relocate it when tree-ifying)
|
|
102
|
+
return TreeNode(name=name, kind="skill", description=desc or None,
|
|
103
|
+
coord=coord, skill_src=str(sd))
|
|
104
|
+
|
|
105
|
+
# A node OWNS its subtree only when there's real NESTING (child node-dirs) or it is
|
|
106
|
+
# a lone skill. A pile of many skills with no nesting is a FLAT FOREST — even if a few
|
|
107
|
+
# carry leftover coord prefixes (those are just oddly-named flat skills, not a root).
|
|
108
|
+
if children or len(sdirs) == 1:
|
|
109
|
+
own = sorted(sdirs, key=_key)[0]
|
|
110
|
+
strays.extend(d.name for d in sdirs if d is not own)
|
|
111
|
+
node = _leaf(own)
|
|
112
|
+
node.children = children
|
|
113
|
+
return [node], strays
|
|
114
|
+
return [_leaf(d) for d in sdirs] + children, strays
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def discover(root: str | Path, *, forest_name: str | None = None) -> SkillTree:
|
|
118
|
+
"""Read the live filesystem from `root` down and reconstruct the tree as it
|
|
119
|
+
ACTUALLY is. A bare forest (many roots, no single parent) is wrapped in a
|
|
120
|
+
SYNTHESIZED root so the result is always a tree (the thing it should become)."""
|
|
121
|
+
root = Path(root)
|
|
122
|
+
nodes, _ = _discover_node(root)
|
|
123
|
+
if len(nodes) == 1:
|
|
124
|
+
return SkillTree(nodes[0])
|
|
125
|
+
return SkillTree(TreeNode(
|
|
126
|
+
name=forest_name or root.name, kind="sc",
|
|
127
|
+
description=f"(synthesized root over a bare forest of {len(nodes)})",
|
|
128
|
+
children=nodes))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── cohere: reality vs the engineered shape → drift findings ─────────────────
|
|
132
|
+
@dataclass
|
|
133
|
+
class Finding:
|
|
134
|
+
kind: str # bare_forest | uncoordinated | coord_drift | stale_breadcrumb | stray_skill | missing_node | extra_node
|
|
135
|
+
coord: str | None
|
|
136
|
+
name: str
|
|
137
|
+
detail: str
|
|
138
|
+
|
|
139
|
+
def __str__(self) -> str:
|
|
140
|
+
at = f"[{self.coord}] " if self.coord else ""
|
|
141
|
+
return f"{self.kind}: {at}{self.name} — {self.detail}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _node_dir(root: Path, path: list[str]) -> Path:
|
|
145
|
+
"""Tree-path dir for a node reached by plain names from the root (root = root dir)."""
|
|
146
|
+
d = root
|
|
147
|
+
for nm in path[1:]:
|
|
148
|
+
d = d / nm
|
|
149
|
+
return d
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cohere(root: str | Path) -> list[Finding]:
|
|
153
|
+
"""Decoherence report: how the on-disk tree has drifted from its engineered
|
|
154
|
+
shape. With a `skilltree.json` it diffs reality against that canon; without one
|
|
155
|
+
the dir is a bare forest by definition (the rule-01 violation)."""
|
|
156
|
+
root = Path(root)
|
|
157
|
+
findings: list[Finding] = []
|
|
158
|
+
manifest = root / "skilltree.json"
|
|
159
|
+
|
|
160
|
+
if not manifest.exists():
|
|
161
|
+
live = discover(root)
|
|
162
|
+
kids = live.root.children
|
|
163
|
+
if live.root.description and "synthesized root" in live.root.description:
|
|
164
|
+
findings.append(Finding("bare_forest", None, live.root.name,
|
|
165
|
+
f"no skilltree.json; {len(kids)} top skills with no root — run `emit --root` to tree-ify"))
|
|
166
|
+
if any(c.coord is None for c in live.nodes()):
|
|
167
|
+
findings.append(Finding("uncoordinated", None, live.root.name,
|
|
168
|
+
"nodes have no coordinates (never materialized with coords)"))
|
|
169
|
+
return findings
|
|
170
|
+
|
|
171
|
+
canon = SkillTree.load(manifest)
|
|
172
|
+
assign_coords(canon.root) # what the coords SHOULD be
|
|
173
|
+
|
|
174
|
+
# walk canon with tree-path; check disk reality at each node
|
|
175
|
+
def walk(node: TreeNode, path: list[str]) -> None:
|
|
176
|
+
ndir = _node_dir(root, path)
|
|
177
|
+
sname = skill_name(node)
|
|
178
|
+
smd = ndir / ".claude" / "skills" / sname / "SKILL.md"
|
|
179
|
+
# 1. node present on disk at its canonical coord?
|
|
180
|
+
if not smd.exists():
|
|
181
|
+
# maybe present under a DIFFERENT coord (coord drift) or missing entirely
|
|
182
|
+
alt = sorted((ndir / ".claude" / "skills").glob("*")) if (ndir / ".claude" / "skills").exists() else []
|
|
183
|
+
alt_named = [a.name for a in alt if _parse_ident(a.name)[1] == node.name]
|
|
184
|
+
if alt_named:
|
|
185
|
+
findings.append(Finding("coord_drift", node.coord, node.name,
|
|
186
|
+
f"on disk as {alt_named[0]!r}, canon says {sname!r}"))
|
|
187
|
+
else:
|
|
188
|
+
findings.append(Finding("missing_node", node.coord, node.name,
|
|
189
|
+
f"declared in manifest, absent on disk ({smd})"))
|
|
190
|
+
else:
|
|
191
|
+
# 2. branch breadcrumbs match the real children?
|
|
192
|
+
if node.children:
|
|
193
|
+
body = smd.read_text(encoding="utf-8")
|
|
194
|
+
# compare by PLAIN name (strip the coord prefix); coord drift is its own finding
|
|
195
|
+
have = {_parse_ident(Path(p).name)[1] for p in _CRUMB_RE.findall(body)}
|
|
196
|
+
want = {c.name for c in node.children}
|
|
197
|
+
missing = want - have
|
|
198
|
+
extra = have - want
|
|
199
|
+
if missing or extra:
|
|
200
|
+
findings.append(Finding("stale_breadcrumb", node.coord, node.name,
|
|
201
|
+
f"breadcrumbs {'missing ' + ','.join(sorted(missing)) if missing else ''}"
|
|
202
|
+
f"{' / ' if missing and extra else ''}"
|
|
203
|
+
f"{'dangling ' + ','.join(sorted(extra)) if extra else ''}".strip()))
|
|
204
|
+
# 3. extra strays sharing this node's root
|
|
205
|
+
_, strays = _discover_node(ndir)
|
|
206
|
+
for s in strays:
|
|
207
|
+
findings.append(Finding("stray_skill", node.coord, node.name,
|
|
208
|
+
f"extra skill {s!r} sits in this node's root (not in the tree)"))
|
|
209
|
+
for c in node.children:
|
|
210
|
+
walk(c, path + [c.name])
|
|
211
|
+
|
|
212
|
+
walk(canon.root, [canon.root.name])
|
|
213
|
+
return findings
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ── emit: rewrite coords + breadcrumbs + index IN PLACE (non-destructive) ─────
|
|
217
|
+
def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
|
|
218
|
+
"""Refresh ONLY the coord prefix + Index/Descend sections of an existing
|
|
219
|
+
SKILL.md, preserving the hand-written body above them. Creates the file (a
|
|
220
|
+
stub) if absent (e.g. a freshly synthesized forest root)."""
|
|
221
|
+
sname = skill_name(node)
|
|
222
|
+
|
|
223
|
+
def _base_from(text: str) -> str:
|
|
224
|
+
body = text.split("---", 2)[-1] if text.lstrip().startswith("---") else text
|
|
225
|
+
return _SECT_RE.sub("", body).strip()
|
|
226
|
+
|
|
227
|
+
src_md = Path(node.skill_src) / "SKILL.md" if node.skill_src else None
|
|
228
|
+
if smd.exists(): # in-place re-cohere: keep existing body
|
|
229
|
+
base = _base_from(smd.read_text(encoding="utf-8"))
|
|
230
|
+
elif src_md and src_md.exists(): # relocation: carry the body from skill_src
|
|
231
|
+
smd.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
base = _base_from(src_md.read_text(encoding="utf-8"))
|
|
233
|
+
else: # fresh stub (e.g. a synthesized root)
|
|
234
|
+
smd.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
base = f"SkillTree {node.kind} node `{node.name}`."
|
|
236
|
+
|
|
237
|
+
desc = node.description or compose_summary(node, full=False)
|
|
238
|
+
desc = re.sub(r"^\[[\d.]+\]\s*", "", desc)
|
|
239
|
+
if node.coord:
|
|
240
|
+
desc = f"[{node.coord}] {desc}"
|
|
241
|
+
|
|
242
|
+
if node.children:
|
|
243
|
+
crumbs = [_CRUMB.format(
|
|
244
|
+
name=skill_name(c), kind=c.kind,
|
|
245
|
+
path=(node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md").resolve())
|
|
246
|
+
for c in node.children]
|
|
247
|
+
tail = (f"\n\n## Index summary\n{compose_summary(node, full=True)}\n\n"
|
|
248
|
+
f"## Descend — the next layer ({len(node.children)})\n"
|
|
249
|
+
"Only this layer is loaded now. To descend, use the **Read tool** on a child "
|
|
250
|
+
"below — that injects the child's layer. A Bash `cat` reads the bytes but loads "
|
|
251
|
+
"nothing; you must use the Read tool:\n\n" + "\n".join(crumbs))
|
|
252
|
+
else:
|
|
253
|
+
tail = ""
|
|
254
|
+
out = f"---\nname: {sname}\ndescription: {desc}\n---\n\n{base}{tail}\n"
|
|
255
|
+
smd.write_text(out, encoding="utf-8")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
JOURNAL = ".emit-journal.json" # the reversibility receipt written by a tree-ifying emit
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def emit(root: str | Path, *, root_forest: bool = False,
|
|
262
|
+
forest_name: str | None = None) -> dict:
|
|
263
|
+
"""Re-cohere the tree IN PLACE: assign coords + rewrite every node's
|
|
264
|
+
breadcrumbs/index from the canonical shape, without destroying content.
|
|
265
|
+
|
|
266
|
+
No skilltree.json + `root_forest=True` → discover the bare forest, synthesize
|
|
267
|
+
a root, and **tree-ify it**: each flat leaf is MOVED WHOLESALE into its nested
|
|
268
|
+
node-dir (all its files preserved — *not* a body-only re-render), so it stops
|
|
269
|
+
auto-loading. Every move is JOURNALED to `.emit-journal.json` so `unemit` can
|
|
270
|
+
reverse it exactly (your cache-for-reversibility requirement)."""
|
|
271
|
+
root = Path(root)
|
|
272
|
+
manifest = root / "skilltree.json"
|
|
273
|
+
synthesized = False
|
|
274
|
+
if manifest.exists():
|
|
275
|
+
tree = SkillTree.load(manifest)
|
|
276
|
+
else:
|
|
277
|
+
if not root_forest:
|
|
278
|
+
return {"ok": False, "error": "no skilltree.json; pass root_forest=True to tree-ify a bare forest"}
|
|
279
|
+
tree = discover(root, forest_name=forest_name)
|
|
280
|
+
synthesized = True
|
|
281
|
+
|
|
282
|
+
assign_coords(tree.root)
|
|
283
|
+
journal: list[dict] = [] # only populated when synthesizing (i.e. when we MOVE)
|
|
284
|
+
n = 0
|
|
285
|
+
|
|
286
|
+
def walk(node: TreeNode, node_dir: Path) -> None:
|
|
287
|
+
nonlocal n
|
|
288
|
+
target_dir = node_dir / ".claude" / "skills" / skill_name(node)
|
|
289
|
+
old_src = Path(node.skill_src) if node.skill_src else None
|
|
290
|
+
if synthesized and old_src and old_src.exists() \
|
|
291
|
+
and old_src.resolve() != target_dir.resolve():
|
|
292
|
+
# LOSSLESS relocation into the nested node-dir, journaled for unemit.
|
|
293
|
+
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
if old_src.is_symlink():
|
|
295
|
+
# a symlink'd skill (the common ~/.claude case): de-symlink — copy the
|
|
296
|
+
# RESOLVED content into the node-dir (so coord/breadcrumbs are writable),
|
|
297
|
+
# drop the link, journal it so unemit recreates the EXACT symlink.
|
|
298
|
+
journal.append({"op": "desymlink", "symlink": str(old_src),
|
|
299
|
+
"link": os.readlink(old_src), "to": str(target_dir)})
|
|
300
|
+
shutil.copytree(old_src.resolve(), target_dir, symlinks=False)
|
|
301
|
+
old_src.unlink()
|
|
302
|
+
else:
|
|
303
|
+
# a real dir: move the WHOLE dir (baggage and all), journal the bytes.
|
|
304
|
+
orig = (old_src / "SKILL.md").read_text(encoding="utf-8") \
|
|
305
|
+
if (old_src / "SKILL.md").exists() else ""
|
|
306
|
+
journal.append({"op": "move", "from": str(old_src), "to": str(target_dir),
|
|
307
|
+
"skill_md": orig})
|
|
308
|
+
shutil.move(str(old_src), str(target_dir))
|
|
309
|
+
_rewrite_node_md(target_dir / "SKILL.md", node, node_dir)
|
|
310
|
+
else:
|
|
311
|
+
existed = (target_dir / "SKILL.md").exists()
|
|
312
|
+
_rewrite_node_md(target_dir / "SKILL.md", node, node_dir)
|
|
313
|
+
if synthesized and not existed: # a freshly-created node (the synth root)
|
|
314
|
+
journal.append({"op": "create", "path": str(target_dir)})
|
|
315
|
+
n += 1
|
|
316
|
+
for c in node.children:
|
|
317
|
+
walk(c, node_dir / c.name)
|
|
318
|
+
|
|
319
|
+
walk(tree.root, root)
|
|
320
|
+
tree.save(manifest)
|
|
321
|
+
if synthesized:
|
|
322
|
+
journal.append({"op": "manifest", "path": str(manifest)})
|
|
323
|
+
(root / JOURNAL).write_text(json.dumps(journal, indent=2), encoding="utf-8")
|
|
324
|
+
return {"ok": True, "nodes": n, "synthesized_root": synthesized,
|
|
325
|
+
"root": skill_name(tree.root),
|
|
326
|
+
"moves": sum(1 for e in journal if e["op"] in ("move", "desymlink")),
|
|
327
|
+
"journal": str(root / JOURNAL) if synthesized else None}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _prune_empty(d: Path, stop: Path) -> None:
|
|
331
|
+
"""Remove `d` and its now-empty parents, up to (not including) `stop`."""
|
|
332
|
+
d = Path(d)
|
|
333
|
+
stop = Path(stop).resolve()
|
|
334
|
+
while d.resolve() != stop and d.is_dir() and not any(d.iterdir()):
|
|
335
|
+
d.rmdir()
|
|
336
|
+
d = d.parent
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def unemit(root: str | Path) -> dict:
|
|
340
|
+
"""Reverse the last tree-ifying `emit` by replaying `.emit-journal.json`
|
|
341
|
+
backwards: move every relocated dir back to where it came from, restore its
|
|
342
|
+
original SKILL.md, delete the synthesized root + manifest, prune the empty
|
|
343
|
+
nesting. A clean, lossless undo — nothing was destroyed, only moved-with-a-receipt."""
|
|
344
|
+
root = Path(root)
|
|
345
|
+
jpath = root / JOURNAL
|
|
346
|
+
if not jpath.exists():
|
|
347
|
+
return {"ok": False, "error": f"no {JOURNAL} — nothing to reverse"}
|
|
348
|
+
journal = json.loads(jpath.read_text(encoding="utf-8"))
|
|
349
|
+
moved = removed = 0
|
|
350
|
+
for entry in reversed(journal):
|
|
351
|
+
op = entry["op"]
|
|
352
|
+
if op == "move":
|
|
353
|
+
frm, to = Path(entry["from"]), Path(entry["to"])
|
|
354
|
+
if to.exists():
|
|
355
|
+
frm.parent.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
shutil.move(str(to), str(frm))
|
|
357
|
+
if entry.get("skill_md"):
|
|
358
|
+
(frm / "SKILL.md").write_text(entry["skill_md"], encoding="utf-8")
|
|
359
|
+
_prune_empty(to.parent, root)
|
|
360
|
+
moved += 1
|
|
361
|
+
elif op == "desymlink":
|
|
362
|
+
sympath, to = Path(entry["symlink"]), Path(entry["to"])
|
|
363
|
+
if to.exists(): # drop the de-symlinked copy
|
|
364
|
+
shutil.rmtree(to)
|
|
365
|
+
_prune_empty(to.parent, root)
|
|
366
|
+
if not (sympath.exists() or sympath.is_symlink()): # recreate the exact link
|
|
367
|
+
sympath.parent.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
os.symlink(entry["link"], sympath)
|
|
369
|
+
moved += 1
|
|
370
|
+
elif op == "create":
|
|
371
|
+
p = Path(entry["path"])
|
|
372
|
+
if p.exists():
|
|
373
|
+
shutil.rmtree(p)
|
|
374
|
+
_prune_empty(p.parent, root)
|
|
375
|
+
removed += 1
|
|
376
|
+
elif op == "manifest":
|
|
377
|
+
p = Path(entry["path"])
|
|
378
|
+
if p.exists():
|
|
379
|
+
p.unlink()
|
|
380
|
+
jpath.unlink()
|
|
381
|
+
return {"ok": True, "moved_back": moved, "removed": removed}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ── notifications: cohere → a self-managed TOP-LEVEL rule (the decoherence cron) ─
|
|
385
|
+
# The lord at the top (`~/.claude`) can't watch the tree by hand, so a background
|
|
386
|
+
# check writes its verdict into ONE user-level rule that leaks into every session's
|
|
387
|
+
# system prompt: "you decohered X — here's the tree shape, run the skill to fix it."
|
|
388
|
+
# The cron is READ-ONLY on the tree (it never emits/relocates — that stays
|
|
389
|
+
# agent-initiated, because emit can move real skills); it only rewrites this rule.
|
|
390
|
+
_SEVERITY = {
|
|
391
|
+
"bare_forest": "ERROR", "missing_node": "ERROR", "coord_drift": "ERROR",
|
|
392
|
+
"stale_breadcrumb": "WARN", "stray_skill": "WARN", "uncoordinated": "WARN",
|
|
393
|
+
"extra_node": "WARN",
|
|
394
|
+
}
|
|
395
|
+
_BADGE = {"ERROR": "🔴", "WARN": "🟡"}
|
|
396
|
+
NOTIFY_RULE = "00-system-notifications.md" # sorts first → seen first in the rule block
|
|
397
|
+
|
|
398
|
+
_NOTIFY_HEAD = (
|
|
399
|
+
"# [SKILLTREE] System Notifications\n\n"
|
|
400
|
+
"> This rule posts system notifications. It is **automatically managed by SkillTree**.\n"
|
|
401
|
+
"> **Do not edit this rule** — it is rewritten by the decoherence check whenever the\n"
|
|
402
|
+
"> SkillTree's on-disk shape drifts from its engineered (coherent) form.\n"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def render_notifications(findings: list[Finding], *, root: str | Path) -> str:
|
|
407
|
+
"""Render the body of the self-managed `00-system-notifications` rule."""
|
|
408
|
+
lines = [_NOTIFY_HEAD, "## Warnings", ""]
|
|
409
|
+
if not findings:
|
|
410
|
+
lines.append("None. Systems nominal.")
|
|
411
|
+
return "\n".join(lines) + "\n"
|
|
412
|
+
errs = [f for f in findings if _SEVERITY.get(f.kind) == "ERROR"]
|
|
413
|
+
warns = [f for f in findings if _SEVERITY.get(f.kind) != "ERROR"]
|
|
414
|
+
for f in errs + warns:
|
|
415
|
+
sev = _SEVERITY.get(f.kind, "WARN")
|
|
416
|
+
lines.append(f"- {_BADGE[sev]} **{sev}** — {f}")
|
|
417
|
+
lines += [
|
|
418
|
+
"", f"## To fix these ({len(errs)} error(s), {len(warns)} warning(s))",
|
|
419
|
+
"",
|
|
420
|
+
f"The SkillTree under `{root}` has **decohered**: its `cat`-breadcrumbs / coordinates "
|
|
421
|
+
"no longer match the tree's real shape (rule 01 — a bare forest or stale wiring).",
|
|
422
|
+
"",
|
|
423
|
+
"**Run the `skilltree` skill** (the recohere flow). In short: `skilltree emit <root>` "
|
|
424
|
+
"re-coheres the tree in place (add `--root` to tree-ify a bare forest). This notice "
|
|
425
|
+
"clears itself on the next check.",
|
|
426
|
+
]
|
|
427
|
+
return "\n".join(lines) + "\n"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def write_notifications(root: str | Path, *, rules_dir: str | Path,
|
|
431
|
+
only_if_changed: bool = True) -> dict:
|
|
432
|
+
"""Run `cohere(root)`, render the verdict, and write the `system_notifications`
|
|
433
|
+
rule into `rules_dir` (e.g. `~/.claude/rules`). Idempotent: only rewrites when
|
|
434
|
+
the content changed (so a nominal tree doesn't churn the file every check)."""
|
|
435
|
+
findings = cohere(root)
|
|
436
|
+
content = render_notifications(findings, root=Path(root))
|
|
437
|
+
rules_dir = Path(rules_dir)
|
|
438
|
+
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
target = rules_dir / NOTIFY_RULE
|
|
440
|
+
changed = (not target.exists()) or target.read_text(encoding="utf-8") != content
|
|
441
|
+
if changed or not only_if_changed:
|
|
442
|
+
target.write_text(content, encoding="utf-8")
|
|
443
|
+
errs = sum(1 for f in findings if _SEVERITY.get(f.kind) == "ERROR")
|
|
444
|
+
return {"findings": len(findings), "errors": errs, "warnings": len(findings) - errs,
|
|
445
|
+
"nominal": not findings, "changed": changed, "path": str(target)}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def watch(root: str | Path, *, rules_dir: str | Path, interval: float = 300,
|
|
449
|
+
iterations: int | None = None, sleep=None) -> dict:
|
|
450
|
+
"""The decoherence loop: every `interval` seconds, re-check the tree and refresh
|
|
451
|
+
the notification rule. `iterations` bounds it (None = forever, for the real cron;
|
|
452
|
+
1 = a single check, for tests). READ-ONLY on the tree — only the rule is written."""
|
|
453
|
+
import time
|
|
454
|
+
_sleep = sleep or time.sleep
|
|
455
|
+
n = 0
|
|
456
|
+
last: dict = {}
|
|
457
|
+
while iterations is None or n < iterations:
|
|
458
|
+
last = write_notifications(root, rules_dir=rules_dir)
|
|
459
|
+
n += 1
|
|
460
|
+
if iterations is not None and n >= iterations:
|
|
461
|
+
break
|
|
462
|
+
_sleep(interval)
|
|
463
|
+
return {"checks": n, "last": last}
|
skilltree/exchange.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Exchange — many skill trees in one repo, under a master.
|
|
2
|
+
|
|
3
|
+
An exchange manifest (JSON or YAML) lists member skill trees and a master name:
|
|
4
|
+
|
|
5
|
+
name: my-exchange
|
|
6
|
+
master: cc-master
|
|
7
|
+
trees:
|
|
8
|
+
- name: cognition
|
|
9
|
+
manifest: trees/cognition.skilltree.json
|
|
10
|
+
- name: writing
|
|
11
|
+
manifest: trees/writing.skilltree.json
|
|
12
|
+
|
|
13
|
+
`build` materializes each member tree under `<repo>/trees/<name>/`, then writes a
|
|
14
|
+
MASTER root whose `cat`-breadcrumbs point into each member tree's root. The master
|
|
15
|
+
is itself a SkillTree-of-trees — the closure again: a tree whose first layer is
|
|
16
|
+
the set of member roots. `validate` checks every member tree AND the master's
|
|
17
|
+
cross-tree breadcrumbs.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
import json
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .materialize import node_skill_md
|
|
26
|
+
from .materialize import materialize
|
|
27
|
+
from .model import SkillTree
|
|
28
|
+
from .validate import Violation, _CRUMB_RE, _has_frontmatter
|
|
29
|
+
from .validate import validate as validate_tree
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Member:
|
|
34
|
+
name: str
|
|
35
|
+
manifest: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Exchange:
|
|
40
|
+
name: str
|
|
41
|
+
master: str
|
|
42
|
+
trees: list[Member]
|
|
43
|
+
base: Path # dir the manifest paths resolve against
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_exchange(path: str | Path) -> Exchange:
|
|
47
|
+
p = Path(path)
|
|
48
|
+
text = p.read_text(encoding="utf-8")
|
|
49
|
+
if p.suffix in (".yaml", ".yml"):
|
|
50
|
+
import yaml
|
|
51
|
+
data = yaml.safe_load(text)
|
|
52
|
+
else:
|
|
53
|
+
data = json.loads(text)
|
|
54
|
+
return Exchange(
|
|
55
|
+
name=data["name"],
|
|
56
|
+
master=data.get("master", "master"),
|
|
57
|
+
trees=[Member(m["name"], m["manifest"]) for m in data.get("trees", [])],
|
|
58
|
+
base=p.resolve().parent,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _front(name: str, description: str, body: str) -> str:
|
|
63
|
+
return f"---\nname: {name}\ndescription: {description}\n---\n\n{body.rstrip()}\n"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build(exchange: Exchange, repo_dir: str | Path) -> Path:
|
|
67
|
+
"""Materialize every member tree + the master. Returns the master SKILL.md path."""
|
|
68
|
+
repo = Path(repo_dir)
|
|
69
|
+
members: list[tuple[str, Path]] = [] # (member name, member root SKILL.md)
|
|
70
|
+
for m in exchange.trees:
|
|
71
|
+
tree = SkillTree.load((exchange.base / m.manifest).resolve())
|
|
72
|
+
tree_dir = repo / "trees" / m.name
|
|
73
|
+
materialize(tree, tree_dir)
|
|
74
|
+
members.append((m.name, node_skill_md(tree_dir, tree.root.name).resolve()))
|
|
75
|
+
|
|
76
|
+
master_md = node_skill_md(repo, exchange.master)
|
|
77
|
+
master_md.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
crumbs = [f"- {name} (tree): Read `{root_md}`" for name, root_md in members]
|
|
79
|
+
body = (f"Master of the **{exchange.name}** exchange — {len(members)} skill tree(s).\n\n"
|
|
80
|
+
"## Trees — pick one and **Read** (the Read tool) into its root\n"
|
|
81
|
+
"Only this master is loaded. Read a tree's root below to load it (a Bash `cat` won't); "
|
|
82
|
+
"each tree then walks its own breadcrumbs:\n\n"
|
|
83
|
+
+ "\n".join(crumbs))
|
|
84
|
+
master_md.write_text(_front(exchange.master, f"master of the {exchange.name} exchange", body),
|
|
85
|
+
encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
(repo / "exchange.lock.json").write_text(json.dumps(
|
|
88
|
+
{"name": exchange.name, "master": exchange.master,
|
|
89
|
+
"trees": [{"name": n, "root": str(r)} for n, r in members]}, indent=2), encoding="utf-8")
|
|
90
|
+
return master_md
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate(repo_dir: str | Path, exchange: Exchange) -> list[Violation]:
|
|
94
|
+
repo = Path(repo_dir)
|
|
95
|
+
out: list[Violation] = []
|
|
96
|
+
# 1. each member tree validates on its own
|
|
97
|
+
for m in exchange.trees:
|
|
98
|
+
for v in validate_tree(repo / "trees" / m.name):
|
|
99
|
+
out.append(Violation(v.severity, f"{m.name}/{v.where}", v.message))
|
|
100
|
+
# 2. the master root + its cross-tree breadcrumbs
|
|
101
|
+
master_md = node_skill_md(repo, exchange.master)
|
|
102
|
+
if not master_md.is_file():
|
|
103
|
+
out.append(Violation("error", exchange.master, f"missing master SKILL.md at {master_md}"))
|
|
104
|
+
return out
|
|
105
|
+
if not _has_frontmatter(master_md):
|
|
106
|
+
out.append(Violation("error", exchange.master, "master SKILL.md lacks frontmatter"))
|
|
107
|
+
found = {Path(p).resolve() for p in _CRUMB_RE.findall(master_md.read_text(encoding="utf-8"))}
|
|
108
|
+
expected = {
|
|
109
|
+
node_skill_md(repo / "trees" / m.name, SkillTree.load((exchange.base / m.manifest).resolve()).root.name).resolve()
|
|
110
|
+
for m in exchange.trees
|
|
111
|
+
}
|
|
112
|
+
for missing in expected - found:
|
|
113
|
+
out.append(Violation("error", exchange.master, f"no master breadcrumb for tree root → {missing}"))
|
|
114
|
+
for p in found:
|
|
115
|
+
if not Path(p).is_file():
|
|
116
|
+
out.append(Violation("error", exchange.master, f"dead master breadcrumb: `cat {p}`"))
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_valid(repo_dir: str | Path, exchange: Exchange) -> bool:
|
|
121
|
+
return not any(v.severity == "error" for v in validate(repo_dir, exchange))
|