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/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()
|