leyline-code 0.8.1__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.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/agent_skills.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""The skills that ship with Leyline: one folder each, with a SKILL.md that tells a coding agent how to do one job
|
|
2
|
+
with the map (answer a question, plan a change, review a pull request).
|
|
3
|
+
|
|
4
|
+
The folders under `skills/` at the top of the repository are the only copy kept by hand. A wheel carries them as
|
|
5
|
+
`leyline/skills/` (pyproject.toml's force-include), so an installed Leyline finds them beside this module; a checkout
|
|
6
|
+
finds the top-level folder. A new folder there is picked up by the CLI, the MCP server and the wheel with no other
|
|
7
|
+
edit.
|
|
8
|
+
|
|
9
|
+
`install` copies them into a repository's `.claude/skills/` or `.agents/skills/` and records each file's hash in
|
|
10
|
+
`.leyline-skills.json` there, so a later install updates a copy nobody edited and leaves an edited one alone."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import textwrap
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from . import __version__, store
|
|
24
|
+
|
|
25
|
+
HERE = Path(__file__).resolve().parent
|
|
26
|
+
MANIFEST = ".leyline-skills.json"
|
|
27
|
+
NAME = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
28
|
+
TARGETS = {"claude": ".claude/skills", "agents": ".agents/skills"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def skills_dir() -> Optional[Path]:
|
|
32
|
+
"""Where the skills are: beside this module in an installed wheel, else `skills/` at the top of a checkout."""
|
|
33
|
+
for d in (HERE / "skills", HERE.parents[1] / "skills"):
|
|
34
|
+
if d.is_dir() and any(d.glob("*/SKILL.md")):
|
|
35
|
+
return d
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def frontmatter(text: str) -> tuple[dict, str]:
|
|
40
|
+
"""The `key: value` lines of a SKILL.md's frontmatter, and the text after it. Only the flat form skills use."""
|
|
41
|
+
if not text.startswith("---\n"):
|
|
42
|
+
return {}, text
|
|
43
|
+
end = text.find("\n---\n", 3)
|
|
44
|
+
if end < 0:
|
|
45
|
+
return {}, text
|
|
46
|
+
meta = {}
|
|
47
|
+
for line in text[4:end].splitlines():
|
|
48
|
+
if ":" in line and not line.startswith((" ", "\t", "#")):
|
|
49
|
+
key, value = line.split(":", 1)
|
|
50
|
+
value = value.strip()
|
|
51
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
52
|
+
value = value[1:-1]
|
|
53
|
+
meta[key.strip()] = value
|
|
54
|
+
return meta, text[end + 5:].lstrip("\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Skill:
|
|
59
|
+
folder: Path
|
|
60
|
+
meta: dict
|
|
61
|
+
body: str
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def name(self) -> str:
|
|
65
|
+
return self.meta.get("name") or self.folder.name
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def description(self) -> str:
|
|
69
|
+
return self.meta.get("description", "")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def text(self) -> str:
|
|
73
|
+
return (self.folder / "SKILL.md").read_text(encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
def files(self) -> dict[str, bytes]:
|
|
76
|
+
"""Every file of the skill, by its path inside the folder."""
|
|
77
|
+
return {p.relative_to(self.folder).as_posix(): p.read_bytes() for p in sorted(self.folder.rglob("*"))
|
|
78
|
+
if p.is_file() and "__pycache__" not in p.parts and p.name != MANIFEST}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read(folder: Path) -> Skill:
|
|
82
|
+
meta, body = frontmatter((folder / "SKILL.md").read_text(encoding="utf-8"))
|
|
83
|
+
return Skill(folder, meta, body)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def available() -> list[Skill]:
|
|
87
|
+
d = skills_dir()
|
|
88
|
+
return [read(f.parent) for f in sorted(d.glob("*/SKILL.md"))] if d else []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def find(name: str) -> Optional[Skill]:
|
|
92
|
+
"""A skill by its name, with or without the `leyline-` in front."""
|
|
93
|
+
by = {s.name: s for s in available()}
|
|
94
|
+
return by.get(name) or by.get("leyline-" + name)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def problems(s: Skill) -> list[str]:
|
|
98
|
+
"""What is wrong with a skill's frontmatter, as an agent reads it: none when it is right."""
|
|
99
|
+
out = []
|
|
100
|
+
if not s.meta:
|
|
101
|
+
return ["SKILL.md does not start with frontmatter (--- name: ... description: ... ---)"]
|
|
102
|
+
name, desc = s.meta.get("name", ""), s.meta.get("description", "")
|
|
103
|
+
if not NAME.match(name) or len(name) > 64:
|
|
104
|
+
out.append(f"name {name!r} is not lower case words joined by hyphens, 64 characters at most")
|
|
105
|
+
if name != s.folder.name:
|
|
106
|
+
out.append(f"name {name!r} is not the folder's name, {s.folder.name!r}")
|
|
107
|
+
if not desc or len(desc) > 1024:
|
|
108
|
+
out.append("description is missing or longer than 1,024 characters")
|
|
109
|
+
if "<" in desc or ">" in desc:
|
|
110
|
+
out.append("description has < or >, which agents may read as markup")
|
|
111
|
+
if not s.body.startswith("# "):
|
|
112
|
+
out.append("the text after the frontmatter does not open with a # heading")
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# -- installing into a repository ------------------------------------------------------------------
|
|
117
|
+
def _sha(data: bytes) -> str:
|
|
118
|
+
return hashlib.sha256(data).hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def repo_root(start: Path) -> Path:
|
|
122
|
+
"""The repository around `start` (the nearest folder with .git), else `start` itself."""
|
|
123
|
+
start = start.resolve()
|
|
124
|
+
for d in (start, *start.parents):
|
|
125
|
+
if (d / ".git").exists():
|
|
126
|
+
return d
|
|
127
|
+
return start
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def targets(root: Path, which: Optional[str]) -> list[dict]:
|
|
131
|
+
"""The skills folders to install into: `claude`, `agents` or `both`; by default both when the repository has an
|
|
132
|
+
.agents folder, else .claude. A folder that is a link to another one listed is written once."""
|
|
133
|
+
which = which or ("both" if (root / ".agents").is_dir() else "claude")
|
|
134
|
+
out: list[dict] = []
|
|
135
|
+
for key in (("claude", "agents") if which == "both" else (which,)):
|
|
136
|
+
path = root / TARGETS[key]
|
|
137
|
+
same = next((t for t in out if t["path"].resolve() == path.resolve()), None)
|
|
138
|
+
if same and same["path"].is_symlink() and not path.is_symlink(): # name the real folder, and the link to it
|
|
139
|
+
same["also"].append(same["label"])
|
|
140
|
+
same["path"], same["label"] = path, TARGETS[key]
|
|
141
|
+
elif same:
|
|
142
|
+
same["also"].append(TARGETS[key])
|
|
143
|
+
else:
|
|
144
|
+
out.append({"path": path, "label": TARGETS[key], "also": []})
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _manifest(folder: Path) -> dict:
|
|
149
|
+
try:
|
|
150
|
+
m = json.loads((folder / MANIFEST).read_text(encoding="utf-8"))
|
|
151
|
+
return m if isinstance(m, dict) and isinstance(m.get("skills"), dict) else {"skills": {}}
|
|
152
|
+
except (OSError, ValueError, RecursionError):
|
|
153
|
+
return {"skills": {}}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _copy(s: Skill, dest: Path, shipped: dict[str, bytes], drop: list[str]) -> None:
|
|
157
|
+
if dest.is_symlink() or dest.is_file():
|
|
158
|
+
dest.unlink()
|
|
159
|
+
for rel in drop: # files an earlier version shipped and this one does not, left as installed
|
|
160
|
+
(dest / rel).unlink(missing_ok=True)
|
|
161
|
+
for rel, data in shipped.items():
|
|
162
|
+
(dest / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
store.write_file(dest / rel, data)
|
|
164
|
+
shutil.copymode(s.folder / rel, dest / rel)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def install_one(s: Skill, folder: Path, manifest: dict, force: bool) -> dict:
|
|
168
|
+
"""Install one skill into one skills folder: installed, updated, unchanged, replaced (--force) or kept (edited
|
|
169
|
+
here since it was installed, or not installed by Leyline)."""
|
|
170
|
+
dest = folder / s.name
|
|
171
|
+
shipped = s.files()
|
|
172
|
+
want = {rel: _sha(b) for rel, b in shipped.items()}
|
|
173
|
+
record = (manifest["skills"].get(s.name) or {}).get("files") or {}
|
|
174
|
+
if not dest.exists() and not dest.is_symlink():
|
|
175
|
+
state, drop = "installed", []
|
|
176
|
+
elif not dest.is_dir() or dest.is_symlink():
|
|
177
|
+
if not force:
|
|
178
|
+
return {"skill": s.name, "state": "kept", "why": f"{dest.name} here is not a folder; --force replaces it"}
|
|
179
|
+
state, drop = "replaced", []
|
|
180
|
+
else:
|
|
181
|
+
now = {rel: _sha((dest / rel).read_bytes()) if (dest / rel).is_file() else None for rel in {*want, *record}}
|
|
182
|
+
gone = [rel for rel in record if rel not in want and now.get(rel) is not None]
|
|
183
|
+
if all(now[rel] == want[rel] for rel in want) and not gone:
|
|
184
|
+
state, drop = "unchanged", []
|
|
185
|
+
else:
|
|
186
|
+
edited = sorted(rel for rel in {*want, *record} if now.get(rel) != record.get(rel)) if record else []
|
|
187
|
+
if record and not edited:
|
|
188
|
+
state, drop = "updated", gone
|
|
189
|
+
elif force:
|
|
190
|
+
state, drop = "replaced", gone
|
|
191
|
+
else:
|
|
192
|
+
why = (f"edited here since it was installed ({', '.join(edited)})" if record else
|
|
193
|
+
"a different copy is here that Leyline did not install")
|
|
194
|
+
return {"skill": s.name, "state": "kept", "why": why + "; --force replaces it"}
|
|
195
|
+
if state != "unchanged":
|
|
196
|
+
_copy(s, dest, shipped, drop)
|
|
197
|
+
manifest["skills"][s.name] = {"files": want, "version": __version__}
|
|
198
|
+
return {"skill": s.name, "state": state}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def install(root: Path, which: Optional[str] = None, force: bool = False, names: Optional[list[str]] = None) -> dict:
|
|
202
|
+
"""Copy the skills into the repository at `root`. Returns, per skills folder, what happened to each skill."""
|
|
203
|
+
skills = available()
|
|
204
|
+
if names:
|
|
205
|
+
picked = [find(n) for n in names]
|
|
206
|
+
missing = [n for n, s in zip(names, picked) if s is None]
|
|
207
|
+
if missing:
|
|
208
|
+
return {"error": f"no skill named {', '.join(missing)}. `leyline skills list` names them."}
|
|
209
|
+
skills = [s for s in picked if s is not None]
|
|
210
|
+
if not skills:
|
|
211
|
+
return {"error": "this Leyline has no skills to install: its skills folder is missing."}
|
|
212
|
+
out = []
|
|
213
|
+
for t in targets(root, which):
|
|
214
|
+
t["path"].mkdir(parents=True, exist_ok=True)
|
|
215
|
+
manifest = _manifest(t["path"])
|
|
216
|
+
results = [install_one(s, t["path"], manifest, force) for s in skills]
|
|
217
|
+
manifest["leyline"] = __version__
|
|
218
|
+
store.write_file(t["path"] / MANIFEST, json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
|
219
|
+
out.append({"folder": t["label"], "path": str(t["path"]), "also": t["also"], "skills": results})
|
|
220
|
+
return {"root": str(root), "targets": out}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# -- text for a person -----------------------------------------------------------------------------
|
|
224
|
+
EXAMPLES = { # what a person might ask that each skill answers; the first four installed are shown after an install
|
|
225
|
+
"leyline-ask": "who calls the save function, and how sure is that?",
|
|
226
|
+
"leyline-explain-flow": "what happens when a user saves a file?",
|
|
227
|
+
"leyline-explore-module": "what is in editor/host, and where do I start?",
|
|
228
|
+
"leyline-quick-change": "make the retry count 3",
|
|
229
|
+
"leyline-pr-review": "review this pull request",
|
|
230
|
+
"leyline-spec": "plan adding an export button, with tests",
|
|
231
|
+
"leyline-change-impact": "what would changing the validator's output format affect?",
|
|
232
|
+
"leyline-adversarial-review": "stress-test the plan for loud-engine",
|
|
233
|
+
"leyline-tour": "write a tour of the payment code for a new hire",
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def list_text() -> str:
|
|
238
|
+
skills = available()
|
|
239
|
+
if not skills:
|
|
240
|
+
return "This Leyline has no skills: its skills folder is missing."
|
|
241
|
+
L = []
|
|
242
|
+
for s in skills:
|
|
243
|
+
L.append(s.name)
|
|
244
|
+
L += textwrap.wrap(s.description, 100, initial_indent=" ", subsequent_indent=" ")
|
|
245
|
+
L += ["", "Read one: `leyline skills show <name>`. Give them to your coding agent: `leyline skills install` in the"
|
|
246
|
+
" repository."]
|
|
247
|
+
return "\n".join(L)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def install_text(r: dict) -> str:
|
|
251
|
+
words = {"installed": "installed", "updated": "updated", "unchanged": "up to date", "replaced": "replaced",
|
|
252
|
+
"kept": "kept"}
|
|
253
|
+
L = []
|
|
254
|
+
for t in r["targets"]:
|
|
255
|
+
also = f" ({', '.join(t['also'])} {'links' if len(t['also']) == 1 else 'link'} to it)" if t["also"] else ""
|
|
256
|
+
L.append(f"{t['folder']}{also}:")
|
|
257
|
+
for x in t["skills"]:
|
|
258
|
+
L.append(f" {words[x['state']]:<11} {x['skill']}" + (f": {x['why']}" if x.get("why") else ""))
|
|
259
|
+
kept = [x["skill"] for t in r["targets"] for x in t["skills"] if x["state"] == "kept"]
|
|
260
|
+
names = {x["skill"] for t in r["targets"] for x in t["skills"]}
|
|
261
|
+
ask = [f" \"{q}\" ({n})" for n, q in EXAMPLES.items() if n in names][:4]
|
|
262
|
+
folders = " and ".join(f for t in r["targets"] for f in (t["folder"], *t["also"]))
|
|
263
|
+
wrap = lambda s: textwrap.wrap(s, 100)
|
|
264
|
+
L += ["", *wrap(f"Your agent reads skills from {folders} when a session starts: start a new one if one is open."
|
|
265
|
+
" Then ask in plain words, and it picks the skill. For example:"), *ask,
|
|
266
|
+
"The skills use Leyline's MCP server. Connect it once, in the repository:",
|
|
267
|
+
" claude mcp add leyline -- leyline serve",
|
|
268
|
+
"Without it, they fall back to the leyline command.",
|
|
269
|
+
"Commit the folder if the rest of the team should have them too."]
|
|
270
|
+
if kept:
|
|
271
|
+
L.append(f"{len(kept)} kept as edited here; `leyline skills install --force` replaces them with Leyline's.")
|
|
272
|
+
return "\n".join(L)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def start_here() -> str:
|
|
276
|
+
"""Where an agent connected to the server starts, for the server's instructions."""
|
|
277
|
+
names = [s.name for s in available()]
|
|
278
|
+
return ("\n\nStart here, by what the person wants: `map` once if nothing is mapped. To understand code, follow"
|
|
279
|
+
" the leyline-ask skill (how something runs: leyline-explain-flow; what a part holds: leyline-explore-module)."
|
|
280
|
+
" To change code: leyline-spec (`plan`) when there is a design to agree, leyline-quick-change (`quick`) for"
|
|
281
|
+
" a small fix, leyline-pr-review (`review_pr`) for code someone else wrote. Then leyline-adversarial-review"
|
|
282
|
+
" (`spec_review_facts`), the person decides each finding, the code is written, and `check` (or `quick` with"
|
|
283
|
+
" done) gives the verdict. Each skill is an MCP prompt of the same name; the `skills` tool lists them and"
|
|
284
|
+
" returns one's text." + (f" Skills: {', '.join(names)}." if names else ""))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cli(args) -> int:
|
|
288
|
+
"""`leyline skills list | show <name> | install [names] [--to DIR] [--claude|--agents|--both] [--force]`."""
|
|
289
|
+
import sys
|
|
290
|
+
if args.action == "list":
|
|
291
|
+
print(list_text())
|
|
292
|
+
return 0 if available() else 1
|
|
293
|
+
if args.action == "show":
|
|
294
|
+
s = find(args.names[0]) if args.names else None
|
|
295
|
+
if s is None:
|
|
296
|
+
print(f"leyline: no skill named {args.names[0] if args.names else '(none given)'}. `leyline skills list`"
|
|
297
|
+
" names them.", file=sys.stderr)
|
|
298
|
+
return 2
|
|
299
|
+
print(s.text, end="")
|
|
300
|
+
return 0
|
|
301
|
+
root = Path(args.to) if args.to else repo_root(Path.cwd())
|
|
302
|
+
if not root.is_dir():
|
|
303
|
+
print(f"leyline: {root} is not a directory. Name the repository with --to.", file=sys.stderr)
|
|
304
|
+
return 2
|
|
305
|
+
r = install(root, args.where, args.force, args.names or None)
|
|
306
|
+
if "error" in r:
|
|
307
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
308
|
+
return 2
|
|
309
|
+
print(f"Leyline's skills, into {root}:")
|
|
310
|
+
print(install_text(r))
|
|
311
|
+
return 0
|