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.
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))