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.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/server.py ADDED
@@ -0,0 +1,1230 @@
1
+ """MCP server over a Leyline store. map, plan and check first; then reads of the graph, and writes of annotations,
2
+ views, proposals, rules and test runs.
3
+
4
+ Every tool goes through `_tool`, which turns a missing store, a bad argument or a crash into an error the agent
5
+ can act on, and keeps each answer small enough to read: lists are cut to a stated number of items and the whole
6
+ answer to about LIMIT characters, with `cut` saying what was cut and how to see the rest."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import inspect
12
+ import json
13
+ import os
14
+ import re
15
+ import sqlite3
16
+ import threading
17
+ from pathlib import Path
18
+ from typing import Annotated, Literal, Optional
19
+
20
+ from pydantic import Field
21
+ from typing_extensions import NotRequired, TypedDict
22
+
23
+ try: # mcp 2.x
24
+ from mcp.server.mcpserver import MCPServer as FastMCP
25
+ from mcp.server.mcpserver.exceptions import ToolError
26
+ except ImportError: # mcp 1.x
27
+ from mcp.server.fastmcp import FastMCP
28
+ from mcp.server.fastmcp.exceptions import ToolError
29
+
30
+ from . import change as change_mod, coverage as measured, diff, loop, spec as spec_loop, patterns as pattern_labels, query, rules, store, tours as tour_store
31
+ from . import agent_skills
32
+
33
+ mcp = FastMCP(
34
+ "leyline",
35
+ instructions=(
36
+ "Leyline is a map of a codebase (a graph in SQLite) for a loop in which a person decides the design of a change"
37
+ " and agents write the code. The loop, in order:\n"
38
+ "1. `map` with the repository directory, once, if a tool says nothing is mapped.\n"
39
+ "2. Write the change as an OpenSpec folder, openspec/changes/<id>/ (proposal.md, specs/<area>/spec.md with"
40
+ " scenarios, tasks.md naming code in backticks). Before writing, look around with `overview`, `search`,"
41
+ " `expand` and `impact`.\n"
42
+ "3. `plan` with the change and the test output from before any edit. It returns `status.blocking` (fix the spec"
43
+ " and call `plan` again until it is empty) and the page `leyline.md`.\n"
44
+ "4. Review: `spec_review_facts` once with reviewer=logic and once with reviewer=performance; file real problems"
45
+ " with `spec_finding`. Only the person decides a finding; record their decision with `spec_resolve`. Then `plan`"
46
+ " again.\n"
47
+ "5. Implement the tasks.\n"
48
+ "6. `check` with the change and the test output from after. Report its verdict (`done_as_agreed`, `why_not`),"
49
+ " not your own account.\n"
50
+ "Every answer from `map`, `plan` and `check` ends with `next`: do that. `plan` and `check` re-map changed code"
51
+ " themselves. Node ids come from `search`, `overview` and `expand`; never guess one. Edges marked heuristic come"
52
+ " from syntax and can be wrong. Long lists are cut: `cut` says what was cut and `more` how to see the rest.\n"
53
+ "To answer how something works, outside the loop: `find_flows` with the question in words, then `explain_path`"
54
+ " from the candidate that fits, reading the code (`source`) at the steps that matter."
55
+ ) + agent_skills.start_here(),
56
+ )
57
+
58
+ LIMIT = 24_000 # characters; an answer much longer than this crowds out the rest of an agent's context
59
+
60
+ _local = threading.local()
61
+ _lock = threading.Lock() # one re-index at a time
62
+ _generation = [0] # bumped on every re-index, so each thread opens the store afresh
63
+
64
+
65
+ def _path() -> str:
66
+ return os.environ.get("LEYLINE_DB") or ".leyline/leyline.db"
67
+
68
+
69
+ _START = Path.cwd().resolve() # where the server was started: the place of the first map
70
+
71
+
72
+ def _repository_of(folder: Path) -> Optional[Path]:
73
+ """The git repository `folder` is in, when it is in one: the server is started where the agent was opened, which
74
+ may be a folder inside the repository it is to map."""
75
+ from .loop import repository_of
76
+ return repository_of(folder)
77
+
78
+
79
+ def _roots() -> list[Path]:
80
+ """The repositories the store maps, resolved; none before anything is mapped."""
81
+ if not Path(_path()).is_file():
82
+ return []
83
+ try:
84
+ return [Path(r).resolve() for r in store.roots(_db()).values()]
85
+ except (sqlite3.Error, OSError):
86
+ return []
87
+
88
+
89
+ def _outside(path, what: str) -> Optional[str]:
90
+ """An error when a path an agent passed is outside the repositories the store maps (before anything is mapped,
91
+ the directory the server started in, and the repository it is a folder of). The agent may have read untrusted
92
+ text naming the path (map ~/, plan a folder elsewhere, import a file from anywhere); the person at the command
93
+ line is not limited."""
94
+ allowed = _roots()
95
+ if not allowed:
96
+ allowed = [_START] + [top for top in [_repository_of(_START)] if top and top != _START]
97
+ try:
98
+ p = Path(path).resolve()
99
+ except (OSError, RuntimeError):
100
+ return f"{what} {path!r} cannot be resolved."
101
+ if any(p == r or r in p.parents for r in allowed):
102
+ return None
103
+ return (f"{what} {path!r} ({p}) is outside the repositories this server maps ({', '.join(map(str, allowed))}):"
104
+ " a tool reads and writes only inside them. If the person wants it, they can run the `leyline` command"
105
+ " themselves, or start the server in that repository.")
106
+
107
+
108
+ def _db() -> sqlite3.Connection:
109
+ # The server may run each tool call on a different worker thread, and a SQLite connection
110
+ # belongs to the thread that opened it. Keep one per thread, for the store the path names now.
111
+ con = getattr(_local, "con", None)
112
+ where = str(Path(_path()).resolve())
113
+ if con is None or getattr(_local, "gen", None) != _generation[0] or getattr(_local, "where", None) != where:
114
+ if con is not None:
115
+ con.close()
116
+ con = _local.con = store.connect(_path())
117
+ _local.gen, _local.where = _generation[0], where
118
+ return con
119
+
120
+
121
+ def _missing() -> Optional[str]:
122
+ """Why there is nothing to read, or None. Checked before connecting, since connecting creates an empty store."""
123
+ p = Path(_path())
124
+ if not p.is_file():
125
+ return (f"Nothing is mapped yet: there is no store at {p.resolve()}. Call `map` with the repository directory"
126
+ f" (paths=[\".\"] for {Path.cwd()}), then try again.")
127
+ if _db().execute("SELECT 1 FROM nodes WHERE kind = 'repo' LIMIT 1").fetchone() is None:
128
+ return (f"The store at {p.resolve()} is empty, or is being written by `leyline map` right now. If a map is"
129
+ " running, retry in a minute; otherwise call `map` with the repository directory.")
130
+ return None
131
+
132
+
133
+ # -- keeping answers small --------------------------------------------------------------------------
134
+ def _dump(x) -> str:
135
+ return json.dumps(x, ensure_ascii=False, default=str, separators=(",", ":"))
136
+
137
+
138
+ def _cap(x, n: int, keep: tuple = (), cut: Optional[dict] = None, path: str = "") -> dict:
139
+ """Cut every list in `x` longer than `n` to its first `n` items, in place; a list under a key in `keep` stays
140
+ whole (the lists inside it are still cut). Returns {path: "kept of total"} for what was cut."""
141
+ cut = {} if cut is None else cut
142
+ items = x.items() if isinstance(x, dict) else enumerate(x) if isinstance(x, list) else ()
143
+ for k, v in list(items):
144
+ p = f"{path}.{k}" if isinstance(k, str) and path else (k if isinstance(k, str) else f"{path}[]")
145
+ if isinstance(v, list) and len(v) > n and k not in keep:
146
+ cut.setdefault(p, f"{n} of {len(v)}")
147
+ x[k] = v = v[:n]
148
+ if isinstance(v, (dict, list)):
149
+ _cap(v, n, keep, cut, p)
150
+ return cut
151
+
152
+
153
+ CUT_MARK = re.compile(r"\n\[\.\.\. cut here: ([\d,]+) characters in all\]$")
154
+
155
+
156
+ def _uncut(s: str) -> tuple[str, int]:
157
+ """A string as it was before `_fit` cut it, as far as it is kept: (what is left of it, its whole length)."""
158
+ m = CUT_MARK.search(s)
159
+ return (s[:m.start()], int(m[1].replace(",", ""))) if m else (s, len(s))
160
+
161
+
162
+ def _largest(x, path: str = "", best=None, floor: tuple = (3, 1000)):
163
+ """The longest list (of more than floor[0] items) or string (over floor[1] characters, not counting the mark of
164
+ an earlier cut) inside x, as (size, holder, key, path)."""
165
+ items = x.items() if isinstance(x, dict) else enumerate(x) if isinstance(x, list) else ()
166
+ for k, v in items:
167
+ p = f"{path}.{k}" if isinstance(k, str) and path else (k if isinstance(k, str) else f"{path}[]")
168
+ if (isinstance(v, list) and len(v) > floor[0]) or (isinstance(v, str) and len(_uncut(v)[0]) > floor[1]):
169
+ size = len(_dump(v))
170
+ if best is None or size > best[0]:
171
+ best = (size, x, k, p)
172
+ if isinstance(v, (dict, list)):
173
+ best = _largest(v, p, best, floor)
174
+ return best
175
+
176
+
177
+ MORE = ("Lists were cut to keep this answer short; `cut` says which (shown of total). To see the rest, narrow the call"
178
+ " (a node id, `scope`, `kind` or `through`), raise `limit`, or page with `offset` where the tool has it.")
179
+
180
+
181
+ def _fit(out: dict, cut: dict, limit: int = LIMIT, more: str = MORE) -> str:
182
+ """The answer as compact JSON of at most about `limit` characters: the longest lists and strings are
183
+ shortened until it fits (to 3 items and 1,000 characters, then, when that is not enough, to 1 item and 200
184
+ characters), and `cut` says what was cut."""
185
+ text = _dump(out)
186
+ for floor in ((3, 1000), (1, 200)):
187
+ for _ in range(200):
188
+ if len(text) <= limit:
189
+ break
190
+ best = _largest(out, floor=floor)
191
+ if best is None:
192
+ break
193
+ size, holder, key, p = best
194
+ v, excess = holder[key], len(text) - limit + 200
195
+ if isinstance(v, list):
196
+ keep = max(floor[0], min(len(v) - 1, len(v) - -(-excess * len(v) // max(size, 1))))
197
+ cut[p] = f"{keep} of {cut[p].split(' of ')[1] if p in cut else len(v)}"
198
+ holder[key] = v[:keep]
199
+ else:
200
+ body, whole = _uncut(v) # a string cut before is cut again from what is left, and keeps its length
201
+ keep = max(floor[1], len(body) - excess)
202
+ holder[key] = body[:keep] + f"\n[... cut here: {whole:,} characters in all]"
203
+ cut[p] = f"{keep:,} of {whole:,} characters"
204
+ text = _dump({**out, "cut": cut, "more": more} if cut else out)
205
+ if cut:
206
+ out["cut"], out["more"] = cut, more
207
+ text = _dump(out)
208
+ return text
209
+
210
+
211
+ def _tool(fn=None, *, name: Optional[str] = None, needs_store: bool = True, items: int = 0, keep: tuple = (),
212
+ more: str = MORE, limit: int = LIMIT):
213
+ """Register `fn` as a tool. Its answer is a dict; one with "error" is returned as a tool error. `items` caps
214
+ every list in the answer (0 leaves them to the size limit alone); lists under keys in `keep` stay whole.
215
+ `more` tells the agent how to see what was cut; `limit` is the answer's size in characters."""
216
+ if fn is None:
217
+ return lambda f: _tool(f, name=name, needs_store=needs_store, items=items, keep=keep, more=more, limit=limit)
218
+
219
+ @functools.wraps(fn)
220
+ def run(*args, **kwargs):
221
+ try:
222
+ why = _missing() if needs_store else None
223
+ out = {"error": why} if why else fn(*args, **kwargs)
224
+ except store.UntrustedStore as e:
225
+ out = {"error": str(e)}
226
+ except sqlite3.OperationalError as e:
227
+ # A map running in another process holds the write lock, or changed the tables under this connection.
228
+ _generation[0] += 1
229
+ out = {"error": f"The store could not be read ({e}). A map may be running; retry in a minute."}
230
+ except Exception as e: # the agent gets the reason, not a traceback
231
+ out = {"error": f"{fn.__name__} failed: {type(e).__name__}: {e}"}
232
+ if not isinstance(out, dict):
233
+ out = {"result": out}
234
+ if "error" in out:
235
+ rest = {k: v for k, v in out.items() if k != "error"}
236
+ raise ToolError(_for_agent(str(out["error"])) + (" " + _fit(rest, _cap(rest, 10)) if rest else ""))
237
+ cut = out.pop("cut") if isinstance(out.get("cut"), dict) else {} # what the tool itself cut
238
+ cut.update(_cap(out, items, keep) if items else {})
239
+ return _fit(out, cut, limit=limit, more=more)
240
+
241
+ # No return annotation: the answer is text (compact JSON), not a structured result to validate.
242
+ run.__signature__ = inspect.signature(fn, eval_str=True).replace(return_annotation=inspect.Signature.empty)
243
+ del run.__wrapped__
244
+ return mcp.tool(name=name or fn.__name__)(run)
245
+
246
+
247
+ # -- argument types ---------------------------------------------------------------------------------
248
+ NodeId = Annotated[str, Field(description="A node id exactly as `search`, `overview` or `expand` returned it.")]
249
+ ChangeArg = Annotated[str, Field(description="The change: its folder (openspec/changes/<id>, absolute or relative to the"
250
+ " server's directory) or just its id.")]
251
+ TestOutput = Annotated[Optional[str], Field(description="The test runner's output as text: " + diff.READS + "."
252
+ " Use this or test_results.")]
253
+
254
+
255
+ class TestResult(TypedDict):
256
+ name: Annotated[str, Field(description="The test's name as the runner prints it")]
257
+ status: Literal["pass", "fail", "skip"]
258
+ message: NotRequired[str]
259
+
260
+
261
+ TestResults = Annotated[Optional[list[TestResult]], Field(description="The results as a list, for runner output that"
262
+ " test_output cannot read.")]
263
+ NodeKind = Literal["module", "file", "type", "callable", "field", "test", "entry_point", "external", "system", "repo"]
264
+ EdgeKind = Literal["calls", "imports", "uses_type", "extends", "implements", "instantiates", "has_field", "exposes",
265
+ "depends_on", "reads", "writes", "communicates", "overrides", "groups"]
266
+ Limit = Annotated[int, Field(ge=1, le=500, description="Items to show in each list (each list also gives its total).")]
267
+ Offset = Annotated[int, Field(ge=0, description="Items to skip, to page through a long list.")]
268
+
269
+
270
+ class Target(TypedDict):
271
+ id: NotRequired[Annotated[str, Field(description="The node id of what changes (not needed for action add)")]]
272
+ action: Literal["behavior", "signature", "rename", "remove", "add"]
273
+ note: NotRequired[Annotated[str, Field(description="Why it changes")]]
274
+ name: NotRequired[Annotated[str, Field(description="add only: the new member's name")]]
275
+ parent: NotRequired[Annotated[str, Field(description="add only: id of the type, file or module it goes in")]]
276
+ uses: NotRequired[Annotated[list[str], Field(description="add only: ids it will call")]]
277
+ used_by: NotRequired[Annotated[list[str], Field(description="add only: ids that will call it")]]
278
+
279
+
280
+ class Mark(TypedDict):
281
+ id: Annotated[str, Field(description="A node id")]
282
+ role: NotRequired[Annotated[str, Field(description="A short label for its group")]]
283
+ note: NotRequired[Annotated[str, Field(description="One line on why it is here")]]
284
+
285
+
286
+ class Stop(TypedDict):
287
+ title: str
288
+ kind: Literal["node", "flow", "pattern", "view", "repo"]
289
+ ref: Annotated[str, Field(description="The id of what the stop points at")]
290
+ narrative: Annotated[str, Field(description="What to notice here and why it matters")]
291
+
292
+
293
+ def _change(arg: str) -> tuple[Optional[Path], Optional[str]]:
294
+ """A change folder from a folder path, an id or a spec id (spec-<id>), with an error when there is none."""
295
+ folder = loop.find_change(arg, _path())
296
+ if folder is None and arg.startswith("spec-"):
297
+ folder = loop.find_change(arg[5:], _path())
298
+ if folder is not None and (why := _outside(folder, "The change folder")):
299
+ return None, why
300
+ if folder is None or not (folder / "tasks.md").is_file() and not (folder / "proposal.md").is_file():
301
+ return None, (f"No change folder {arg!r}: looked for it as a path from {Path.cwd()} and under openspec/changes/"
302
+ " in the mapped repositories. Write the change first, as openspec/changes/<id>/ with proposal.md,"
303
+ " specs/ and tasks.md, or pass the folder's full path.")
304
+ return folder, None
305
+
306
+
307
+ def _change_id(arg: str) -> tuple[Optional[str], Optional[str]]:
308
+ """The stored id of a planned change (spec-<id>), from a folder, an id or the stored id itself."""
309
+ con = _db()
310
+ for cid in (arg, "spec-" + arg, "spec-" + Path(arg).name):
311
+ if con.execute("SELECT 1 FROM change_proposals WHERE id = ?", (cid,)).fetchone():
312
+ return cid, None
313
+ return None, f"No planned change {arg!r}. Call `plan` with the change folder first."
314
+
315
+
316
+ def _results(test_output: Optional[str], test_results: Optional[list[dict]]):
317
+ """Test results from runner output or from a list: (results or None, error or None)."""
318
+ if test_results is not None:
319
+ return [dict(r) for r in test_results], None
320
+ if test_output is None:
321
+ return None, None
322
+ parsed = diff.parse_test_output(test_output)
323
+ if not parsed:
324
+ return None, (f"found no test results in test_output: it reads {diff.READS}. Pass other formats as test_results.")
325
+ return parsed, None
326
+
327
+
328
+ CLI_TO_TOOL = (("`leyline plan`", "`plan`"), ("`leyline check`", "`check`"), ("`leyline spec findings`", "`spec_findings`"),
329
+ ("`leyline spec facts`", "`spec_review_facts`"))
330
+
331
+
332
+ def _for_agent(text: str) -> str:
333
+ """Text written for the command line, naming the tools instead of the commands."""
334
+ for cli, tool in CLI_TO_TOOL:
335
+ text = text.replace(cli, tool)
336
+ return text
337
+
338
+
339
+ def _known(node_id: str) -> Optional[dict]:
340
+ """An error for a node id that is not in the store, with near matches; None when it is there."""
341
+ if query._node(_db(), node_id) is not None:
342
+ return None
343
+ hits = query.search(_db(), node_id.split(":")[-1].split(".")[-1], limit=5)["results"] if node_id.strip() else []
344
+ return {"error": f"No node with id {node_id!r}. Find ids with `search`.",
345
+ "did_you_mean": [{"id": h["id"], "kind": h["kind"]} for h in hits]}
346
+
347
+
348
+ # -- the loop ---------------------------------------------------------------------------------------
349
+ @_tool(name="map", needs_store=False, items=30)
350
+ def map_code(paths: Annotated[Optional[list[str]], Field(description="Repository directories, absolute or relative to the"
351
+ " server's directory. Several are mapped together as one"
352
+ " workspace. Leave out to map again what the store holds.")] = None) -> dict:
353
+ """Loop step 1: index the code into the store this server reads. Call it once, when a tool says nothing is
354
+ mapped; a workspace of several repositories is named whole in that call. A folder inside a repository means the
355
+ repository. `plan` and `check` re-map changed code on their own. Returns what was found,
356
+ the map page a person can open, and `next`."""
357
+ for p in paths or []:
358
+ if (why := _outside(p, "The directory")):
359
+ return {"error": why}
360
+ if paths: # a folder inside a mapped repository (where the server was started, say) means that repository
361
+ roots = _roots() or [top for top in [_repository_of(_START)] if top]
362
+ folded = []
363
+ for p in paths:
364
+ try:
365
+ r = Path(p).resolve()
366
+ except (OSError, RuntimeError):
367
+ r = None
368
+ # only a directory that exists: a path that does not must not fold onto the repository it would sit in
369
+ inside = next((root for root in roots if r is not None and r.is_dir() and root in r.parents), None)
370
+ folded.append(str(inside) if inside is not None else p)
371
+ paths = list(dict.fromkeys(folded))
372
+ bad = [p for p in paths or [] if not Path(p).is_dir()]
373
+ if bad:
374
+ return {"error": f"Not a directory: {', '.join(map(str, bad))}. Pass repository directories (relative paths are"
375
+ f" from {Path.cwd()})."}
376
+ if not paths and not Path(_path()).is_file():
377
+ return {"error": f"Nothing is mapped in {Path(_path()).resolve()} yet: pass the repository directory as paths"
378
+ f" (for example [\".\"] for {Path.cwd()})."}
379
+ with _lock:
380
+ try:
381
+ m = loop.map_repos(paths or [], _path())
382
+ finally:
383
+ _generation[0] += 1
384
+ if "error" in m:
385
+ return m
386
+ m["db"] = str(Path(m["db"]).resolve())
387
+ if m.get("page"):
388
+ m["page"] = str(Path(m["page"]).resolve())
389
+ summary = loop.map_text(m).replace("Map page:", "Map page (for the person to open):").rsplit("\nNext:", 1)[0]
390
+ m["map_page"] = m.pop("page", None)
391
+ return {**m, "summary": summary, "next": [
392
+ "Write the change as an OpenSpec folder, openspec/changes/<id>/ (proposal.md, specs/<area>/spec.md, tasks.md;"
393
+ " the leyline-spec skill says how), then call `plan` with it and the current test output."]}
394
+
395
+
396
+ @_tool(items=15, keep=("blocking", "decide", "notes", "tasks", "scenarios", "findings", "gaps", "next"),
397
+ more="Lists were cut to keep this answer short; `cut` says which. `page` and the leyline.md it was written to"
398
+ " (`written`) have the whole plan.")
399
+ def plan(change: ChangeArg, test_output: TestOutput = None, test_results: TestResults = None,
400
+ new_baseline: Annotated[bool, Field(description="Forget the picture of the code kept from the first plan and"
401
+ " compare from the code as it is now. Only to start over.")] = False) -> dict:
402
+ """Loop step 2, before any code is written; call again after every edit to the spec. Re-maps changed code,
403
+ ties each task to code and each scenario to a test, and writes the plan page `leyline.md` into the change
404
+ folder (its text is `page`). `status.blocking` lists what must be fixed in the spec, or decided by the person,
405
+ before implementation; `next` says what to do. Pass the tests as they run now, before any edit, so `check`
406
+ can tell a test the change broke from one that already failed."""
407
+ folder, err = _change(change)
408
+ if err:
409
+ return {"error": err}
410
+ results, err = _results(test_output, test_results)
411
+ if err:
412
+ return {"error": err}
413
+ with _lock:
414
+ try:
415
+ b = loop.plan(_path(), folder, results, new_baseline)
416
+ finally:
417
+ _generation[0] += 1
418
+ if "error" in b:
419
+ return b
420
+ b["map_page"] = str(Path(b.pop("page")).resolve()) if b.get("page") else None
421
+ status = spec_loop.brief_status(b)
422
+ status["notes"] = [_for_agent(n) for n in status["notes"]]
423
+ return {"status": status, "next": loop.next_after_plan(b, change, for_agent=True),
424
+ "page": spec_loop.brief_text(b), **b}
425
+
426
+
427
+ @_tool(items=25, keep=("tasks", "scenarios", "why_not", "next"),
428
+ more="Lists were cut to keep this answer short; `cut` says which. leyline.md (`written`) has the whole verdict.")
429
+ def check(change: ChangeArg, test_output: TestOutput = None, test_results: TestResults = None,
430
+ coverage_path: Annotated[Optional[str], Field(description="Coverage measured on the same test run (pytest"
431
+ " --cov=<package> --cov-context=test writes .coverage):"
432
+ " each scenario then says whether its test ran the"
433
+ " changed code (`ran_changed_code`).")] = None) -> dict:
434
+ """Loop step 3, after the tasks are implemented. Re-maps the code, records the test run, and says whether the
435
+ change was done as agreed: each task from what changed in the code, each scenario from its test's result,
436
+ edits outside the spec, new links between modules, rules newly broken. Appends the verdict to `leyline.md`.
437
+ `done_as_agreed` is the verdict and `why_not` the reasons; `next` says what to do. Report this verdict to the
438
+ person, never your own account of the change."""
439
+ folder, err = _change(change)
440
+ if err:
441
+ return {"error": err}
442
+ if coverage_path and (why := _outside(coverage_path, "The coverage file")):
443
+ return {"error": why}
444
+ results, err = _results(test_output, test_results)
445
+ if err:
446
+ return {"error": err}
447
+ with _lock:
448
+ try:
449
+ v = loop.check(_path(), folder, results, coverage_path)
450
+ finally:
451
+ _generation[0] += 1
452
+ if "error" in v:
453
+ return v
454
+ v["map_page"] = str(Path(v.pop("page")).resolve()) if v.get("page") else None
455
+ return {"done_as_agreed": v["done_as_agreed"], "why_not": v.get("why_not"),
456
+ "next": loop.next_after_check(v, change, for_agent=True), "page": spec_loop.verify_text(v), **v}
457
+
458
+
459
+ # -- looking around ---------------------------------------------------------------------------------
460
+ @_tool
461
+ def overview(scope: Annotated[Optional[str], Field(description="A module path prefix (such as `src/core`) to see one part"
462
+ " of a large repository.")] = None,
463
+ limit: Limit = 20) -> dict:
464
+ """The top-level map: repositories, modules with sizes, which modules depend on which (counts per edge kind),
465
+ proposed systems (groups of types inside a module), external packages, and which extractors ran. Call it
466
+ first to get oriented. Lists are largest first; `expand` a module or system id for its contents."""
467
+ o = query.overview(_db())
468
+ inside = lambda mid: scope is None or (mid or "").split(":module:")[-1].startswith(scope.strip("/"))
469
+ out: dict = {}
470
+ if scope:
471
+ out["scope"] = scope
472
+ repos, all_mods = [], set()
473
+ for r in o["repos"]:
474
+ mods = [m for m in r["modules"] if inside(m["id"])]
475
+ all_mods |= {m["id"] for m in mods}
476
+ mods.sort(key=lambda m: -m["loc"])
477
+ repos.append({**{k: v for k, v in r.items() if k != "modules" and v is not None},
478
+ "modules_total": len(mods), "lines": sum(m["loc"] for m in mods),
479
+ "modules": [{k: v for k, v in m.items() if k not in ("name", "path")} for m in mods[:limit]]})
480
+ if scope and not all_mods:
481
+ return {"error": f"No module path starts with {scope!r}. Call `overview` without scope to see the module paths."}
482
+ out["repos"] = repos
483
+ if "workspace" in o:
484
+ out["workspace"] = o["workspace"]
485
+ edges = [e for e in o["module_edges"] if e["from"] in all_mods or e["to"] in all_mods]
486
+ out["module_edges"] = {"total": len(edges), "items": edges[:limit]}
487
+ systems = [s for s in o["systems"] if s["module"] and inside(":module:" + s["module"])]
488
+ systems.sort(key=lambda s: -len(s["members"]))
489
+ out["systems"] = {"total": len(systems), "items": [
490
+ {"id": s["id"], "name": s["name"], "members": len(s["members"]),
491
+ **({"responsibility": s["responsibility"]} if s["responsibility"] else {})} for s in systems[:limit]]}
492
+ ext = [x for x in o["externals"] if any(u in all_mods for u in x["used_by"])]
493
+ ext.sort(key=lambda x: (-len(x["used_by"]), x["name"]))
494
+ named = [x for x in o.get("named_parts", []) if x["module"] in all_mods]
495
+ if named: # parts of modules that were given names (module_outline, name_part)
496
+ out["named_parts"] = {"total": len(named), "items": [
497
+ {k: x[k] for k in ("id", "name", "summary") if x.get(k)} for x in named[:limit]]}
498
+ out["externals"] = {"total": len(ext), "items": [
499
+ {"id": x["id"], "used_by_modules": len(x["used_by"])}
500
+ for x in ext[:limit]]}
501
+ ran: dict = {}
502
+ for c in o["coverage"]:
503
+ if c["extractor"] != "timing" and c["status"] != "no_files":
504
+ ran.setdefault(c["status"], []).append(c["extractor"])
505
+ out["extractors"] = ran
506
+ out["notes"] = o["notes"]
507
+ return out
508
+
509
+
510
+ @_tool(items=20)
511
+ def cross_repo(limit: Limit = 20) -> dict:
512
+ """For a workspace of several repositories mapped together: the links between them by kind, the functions
513
+ most called from another repository, and how many flows cross into another repository and come back."""
514
+ return query.cross_repo(_db(), limit)
515
+
516
+
517
+ @_tool(items=40, keep=("did_you_mean",))
518
+ def search(text: Annotated[str, Field(min_length=1, description="Words of a name, qualified name or path, such as"
519
+ " `Engine start` or `core/engine.py`.")],
520
+ kind: Annotated[Optional[NodeKind], Field(description="Only nodes of this kind.")] = None,
521
+ limit: Annotated[int, Field(ge=1, le=100, description="Most results to return.")] = 20) -> dict:
522
+ """Find node ids by name, qualified name or path, best match first. Start here to get the id any other tool
523
+ needs."""
524
+ if not text.strip():
525
+ return {"error": "text is empty: pass words of the name you are looking for."}
526
+ r = query.search(_db(), text, kind, limit)
527
+ if not r["results"]:
528
+ r["note"] = ("Nothing matches." + (f" Try without kind={kind}." if kind else "")
529
+ + " Search matches whole-word prefixes of names and paths: try one distinctive word.")
530
+ return r
531
+
532
+
533
+ @_tool(items=40)
534
+ def expand(node_id: NodeId, limit: Limit = 20) -> dict:
535
+ """One node in detail. A module: its files, public types, entry points and module dependencies. A type: its
536
+ members, bases and the modules that use it. A function: its signature, callers and callees, and the fields
537
+ it reads and writes (`data`). Each list shows `limit` items and its total."""
538
+ if (err := _known(node_id)):
539
+ return err
540
+ out = query.expand(_db(), node_id, limit)
541
+ for k in ("depends_on", "depended_on_by", "externals"):
542
+ if isinstance(out.get(k), list) and len(out[k]) > limit:
543
+ out[k] = {"total": len(out[k]), "items": out[k][:limit]}
544
+ return out
545
+
546
+
547
+ @_tool
548
+ def neighbors(node_id: NodeId,
549
+ direction: Annotated[Literal["out", "in", "both"], Field(description="out: edges from the node; in: edges"
550
+ " to it.")] = "both",
551
+ kinds: Annotated[Optional[list[EdgeKind]], Field(description="Only these edge kinds. reads and writes run"
552
+ " from a function to a field.")] = None,
553
+ limit: Limit = 25) -> dict:
554
+ """The raw edges around a node, grouped by edge kind, each group with its total. Use `expand` for a summary
555
+ of a node; use this to list one kind of edge, such as everything that calls it (direction=in, kinds=[calls])."""
556
+ if (err := _known(node_id)):
557
+ return err
558
+ return query.neighbors(_db(), node_id, direction, kinds, limit)
559
+
560
+
561
+ @_tool
562
+ def source(node_id: NodeId,
563
+ max_lines: Annotated[int, Field(ge=1, le=2000, description="Most lines to return from the start.")] = 200) -> dict:
564
+ """The source text of a type, function or field, read from the working tree that was mapped."""
565
+ if (err := _known(node_id)):
566
+ return err
567
+ return query.source(_db(), node_id, max_lines)
568
+
569
+
570
+ @_tool
571
+ def flows(kind: Annotated[Optional[Literal["entry", "test"]], Field(description="entry: walked from a program's entry"
572
+ " point; test: walked from a test.")] = None,
573
+ through: Annotated[Optional[str], Field(description="Only flows that pass this node id.")] = None,
574
+ limit: Limit = 25, offset: Offset = 0) -> dict:
575
+ """List flows: the call paths walked from each entry point and each test. Entry points come first. Pass
576
+ `through` to find the flows that reach one function; read one with `flow`."""
577
+ if through and (err := _known(through)):
578
+ return err
579
+ r = query.flows(_db(), kind, through)
580
+ rows = sorted(r["flows"], key=lambda f: f["kind"] != "entry")
581
+ page = rows[offset:offset + limit]
582
+ for f in page: # a test's flow can pass through dozens of modules
583
+ if len(f["modules"]) > 5:
584
+ f["modules"] = f["modules"][:5] + [f"... {len(f['modules']) - 5} more"]
585
+ out = {"total": r["total"], "offset": offset, "flows": page, "note": r["note"]}
586
+ if offset + limit < r["total"]:
587
+ out["next_offset"] = offset + limit
588
+ return out
589
+
590
+
591
+ @_tool
592
+ def flow(flow_id: Annotated[str, Field(description="A flow id from `flows`.")],
593
+ max_steps: Annotated[int, Field(ge=1, le=1000, description="Most steps to return.")] = 40,
594
+ offset: Offset = 0) -> dict:
595
+ """One flow step by step, in source order: each step's function, call depth and the step it was reached
596
+ from. Page through a long flow with `offset`."""
597
+ r = query.flow(_db(), flow_id, offset + max_steps)
598
+ if "error" in r:
599
+ return r
600
+ r["steps"] = r["steps"][offset:]
601
+ r["offset"] = offset
602
+ if offset + max_steps < (r.get("total_steps") or 0):
603
+ r["next_offset"] = offset + max_steps
604
+ return r
605
+
606
+
607
+ @_tool
608
+ def trace(from_id: Annotated[str, Field(description="Node id of the function to start from.")],
609
+ to_id: Annotated[str, Field(description="Node id of the function to reach.")]) -> dict:
610
+ """The shortest chain of calls and channels from one function to another."""
611
+ for i in (from_id, to_id):
612
+ if (err := _known(i)):
613
+ return err
614
+ return query.trace(_db(), from_id, to_id)
615
+
616
+
617
+ @_tool
618
+ def impact(node_id: NodeId,
619
+ max_depth: Annotated[int, Field(ge=1, le=20, description="How many calls back to follow.")] = 6,
620
+ limit: Limit = 15) -> dict:
621
+ """What can reach a node: its callers, near and far, grouped by module (largest first), and the flows that
622
+ pass through it. Call it on what a change will touch before writing the spec. Read-only; `propose_change`
623
+ gives a fuller assessment and saves it as a view."""
624
+ if (err := _known(node_id)):
625
+ return err
626
+ r = query.impact(_db(), node_id, max_depth, limit)
627
+ mods = r["by_module"]
628
+ for m in mods:
629
+ total = m.pop("direct_total", len(m["direct"]))
630
+ if total > 5:
631
+ m["direct"], m["direct_more"] = m["direct"][:5], total - 5
632
+ r["by_module"] = {"total": len(mods), "items": mods[:limit]}
633
+ r["flows_through"]["items"] = r["flows_through"]["items"][:limit]
634
+ return r
635
+
636
+
637
+ @_tool(items=10, limit=36_000)
638
+ def context(focus: Annotated[list[str], Field(min_length=1, description="What you are working on: node ids, names"
639
+ " (`Owner.method`), file paths, a change id"
640
+ " (spec-<id>, pr-<id>) or words to search for.")],
641
+ budget_tokens: Annotated[int, Field(ge=200, le=8000, description="How long the outline may be, in tokens"
642
+ " (characters / 4).")] = 2000) -> dict:
643
+ """A short outline of the code around what you are working on, to read before editing it: the related
644
+ functions, types and tests file by file, as declaration lines (no bodies), most related first, cut to the
645
+ budget. The focus is marked `>`; channel ends are said in words ("answers GET /api/x"); the last lines say what
646
+ was left out. Ranked by personalized PageRank over calls, channels, type use and containment. Read a body
647
+ with `source`."""
648
+ from . import context as ctx
649
+ r = ctx.build(_db(), focus, budget_tokens)
650
+ if "error" in r:
651
+ return r
652
+ return {"text": r["text"], "tokens": r["tokens"], "focus": r["focus"], "shown": r["shown"], "left_out": r["left_out"]}
653
+
654
+
655
+ @_tool(limit=30_000, more="Lists were cut to keep this answer short; `cut` says which. Lower depth, or drill into one"
656
+ " part by its id, to see the rest.")
657
+ def module_outline(module: Annotated[Optional[str], Field(description="A module's path (such as `editor/core`) or id, or"
658
+ " a part id from an earlier outline. Leave out to"
659
+ " list the modules.")] = None,
660
+ depth: Annotated[int, Field(ge=1, le=3, description="1: the parts; 2: and the parts inside each; 3:"
661
+ " one more level of names.")] = 2) -> dict:
662
+ """A large module split into at most 12 parts: its folders, or groups of files (found from calls and type use)
663
+ where a folder is flat, largest first. For each part: size (files, functions, lines), entry points (routes, UI
664
+ handlers and components, programs, commands; tests), what it uses and what uses it (other parts and modules,
665
+ counted links), channels crossing its edge, its busiest functions (most flows through them), key types, what makes
666
+ it risky beside its siblings, and its name and summary if one was given. Drill in with a part's id; a file or a
667
+ group of one file lists its key functions. Name parts with `name_part` after reading their code."""
668
+ from . import outline
669
+ return outline.outline(_db(), module, depth)
670
+
671
+
672
+ @_tool
673
+ def name_part(part_id: Annotated[str, Field(min_length=1, description="A part id from module_outline.")],
674
+ name: Annotated[str, Field(min_length=1, description="A short name a person would use, such as"
675
+ " \"Validation rules\".")],
676
+ summary: Annotated[str, Field(description="One line on what the part does.")] = "",
677
+ evidence: Annotated[Optional[list[str]], Field(description="Node ids of the code you read to name it;"
678
+ " needed unless layer is intent.")] = None,
679
+ layer: Annotated[Literal["inferred", "intent"], Field(description="inferred: your reading of the code."
680
+ " intent: the person said it.")] = "inferred") -> dict:
681
+ """Give a part from module_outline a name and a one-line summary. Outlines, `overview`, explain_path's steps and
682
+ the map page show it. It is kept across maps; when the part later keeps less than half of its files, it is shown
683
+ as "may be stale"."""
684
+ from . import outline
685
+ return outline.name_part(_db(), part_id, name, summary, evidence, layer, "mcp")
686
+
687
+
688
+ # -- asking how something works -----------------------------------------------------------------------
689
+ @_tool(keep=("words",))
690
+ def find_flows(description: Annotated[str, Field(min_length=1, description="What happens, in plain words, such as"
691
+ " \"what happens when a writer saves a dialogue\""
692
+ " or \"how a vehicle is spawned\".")],
693
+ limit: Annotated[int, Field(ge=1, le=30, description="Most candidates to return.")] = 10) -> dict:
694
+ """Where a described behavior could start, best first: entry points, route handlers, UI event handlers, message
695
+ handlers, commands, tests whose names state the behavior, and other functions, ranked by the words they share
696
+ with the description (code names split, endings cut, a few synonyms such as save, write, persist). Each comes
697
+ with why it matched, its kind, file:line and `flows`, the flows that start there; one where none starts has
698
+ `reached_from`, the entry points' flows that reach it, and `tests_reaching`, the tests' flows that reach it. When
699
+ `ambiguous` is true, show the person the top few and ask which they mean. Then walk one with `explain_path`."""
700
+ from . import explain
701
+ return explain.find_flows(_db(), description, limit)
702
+
703
+
704
+ @_tool(limit=30_000, more="Lists were cut to keep this answer short; `cut` says which. Lower max_steps, or walk from a"
705
+ " later step, to see the rest.")
706
+ def explain_path(start: Annotated[str, Field(min_length=1, description="Where to start: a node id, a name"
707
+ " (`Owner.method`), a route (`PUT /api/x`) or a"
708
+ " flow id, such as a candidate from find_flows.")],
709
+ to: Annotated[Optional[str], Field(description="Where to end: the shortest path from start to it.")] = None,
710
+ through: Annotated[Optional[str], Field(description="A node the path must pass: the path to it, then on"
711
+ " from it.")] = None,
712
+ max_steps: Annotated[int, Field(ge=2, le=200, description="Most steps to show.")] = 40) -> dict:
713
+ """An ordered walk across calls and channels (http, messages, launched programs) from `start`: with nothing
714
+ else, the main flow from it (its shallow steps first, helpers called from many places counted, not shown); with
715
+ `to`, the shortest path; with `through`, the path through that node and on. Each step has its id, name,
716
+ file:line, how it was reached (call, http GET /x, starts a program ...), the calling line and the callee's
717
+ declaration. Data written for a reader that runs later (a file, a table) is under `later_elsewhere`; `not_seen`
718
+ says what the map could not follow. `mermaid` draws the walk. Read the code of a step (`source`) before saying
719
+ what it does."""
720
+ from . import explain
721
+ r = explain.explain_path(_db(), start, to, through, max_steps)
722
+ if "error" not in r:
723
+ r.pop("text", None) # the same walk as `steps`, as lines for the command line
724
+ return r
725
+
726
+
727
+ @_tool
728
+ def diagram(ids: Annotated[list[str], Field(min_length=1, description="Functions or types (a type stands for its"
729
+ " methods): node ids or names.")]) -> dict:
730
+ """A Mermaid sequence diagram of how execution reaches the named code and what it calls. Every arrow is a call
731
+ or a channel link on the map; a dotted arrow is a link the map guessed by name."""
732
+ from . import explain
733
+ return explain.diagram(_db(), ids)
734
+
735
+
736
+ # -- writing to the map -------------------------------------------------------------------------------
737
+ @_tool
738
+ def annotate(node_id: NodeId, key: Annotated[str, Field(description="What is stated, such as `name` or `responsibility`"
739
+ " on a system, or `summary` on a module.")],
740
+ value: Annotated[str, Field(description="The statement itself.")],
741
+ evidence: Annotated[Optional[list[str]], Field(description="Node ids the statement is based on; needed when"
742
+ " layer is inferred.")] = None,
743
+ confidence: Annotated[Optional[float], Field(ge=0, le=1, description="From 0 to 1; for layer inferred.")] = None,
744
+ layer: Annotated[Literal["inferred", "intent"], Field(description="inferred: your reading of the code."
745
+ " intent: a statement the person made.")] = "inferred") -> dict:
746
+ """Record a statement about a node. An inferred statement goes stale when the files behind its evidence
747
+ change. Facts from the code cannot be written."""
748
+ if (err := _known(node_id)):
749
+ return err
750
+ return query.annotate(_db(), node_id, key, value, evidence, confidence, layer, "mcp")
751
+
752
+
753
+ @_tool(items=15, keep=("must_edit", "risks"),
754
+ more="Lists were cut to keep this answer short; `cut` says which. The whole report is in the saved view:"
755
+ " call `view` with view_id and a higher limit.")
756
+ def propose_change(intent: Annotated[str, Field(description="The change in the person's words.")],
757
+ targets: Annotated[list[Target], Field(min_length=1, description="What will change. Target a function"
758
+ " when you can; a type, file or system"
759
+ " counts every function inside it.")],
760
+ title: Annotated[Optional[str], Field(description="A short name for the view; the intent if left out.")] = None,
761
+ depth: Annotated[int, Field(ge=1, le=10, description="How many calls back to follow.")] = 4) -> dict:
762
+ """Assess a change before any code is written, without an OpenSpec folder, and save its blast radius as a
763
+ view the person can open. For a change in the plan/check loop use `plan` instead. Actions: behavior (same
764
+ contract, different result), signature, rename, remove, or add (with name, parent, uses, used_by). Returns
765
+ what must be edited with it, what it reaches, the tests to run, entry points affected, channels crossed,
766
+ untested targets and risks; `view` shows the saved view, `review_change` compares after implementation."""
767
+ targets = [dict(t) for t in targets]
768
+ for t in targets:
769
+ if t["action"] == "add" and not t.get("name"):
770
+ return {"error": "a target with action add needs a name (and a parent: the type, file or module it goes in)"}
771
+ if t["action"] != "add" and not t.get("id"):
772
+ return {"error": f"a target with action {t['action']} needs the id of the node that changes"}
773
+ if t.get("id") and (err := _known(t["id"])):
774
+ return err
775
+ r = change_mod.propose(_db(), intent, targets, title, depth, "mcp")
776
+ if isinstance(r.get("marks"), list): # the marks are the saved view; `view` shows them
777
+ r["marks"] = len(r["marks"])
778
+ return r
779
+
780
+
781
+ @_tool
782
+ def save_view(title: Annotated[str, Field(description="A short name for the view.")], narrative: Annotated[str, Field(description="A few sentences explaining the view.")],
783
+ marks: Annotated[list[Mark], Field(min_length=1, description="The nodes to show.")],
784
+ legend: Annotated[Optional[dict[str, str]], Field(description="Each role mapped to a description.")] = None) -> dict:
785
+ """Save a custom view for the person: any set of nodes worth looking at together, such as the parts of a
786
+ design alternative or everything involved in one feature. It appears in the map page's Views tab."""
787
+ return change_mod.save_view(_db(), title, narrative, [dict(m) for m in marks], "custom", "mcp", legend=legend)
788
+
789
+
790
+ @_tool(items=50)
791
+ def views() -> dict:
792
+ """List saved views, newest first, including the blast-radius view of every proposed change."""
793
+ return change_mod.list_views(_db())
794
+
795
+
796
+ @_tool
797
+ def view(view_id: Annotated[str, Field(description="A view id from `views`, `propose_change` or `save_view`.")],
798
+ limit: Limit = 15) -> dict:
799
+ """One saved view: its narrative, its marked nodes and, for a proposed change, its impact report. Each list
800
+ shows `limit` items and its total."""
801
+ r = change_mod.get_view(_db(), view_id)
802
+ if "error" not in r:
803
+ r["cut"] = _cap(r, limit, ("risks",))
804
+ return r
805
+
806
+
807
+ @_tool(items=25)
808
+ def review_change(change_id: Annotated[str, Field(description="The change_id `propose_change` returned.")],
809
+ before_run: Annotated[Optional[str], Field(description="Label of the test run recorded before the edit.")] = None,
810
+ after_run: Annotated[Optional[str], Field(description="Label of the test run recorded after it.")] = None) -> dict:
811
+ """For a change assessed with `propose_change` (not one in the plan/check loop, which `check` covers), after
812
+ it is implemented: compare the code with the snapshot taken at proposal time. Re-map first with `map`.
813
+ Returns which predicted edits happened, edits not predicted, new and removed module dependencies, flows whose
814
+ path changed, rule results, and the test delta between two runs recorded with `record_test_run`."""
815
+ return diff.review(_db(), change_id, before_run, after_run)
816
+
817
+
818
+ @_tool
819
+ def record_test_run(run: Annotated[str, Field(min_length=1, description="A label, such as `before` or `after`.")],
820
+ results: Annotated[list[TestResult], Field(min_length=1, description="One entry per test.")]) -> dict:
821
+ """Store the outcome of one test run under a label, for `review_change`. `plan` and `check` record their own
822
+ test runs from test_output; this is for the propose_change path."""
823
+ return diff.record_tests(_db(), run, [dict(r) for r in results])
824
+
825
+
826
+ @_tool
827
+ def add_rule(kind: Annotated[Literal["forbid", "no_cycle", "must_be_tested"],
828
+ Field(description="forbid: nothing in selector_from may link to selector_to. no_cycle:"
829
+ " selector_from is `modules` or `systems`. must_be_tested: every function"
830
+ " in selector_from is on some test's path.")],
831
+ selector_from: Annotated[str, Field(description="`module:Name`, `system:Name`, `external:Name`, `path:prefix`,"
832
+ " `id:prefix` or `*`.")],
833
+ selector_to: Annotated[str, Field(description="For forbid: a selector of the same forms.")] = "",
834
+ edge_kinds: Annotated[Optional[list[EdgeKind]], Field(description="For forbid: only these links count.")] = None,
835
+ severity: Annotated[Literal["error", "warning"], Field(description="How a violation is reported.")] = "error",
836
+ reason: Annotated[str, Field(description="Why the rule should hold.")] = "",
837
+ confirmed: Annotated[bool, Field(description="True only when the person stated this rule; otherwise it is"
838
+ " stored as a suggestion.")] = False) -> dict:
839
+ """Add an architecture rule. A rule is the person's intent: unless they stated it, it is stored as
840
+ suggested, with your reason."""
841
+ return rules.add_rule(_db(), kind, selector_from, selector_to, edge_kinds, severity, reason,
842
+ "confirmed" if confirmed else "suggested", "mcp")
843
+
844
+
845
+ @_tool(items=20)
846
+ def check_rules() -> dict:
847
+ """Evaluate every architecture rule against the current map, with examples of each violation."""
848
+ return rules.check(_db())
849
+
850
+
851
+ @_tool(items=10, keep=("patterns",))
852
+ def patterns(pattern: Annotated[Optional[str], Field(description="Only this pattern, such as `strategy`.")] = None,
853
+ node_id: Annotated[Optional[str], Field(description="Only patterns this node (or anything inside it)"
854
+ " plays a role in.")] = None,
855
+ include_tests: Annotated[bool, Field(description="Also patterns inside test code.")] = False,
856
+ limit: Limit = 15) -> dict:
857
+ """Design patterns found by their shape: strategy, decorator, composite, template method, observer, factory,
858
+ builder, singleton, process boundary. Each has the nodes playing each role, a rationale and a confidence. A
859
+ label says the code has the shape, not that the author intended the pattern."""
860
+ if node_id and (err := _known(node_id)):
861
+ return err
862
+ r = pattern_labels.listing(_db(), pattern.strip().lower() if pattern else None, node_id, include_tests, limit)
863
+ if pattern and not r["patterns"]:
864
+ found = pattern_labels.listing(_db(), None, None, include_tests, 1)["by_pattern"]
865
+ r["note"] = f"No {pattern!r} found. Patterns found: {', '.join(sorted(found)) or 'none'}."
866
+ return r
867
+
868
+
869
+ @_tool
870
+ def label_pattern(pattern: Annotated[str, Field(description="The pattern's name, such as `adapter`.")],
871
+ roles: Annotated[dict[str, list[str]], Field(description="Each role mapped to the node ids that play it,"
872
+ " such as {\"adapter\": [...], \"adaptee\": [...]}.")],
873
+ rationale: Annotated[str, Field(description="What in the code makes it this pattern.")],
874
+ confidence: Annotated[float, Field(ge=0, le=1, description="How sure you are, from 0 to 1.")] = 0.6) -> dict:
875
+ """Record a pattern the structural matchers missed, after reading the code. The label goes stale when that
876
+ code changes."""
877
+ return pattern_labels.label(_db(), pattern, roles, rationale, confidence, "mcp")
878
+
879
+
880
+ @_tool
881
+ def shared_state(scope: Annotated[Optional[str], Field(description="A module id or an id prefix.")] = None,
882
+ limit: Limit = 40) -> dict:
883
+ """Fields assigned from outside the type that declares them, most widely written first: the mutable state with
884
+ no single owner. For every reader and writer of one field, or the fields one function touches, `expand` it
885
+ and read `data`."""
886
+ return query.shared_state(_db(), scope, limit)
887
+
888
+
889
+ @_tool(name="coupling")
890
+ def change_coupling(path: Annotated[Optional[str], Field(description="A file: its path in the repository, or the end of"
891
+ " it. Left out: the most coupled pairs.")] = None,
892
+ min_together: Annotated[int, Field(ge=2, description="Commits the two changed in together.")] = 3,
893
+ min_confidence: Annotated[float, Field(ge=0, le=1, description="Share of the file's commits that"
894
+ " changed the other too.")] = 0.5,
895
+ limit: Limit = 20) -> dict:
896
+ """Files (and folders) that usually change in the same commits as a file, from git history: links the map
897
+ cannot see, such as docs, schemas, config, fixtures and the other side of a protocol. Worked out once per
898
+ commit. `plan` and `review_pr` already list those a change leaves alone."""
899
+ from . import coupling as history
900
+ return history.query(_db(), path, min_together, min_confidence, limit)
901
+
902
+
903
+ @_tool(items=50)
904
+ def coverage(node_id: Annotated[Optional[str], Field(description="A function: the tests under which it ran.")] = None,
905
+ flow_id: Annotated[Optional[str], Field(description="A test's flow: its static path against what ran.")] = None,
906
+ import_path: Annotated[Optional[str], Field(description="A coverage.py data file, Cobertura XML report,"
907
+ " Istanbul coverage-final.json or lcov.info to"
908
+ " read into the store first.")] = None,
909
+ test: Annotated[Optional[str], Field(description="With import_path: the one test file that ran, for a"
910
+ " report with no per-test detail (Istanbul, Cobertura,"
911
+ " lcov with no TN: names);"
912
+ " what ran is tied to that file.")] = None) -> dict:
913
+ """Measured test coverage, as opposed to the static paths in `flows`. With no argument: per module, how many
914
+ functions ran, how many are on a test's path but never ran, and how many ran through links the map does not
915
+ have. An Istanbul or Cobertura report covers a whole run: run one test file at a time and import each with
916
+ `test` naming that file."""
917
+ out = {}
918
+ if import_path:
919
+ if (why := _outside(import_path, "The coverage file")):
920
+ return {"error": why}
921
+ if not Path(import_path).is_file():
922
+ return {"error": f"No file at {import_path} (relative paths are from {Path.cwd()})."}
923
+ imported = measured.import_file(_db(), import_path, test=test)
924
+ if isinstance(imported, dict) and "error" in imported:
925
+ return imported
926
+ out["imported"] = imported
927
+ if flow_id:
928
+ return {**out, **measured.compare_flow(_db(), flow_id)}
929
+ if node_id:
930
+ if (err := _known(node_id)):
931
+ return err
932
+ return {**out, "node": node_id, "tests": measured.tests_for(_db(), node_id)}
933
+ return {**measured.summary(_db()), **out}
934
+
935
+
936
+ @_tool(items=60, keep=("commands",))
937
+ def affected_tests(change: Annotated[str, Field(description="The change: its folder or id (a planned spec), or a review"
938
+ " id such as pr-123.")]) -> dict:
939
+ """The tests to run for a change, each with why, and `commands` that run them (pytest node ids,
940
+ `npx vitest run <files>`, `npx jest --runTestsByPath <files>`, `go test -run`). With per-test coverage imported, the tests
941
+ measured running the changed or must-edit code; otherwise, and for changed code no measured test ran, the tests
942
+ whose path on the map passes through it. Run these, then pass their output to `check`."""
943
+ from . import affected
944
+ folder = loop.find_change(change, _path())
945
+ if folder is not None and (why := _outside(folder, "The change folder")):
946
+ return {"error": why}
947
+ with _lock:
948
+ try:
949
+ loop.refresh(_path())
950
+ finally:
951
+ _generation[0] += 1
952
+ con = _db()
953
+ return affected.select(con, affected.change_id_for(con, change, folder))
954
+
955
+
956
+ # -- the spec steps: the parts of plan and check, and review --------------------------------------
957
+ @_tool(items=25, keep=("tasks", "scenarios", "findings", "gaps"))
958
+ def spec_brief(change: ChangeArg,
959
+ new_baseline: Annotated[bool, Field(description="Compare from the code as it is now. Only to start over.")] = False) -> dict:
960
+ """Rarely needed: the brief step of `plan` alone, with no re-map and no test run. Use `plan`."""
961
+ folder, err = _change(change)
962
+ if err:
963
+ return {"error": err}
964
+ return spec_loop.brief(_db(), folder, new_baseline=new_baseline)
965
+
966
+
967
+ @_tool(items=30)
968
+ def spec_review_facts(change: ChangeArg,
969
+ reviewer: Annotated[Optional[Literal["logic", "performance"]],
970
+ Field(description="Which review you are doing; recorded so the plan shows it"
971
+ " ran, even if you file nothing.")] = None) -> dict:
972
+ """Review step, after `plan` and before code is written: what the map says about the change, arranged as the
973
+ questions a logic reviewer and a performance reviewer must answer. Read the code behind anything suspicious
974
+ (`source`, `expand`), then file each real problem with `spec_finding`."""
975
+ if change.startswith("pr-") and not Path(change).is_dir(): # a pull request reviewed with review_pr
976
+ from . import pr
977
+ return pr.review_facts(_db(), change, reviewer)
978
+ if change.startswith("quick-") and not Path(change).is_dir(): # a small change started with `quick`
979
+ from . import quick as quick_mod
980
+ return quick_mod.review_facts(_db(), change, reviewer)
981
+ folder, err = _change(change)
982
+ if err:
983
+ return {"error": err}
984
+ return spec_loop.review_facts(_db(), folder, reviewer)
985
+
986
+
987
+ @_tool(needs_store=False, items=20, keep=("next", "blocking"),
988
+ more="Lists were cut to keep this answer short; `page` has the whole review page and `spec_review_facts` the facts.")
989
+ def review_pr(base: Annotated[Optional[str], Field(description="The branch the change will merge into, or a commit."
990
+ " Default: origin's default branch, else main, else master.")] = None,
991
+ about: Annotated[Optional[str], Field(description="What the change says it does: its title and"
992
+ " description. Default: its commit messages.")] = None,
993
+ github: Annotated[Optional[str], Field(description="A GitHub pull request number: base, title and"
994
+ " description come from it (needs gh, and the pull"
995
+ " request checked out).")] = None,
996
+ review_id: Annotated[Optional[str], Field(description="Name it pr-<review_id>. Default: the pull request"
997
+ " number, else the branch name.")] = None,
998
+ path: Annotated[str, Field(description="The checkout, absolute or relative to the server's directory.")] = ".") -> dict:
999
+ """Review a change someone else wrote, with no spec: a branch or a pull request, checked out here. Maps the commit
1000
+ it left its base at, compares it with the checkout, and returns a page saying what changed, what it reaches and
1001
+ did not change (callers of a changed signature, the other ends of channels its edits touch, removed code still
1002
+ called), and which tests run it. `blocking` lists, one line each, what holds up the merge under the project's
1003
+ gate (`[pr] blocking` in openspec/leyline.toml; by default unedited callers of a changed signature, removed code
1004
+ still called, confirmed error rules newly failing, and open high findings), and `gate_passed` is true when
1005
+ nothing does. Then run the adversarial review on the returned `change_id` with `spec_review_facts` and
1006
+ `spec_finding`, as for a spec."""
1007
+ from . import pr
1008
+ if (why := _outside(path, "The checkout")):
1009
+ return {"error": why}
1010
+ try:
1011
+ with _lock:
1012
+ try:
1013
+ r = pr.review(_path(), Path(path), base, about or "", github, review_id)
1014
+ finally:
1015
+ _generation[0] += 1
1016
+ except pr.GitError as e:
1017
+ return {"error": str(e)}
1018
+ if "error" in r:
1019
+ return r
1020
+ g = r["gate"]
1021
+ return {"change_id": r["change_id"], "gate_passed": g["passed"], "blocking": g["blocking"],
1022
+ "gate": {"kinds": g["kinds"], "config": g["config"] or "the default", "notes": g["notes"]},
1023
+ "page": pr.text(r), "written": r["page"], "size": r["size"],
1024
+ "reaches": r["reaches"], "tests": r["tests"], "other_files": r["other_files"], "house_rules": r["house_rules"],
1025
+ "next": ([f"Blocked: {g['next']}."] if not g["passed"] else [])
1026
+ + [f"Run the leyline-adversarial-review skill on {r['change_id']}: spec_review_facts with reviewer logic,"
1027
+ " then performance; file findings with spec_finding. Show the person the page."]}
1028
+
1029
+
1030
+ @_tool(needs_store=False, items=15, keep=("items", "next"),
1031
+ more="Lists were cut to keep this answer short; `page` has the whole answer.")
1032
+ def quick(what: Annotated[Optional[str], Field(description="Before the edit: the change in one sentence, such as"
1033
+ " \"make the retry count 3\". Code in backticks in it counts as"
1034
+ " named.")] = None,
1035
+ names: Annotated[Optional[list[str]], Field(description="The code it touches, as written in the code:"
1036
+ " `Owner.name`, `module.func`, a constant's name. With"
1037
+ " done: more code the change turned out to touch.")] = None,
1038
+ done: Annotated[Optional[str], Field(description="After the edit: the change_id (quick-<slug>) the first call"
1039
+ " returned.")] = None,
1040
+ test_output: TestOutput = None, test_results: TestResults = None,
1041
+ coverage_path: Annotated[Optional[str], Field(description="With done: coverage measured on the same test run.")] = None,
1042
+ change_id: Annotated[Optional[str], Field(description="Name it quick-<change_id>. Default: from the sentence.")] = None) -> dict:
1043
+ """A small change (a constant, one function and its caller) with no spec folder. Before editing, call it with
1044
+ `what`, `names` and the current test output: it says what the change touches, what must be edited with it, the
1045
+ channels it touches and the tests that run it, and keeps a baseline. After editing, call it with `done` and the
1046
+ new test output: one verdict (`done`), each item proven, partial, contradicted, inconclusive or needs a person, the
1047
+ edits outside the named code and the callers left broken. When `grown` is not empty, write a spec and use `plan`."""
1048
+ from . import quick as quick_mod
1049
+ if coverage_path and (why := _outside(coverage_path, "The coverage file")):
1050
+ return {"error": why}
1051
+ results, err = _results(test_output, test_results)
1052
+ if err:
1053
+ return {"error": err}
1054
+ with _lock:
1055
+ try:
1056
+ if done:
1057
+ if not Path(_path()).is_file():
1058
+ return {"error": "Nothing is mapped here yet: call `quick` with `what` and `names` before editing."}
1059
+ v = quick_mod.done(_path(), done if done.startswith("quick-") else "quick-" + done, results, coverage_path, names)
1060
+ if "error" in v:
1061
+ return v
1062
+ return {"done": v["done"], "page": quick_mod.done_text(v), "next": quick_mod.next_after_done(v, for_agent=True),
1063
+ **{k: v[k] for k in ("change_id", "items", "outside", "broken", "tests_broke", "ran", "grown")}}
1064
+ if not what:
1065
+ return {"error": "Pass `what` (the change in a sentence) and `names` before editing, or `done` after."}
1066
+ b = quick_mod.start(_path(), what, names, results, ".", change_id)
1067
+ finally:
1068
+ _generation[0] += 1
1069
+ if "error" in b:
1070
+ return b
1071
+ return {"change_id": b["change_id"], "page": quick_mod.start_text(b), "next": quick_mod.next_after_start(b, for_agent=True),
1072
+ **{k: b[k] for k in ("named", "values", "unplaced", "must_edit", "channels", "tests_to_run", "grown")}}
1073
+
1074
+
1075
+ @_tool
1076
+ def spec_finding(change: ChangeArg,
1077
+ reviewer: Annotated[Literal["logic", "performance"], Field(description="Which review found it.")],
1078
+ severity: Annotated[Literal["high", "medium", "low"], Field(description="high blocks implementation"
1079
+ " until the person decides it.")],
1080
+ claim: Annotated[str, Field(min_length=1, description="One sentence a person can check.")],
1081
+ evidence: Annotated[list[str], Field(min_length=1, description="Node ids that show it.")],
1082
+ proposal: Annotated[str, Field(description="The change to the spec you propose.")] = "") -> dict:
1083
+ """File a review finding against a planned change. A finding with no node behind it is refused. Only the
1084
+ person resolves findings."""
1085
+ cid, err = _change_id(change)
1086
+ if err:
1087
+ return {"error": err}
1088
+ known = [e for e in evidence if query._node(_db(), e) is not None]
1089
+ if not known:
1090
+ return {"error": f"None of the evidence ids is on the map: {', '.join(evidence[:5])}. Find ids with `search`."}
1091
+ r = spec_loop.add_finding(_db(), cid, reviewer, severity, claim, evidence, proposal)
1092
+ if "error" not in r:
1093
+ r["change_id"] = cid
1094
+ if len(known) < len(evidence):
1095
+ r["ignored_evidence"] = [e for e in evidence if e not in known]
1096
+ return r
1097
+
1098
+
1099
+ @_tool(items=50)
1100
+ def spec_findings(change: ChangeArg) -> dict:
1101
+ """The findings filed against a planned change, and the person's decision on each: open, accepted, rejected or
1102
+ deferred."""
1103
+ cid, err = _change_id(change)
1104
+ if err:
1105
+ return {"error": err}
1106
+ return spec_loop.findings(_db(), cid)
1107
+
1108
+
1109
+ @_tool
1110
+ def spec_resolve(finding_id: Annotated[str, Field(description="A finding id from `spec_finding` or `spec_findings`.")],
1111
+ status: Annotated[Literal["accepted", "rejected", "deferred", "open"],
1112
+ Field(description="accepted means the spec changes to follow it.")],
1113
+ resolution: Annotated[str, Field(description="The person's reason, in their words.")] = "") -> dict:
1114
+ """Record the person's decision on a finding. Call it only with a decision the person stated; never resolve a
1115
+ finding on your own judgment."""
1116
+ return spec_loop.resolve_finding(_db(), finding_id, status, resolution)
1117
+
1118
+
1119
+ @_tool(items=50)
1120
+ def learnings(retire: Annotated[Optional[str], Field(description="A learning id to retire. Only when the person says"
1121
+ " the decision no longer holds.")] = None,
1122
+ why: Annotated[str, Field(description="With retire: the person's reason.")] = "",
1123
+ confirm: Annotated[Optional[str], Field(description="A learning id to confirm against the code as it is"
1124
+ " now, so it is no longer stale. Only when the person"
1125
+ " says the decision still holds.")] = None) -> dict:
1126
+ """Past decisions on review findings: each finding a person rejected, the reason in their words, and the code it
1127
+ is about. `spec_review_facts` lists the ones that apply to a change as `learnings_that_apply`. A learning whose
1128
+ code has changed since is `stale`, naming the nodes `edited` and `gone`: it still applies, and whether it holds
1129
+ is the person's call. One kept before Leyline recorded its code has `code` unknown."""
1130
+ from . import learnings as learned
1131
+ if confirm:
1132
+ return learned.confirm(_db(), confirm)
1133
+ return learned.retire(_db(), retire, why) if retire else learned.listing(_db())
1134
+
1135
+
1136
+ @_tool(items=25, keep=("tasks", "scenarios", "why_not"))
1137
+ def spec_verify(change: ChangeArg,
1138
+ before_run: Annotated[Optional[str], Field(description="Label of a recorded test run from before.")] = None,
1139
+ after_run: Annotated[Optional[str], Field(description="Label of a recorded test run from after.")] = None) -> dict:
1140
+ """Rarely needed: the verify step of `check` alone, with test runs named by label and no re-map. Use `check`."""
1141
+ folder, err = _change(change)
1142
+ if err:
1143
+ return {"error": err}
1144
+ return spec_loop.verify(_db(), folder, before_run, after_run)
1145
+
1146
+
1147
+ @_tool(items=30, keep=("next",), more="Lists were cut to keep this answer short; `page` says what drifted, spec by spec.")
1148
+ def drift(path: Annotated[Optional[str], Field(description="The repository or its openspec/ folder. Default: the server's"
1149
+ " directory and every mapped repository.")] = None,
1150
+ accept: Annotated[bool, Field(description="Record the code as it is now as what the specs mean. Only when the"
1151
+ " person says the specs and the code agree.")] = False) -> dict:
1152
+ """Specs that no longer match the code: every backticked code name in the living specs (openspec/specs/) and in
1153
+ finished changes, compared with the map and with what it meant when the change was checked done. Each name is
1154
+ gone, renamed (to the name in `renamed.to`), moved, signature (changed), ambiguous (could now be several things),
1155
+ body (changed inside; not drift by itself) or ok. Re-maps changed code first. `fails` is true when something is
1156
+ gone, renamed or changed signature."""
1157
+ from . import drift as drift_mod
1158
+ if path is not None and (why := _outside(path, "The path")):
1159
+ return {"error": why}
1160
+ with _lock:
1161
+ try:
1162
+ r = drift_mod.run(_path(), path if path is not None else Path.cwd(), accept)
1163
+ finally:
1164
+ _generation[0] += 1
1165
+ groups = [{**g, "items": [{k: v for k, v in it.items() if k not in ("ids", "was_ids")} for it in g["items"]
1166
+ if it["state"] != "ok"]} for g in r["groups"]]
1167
+ return {"fails": r["fails"], "counts": r["counts"], "page": drift_mod.text(r),
1168
+ "next": drift_mod.next_steps(r, for_agent=True), "groups": [g for g in groups if g["items"]],
1169
+ **({"accepted": r["accepted"]} if "accepted" in r else {}), "problems": r["problems"]}
1170
+
1171
+
1172
+ # -- tours ----------------------------------------------------------------------------------------
1173
+ @_tool(items=50)
1174
+ def tours() -> dict:
1175
+ """List tours: ordered walks through the code. The orientation tour is generated from the map on every
1176
+ index; others were written by an agent or the person."""
1177
+ return tour_store.listing(_db())
1178
+
1179
+
1180
+ @_tool(items=60)
1181
+ def tour(tour_id: Annotated[str, Field(description="A tour id from `tours`.")]) -> dict:
1182
+ """One tour, stop by stop: what each stop points at and what to notice there."""
1183
+ return tour_store.get(_db(), tour_id)
1184
+
1185
+
1186
+ @_tool
1187
+ def save_tour(title: Annotated[str, Field(description="The tour's name.")], stops: Annotated[list[Stop], Field(min_length=1, description="In the order you would explain"
1188
+ " the code aloud.")],
1189
+ audience: Annotated[str, Field(description="Who it is for, such as `new to the payment code`.")] = "") -> dict:
1190
+ """Save a tour you wrote for the person: one feature end to end, or what someone needs before changing one
1191
+ module. Only point at things you looked at; say in each narrative what you inferred."""
1192
+ return tour_store.save(_db(), title, [dict(s) for s in stops], audience, "mcp")
1193
+
1194
+
1195
+ # -- skills: step-by-step instructions for whole jobs, as a tool and as prompts ----------------------
1196
+ @_tool(needs_store=False, items=0)
1197
+ def skills(skill: Annotated[Optional[str], Field(description="A skill's name from the list, such as leyline-ask: returns"
1198
+ " its text, to follow step by step.")] = None) -> dict:
1199
+ """The skills that ship with Leyline: instructions for whole jobs done with these tools (answer a question about
1200
+ the code, plan a change, make a small one, review a pull request). With no name, each one's name and when to use
1201
+ it; with `skill`, its text. Each is also an MCP prompt of the same name."""
1202
+ if skill:
1203
+ s = agent_skills.find(skill)
1204
+ if s is None:
1205
+ return {"error": f"No skill named {skill!r}. Call `skills` with no skill to list them."}
1206
+ return {"name": s.name, "description": s.description, "text": s.body}
1207
+ return {"skills": [{"name": s.name, "description": s.description} for s in agent_skills.available()],
1208
+ "how": "Pick the one whose description fits what the person asked, then follow its text: call `skills` with"
1209
+ " skill set to its name, or load the MCP prompt of the same name. The person can install them for"
1210
+ " their agent with `leyline skills install` in the repository."}
1211
+
1212
+
1213
+ def _skill_prompt(s: agent_skills.Skill) -> None:
1214
+ def load(request: Annotated[str, Field(description="What the person asked, if anything; it is added after the"
1215
+ " skill's text.")] = "") -> str:
1216
+ body = agent_skills.read(s.folder).body # read on each call, so an edited skill is served as it is now
1217
+ return body + (f"\n\nThe person asked: {request.strip()}" if request.strip() else "")
1218
+ mcp.prompt(name=s.name, description=s.description)(load)
1219
+
1220
+
1221
+ for _s in agent_skills.available():
1222
+ _skill_prompt(_s)
1223
+
1224
+
1225
+ def main() -> None:
1226
+ mcp.run()
1227
+
1228
+
1229
+ if __name__ == "__main__":
1230
+ main()