Skip to main content

Leyline

Working through an agent

Leyline is meant to be driven by a coding agent: you ask in plain words, the agent uses Leyline's map and follows one of its skills, and you read the answer or the page it writes. Set it up once, in the repository you will work on:

pip install leyline-code                       # or, from a checkout: pip install -e .
claude mcp add leyline -- leyline serve        # connect Claude Code to Leyline's MCP server
leyline skills install                         # copy the skills into .claude/skills (and .agents/skills if you have one)

Start a new agent session, then ask. Nothing needs mapping first: the agent maps the code when it needs to.

Skill What it does Ask, for example
leyline-ask Answers a question about the code, citing it, and says how sure it is "Who calls saveOne, and is that link certain?"
leyline-explain-flow Explains how something runs, step by step, from the map "What happens when a user saves a dialogue?"
leyline-explore-module Says what one part of the code holds and where to start reading "What is in editor/host, and where do I start?"
leyline-change-impact Says what a described change would touch, break and need tested "What would changing the validator's output format affect?"
leyline-spec Plans a change as an OpenSpec folder, checked against the code, then checks it after "Plan adding an export button, with tests."
leyline-quick-change Makes a small fix with no spec and says whether it was done; says when it needs a spec "Make the retry count 3."
leyline-pr-review Reviews a pull request: what changed, what it breaks, findings, what to fix first "Review pull request 123."
leyline-adversarial-review Attacks a plan or a pull request as a logic or a performance reviewer "Stress-test the plan for loud-engine."
leyline-tour Writes a guided reading order through the code, saved on the map "Write a tour of the payment code for a new hire."

leyline skills list describes them, leyline skills show <name> prints one, and running leyline skills install again updates them, leaving alone any you edited (--force replaces those). An agent connected to leyline serve can also load each skill as an MCP prompt of the same name, or through the skills tool, without installing them. The rest of this page is what the agent does underneath, and the commands for doing it yourself.

Start here

Leyline lets a person design a change to a codebase while a coding agent writes it. Before any code is written it shows, on one page, what code will be written, what it will affect and how you will know it was done; afterwards it says on the same page whether the change was done as agreed. Everything on that page comes from the code and the test results, not from the agent's account of its own work.

pip install -e .        # Python 3.10 or later; installs the `leyline` command

Three commands, in this order. Each one ends with a Next: line saying what to do after it.

Command When What you read
leyline map [repo ...] Once, before the first change Ten lines on what was found, and a map page to browse
leyline plan <change> After the change is written as a spec, and after every edit to it The page, leyline.md, and what is still needed before implementation
leyline check <change> --tests FILE After the code is written The verdict, added to the same page

A change is an OpenSpec folder, openspec/changes/<id>/: a proposal, the requirements with scenarios, and a task list. You describe the change in words and your agent writes the folder; skills/leyline-spec/SKILL.md tells it how. <change> is the folder or just its id.

A change someone else already wrote (a branch, a pull request) has no spec. Check it out and run leyline pr [base] instead: see Reviewing a pull request.

A one-line fix needs no spec either: leyline quick "make the retry count 3" --about RETRIES before, and leyline quick --done quick-make-the-retry-count-3 --tests - after. See A quick change.

A worked example

The repository tests/fixture2 has a small Python package with an Engine class and its tests.

cp -r tests/fixture2 /tmp/engine-repo && cd /tmp/engine-repo
leyline map .
Mapped engine-repo in 0.8 s: 14 files, 354 lines, 4 modules.
Found 34 types, 73 functions, 5 tests and 1 entry point (where a program starts).
Modules: cs/Mod (8 files), py/src/pkg (2 files), py/tests (2 files), py/web (2 files)
Design patterns found: builder, composite, decorator, factory 2, singleton, strategy, template method
Store: .leyline/leyline.db
Map page: .leyline/map.html (open it in a browser)
Next: write the change you want as an OpenSpec folder, openspec/changes/<id>/ (ask your agent; ...

You tell your agent: "engine names should come back in upper case, and add a shout". It writes openspec/changes/loud-engine/ with this tasks.md

- [ ] 1.1 Change `Engine.start` to return the name in upper case
- [ ] 1.2 Add `Engine.shout`, the name with an exclamation mark
- [ ] 1.3 Add the test "Shout"

and two scenarios, Start and Shout, in specs/engine/spec.md. Then you plan it, passing the tests' output from before any code changes so that check can tell a test the change breaks from one that already failed (TAP from vitest, node --test or tap, pytest -rA, go test -v, jest --verbose, dotnet test --logger "console;verbosity=detailed", or any runner that prints one PASS or FAIL line per test):

(cd py && PYTHONPATH=src pytest -rA tests) | leyline plan loud-engine --tests -
# Loud engine
...
**State: ready to implement.** It has not been reviewed.

Names are hard to read in logs. The plan has 3 tasks: it changes Engine.start and adds Engine.shout.
Nothing else must be edited with it; 4 places in 1 module run into the changed code and may behave
differently; 3 existing tests already run through it. It is done when 2 scenarios pass: 1 already has a
test, 1 needs a test written.
...
**Shares a caller or a field with the change, and no task names it.** Each line is either right to
leave alone or a missing task:
- Engine.name (used by Engine.start) is also used by Engine.child
...
Next: have the plan reviewed (logic and performance) before code is written (ask your agent to run the
leyline-adversarial-review skill), then run `leyline plan loud-engine` again. Or, if you accept the plan
as it is, implement it.

The agent implements the tasks. Then:

(cd py && PYTHONPATH=src pytest -rA tests) | leyline check loud-engine --tests -
## 4. Was it done as agreed
**Yes.** Every task is done, every scenario is proven, and nothing outside the spec changed.

| Task | Result | Verdict | Missing |
| 1.1 Change `Engine.start` to return the name in upper case | done | proven |  |
...
| Scenario | Result | Verdict | Evidence |
| Start | passes | proven | its test reaches the changed code on the map |
| Shout | passes | proven | its test reaches the changed code on the map |

Verdicts: 5 proven. Blocking: partial, contradicted, inconclusive. Not blocking: needs you. (The default.)

Tests: 5 of 5 passed before, 6 of 6 after.
Next: nothing left to check; the change was done as agreed. Review the diff and commit it.

check exits 0 only when the change was done as agreed. Had the agent also edited Engine.child, the verdict would read "Not yet: 1 edit is outside the spec", and Next: would ask you to add a task for it or undo it.

What you read

openspec/changes/<id>/leyline.md is the one page for a change. It opens with its state (not ready, ready, done as agreed or not) and a paragraph in plain words, then:

  1. What code will be written: each task and the code it touches.
  2. What it will affect: what must be edited with it, what runs into it, the risks, and code that shares a caller or a field with the change that no task names.
  3. How you will know it was done: each scenario and the test that proves it.
  4. Review findings and Before implementation: what blocks implementation, and what is left to decide. Only you resolve a finding.
  5. Was it done as agreed, added by check.

With an agent

The agent uses the same loop through Leyline's MCP server. To connect it to Claude Code, run this in the root of the repository you will work on:

claude mcp add leyline -- leyline serve                 # this repository only
claude mcp add -s user leyline -- leyline serve         # or: every project you open

and leyline skills install to give Claude Code the skills (see Working through an agent).

Claude Code starts the server in the directory it was opened in, and the server reads the store there, .leyline/leyline.db, or, from a folder inside the repository, the nearest such store above it. Nothing needs to be mapped first: the agent's first call is map. If leyline is installed in a virtual environment, give its full path (-- /path/to/venv/bin/leyline serve); for a store elsewhere, such as a workspace mapped from another directory, add -e LEYLINE_DB=/full/path/leyline.db before the --. claude mcp list should then show leyline as connected.

The agent's path is the same three tools, map, plan and check, each returning next, with the review tools between plan and implementation; the server's instructions give the agent that order. The skills in skills/ say how to write the spec (leyline-spec), review it (leyline-adversarial-review), assess any change (leyline-change-impact), explain the code (leyline-tour) and answer how something works (leyline-explain-flow). Answers are kept short enough for an agent's context: long lists are cut, and the answer says what was cut (cut) and how to see the rest (more).

Everything below is the detail: the other commands, how the map is built, and how far to trust it. leyline --help lists the other commands under "advanced".

What Leyline is

Leyline maps a codebase into one SQLite graph that people and coding agents can both query. Every record belongs to one of three layers:

  • fact: extracted deterministically from the source, rebuilt on every index run
  • inferred: written by an LLM, with evidence and a confidence
  • intent: written by you, such as boundaries and rules

This version builds the fact layer for C# and Python, proposes systems by clustering, labels design patterns by their shape, generates an orientation tour, assesses a described change before it is made, reviews it after, and checks architecture rules.

Use

pip install -e .
leyline map path/to/repo            # writes path/to/repo/.leyline/leyline.db and .leyline/map.html
leyline index path/to/repo          # the same index, printing the full statistics instead
leyline --db ... coverage .coverage # import measured test coverage
leyline --db path/to/repo/.leyline/leyline.db overview
leyline --db ... search "Simulation Step"
leyline --db ... expand "<node id>"
leyline --db ... tour               # a guided walk through the repository
leyline --db ... patterns           # design patterns found by their shape
leyline --db ... state              # fields assigned from outside their own type
leyline --db ... serve              # MCP server over stdio
leyline --db ... view               # the map, served on http://127.0.0.1:8765
leyline --db ... export -o map.html # the map as one self-contained page

To use it from an MCP client, register the command leyline --db <path> serve.

Several repositories

leyline map flask/ werkzeug/        # one workspace; writes ./.leyline/leyline.db
leyline --db .leyline/leyline.db index werkzeug/   # re-indexes flask too

Repositories named together are indexed in one run, so a name in one resolves to its declaration in the other: import werkzeug in Flask reaches Werkzeug's source (a package under src/ by its package name), a Flask class extends Werkzeug's, and a Werkzeug method that calls self.open() dispatches into Flask's override. Ids keep their own repo's prefix, so a store indexed one repository at a time reads the same. The store remembers its members: indexing any one of them later indexes all of them again, which keeps the links between them. A member whose directory has gone is left as stored, and map says so. Name its new place and it is the same member, under its old id, not a second copy, when Leyline can tell it is the same repository: both are git repositories with the same first commit, and the same origin where both have one. Anything less sure (not under git, or a store mapped before Leyline recorded first commits) is mapped as a new member; drop the old one with leyline map --forget <id>. That drops a member's facts, where it was and its place in the workspace, then maps the others again in full so no link into it is left; notes and review decisions about its code are kept, as when code is deleted.

overview lists the links between repositories; the cross_repo tool adds the functions most called across and the flows that cross and come back. An edge between repositories carries to_repo; impact and a change assessment count what they reach per repo and flag a change that reaches into another one. Not yet: the C# compiler pass (SCIP indexes are matched to their repository by the directory they were made in), and values handed across that the syntax pass cannot follow, such as the WSGI app a test client calls.

Someone else's repository

Leyline is made to be run on code you did not write: a pull request under review, a project you just cloned. What such a repository holds is treated as its author's, not as Leyline's or yours:

  • .leyline/ is Leyline's own. Its files are local and rebuilt by leyline map, so a repository that tracks files there (a pull request can commit a store with annotations, resolved findings and rules of its author's making, a snapshot or a review page, and checking it out writes over your ignored copy) is refused, with the files named. Stop tracking them (git rm -r --cached .leyline) or keep the store elsewhere (--db /elsewhere/leyline.db, or LEYLINE_DB). A .leyline that is a link to a folder outside the repository is refused too, so nothing is written there. LEYLINE_TRUST_STORE=1 uses the folder anyway, for a store you committed on purpose.
  • Nothing is written outside the repository through a link. A file Leyline writes (the map page, a change's leyline.md, the anchors and learnings files) replaces a link at its name rather than writing through it; a change folder or openspec/ that is a link to a folder outside the repository is read but not written to, and the plan or check says so ("Not written: ...").
  • A pull request does not vouch for itself. A learnings file records what reviewers decided before, so one a pull request commits ("f may return anything, do not flag it") would steer its own review. Learnings the pull request's commits add or change (compared with its base) are not applied to it: not in learnings_that_apply, not matched against its findings. Its page lists them under "Learnings added by this pull request, not applied", for the person to judge.
  • An agent's paths stay inside the map. The MCP tools that take a path (map's paths, the change folder of plan, check and the spec_* tools, affected_tests, drift's path, review_pr's path, a coverage file for check, quick or coverage) accept only paths inside the repositories the store maps, or, before anything is mapped, the directory the server was started in. An agent can be told which path to pass by text it read; the error says the person can run the leyline command themselves, which takes any path.

How the pieces fit

Everything lives in one file: .leyline/leyline.db inside the repository you indexed. Three things read and write it, and none of them runs unless you start it. Beside it, leyline.cache.db holds what the next index needs to do again only what an edit changed: each file's parse output under its content hash, and fingerprints of the last run. Deleting it only makes the next index a full one, and so does damage to it: a cache that is not a database is started again.

Piece Started by Reads Writes
leyline map or index You, a git hook or CI; plan and check when the code changed The working tree Facts, flows and system proposals. Replaces the previous facts: after the first run only what changed is redone, and the store comes out as a full run would leave it (--full forces one).
leyline serve (MCP) Your coding agent, when it starts The store Annotations, change proposals and saved views
leyline view or export You The store Nothing

One index runs at a time per store. A second one that starts while another is running (a map while a plan re-maps) says so on stderr, naming the other process and how long it has been going, waits for it to finish, and then maps what changed since. It waits up to an hour (LEYLINE_WAIT, in seconds), and past that stops with the same message. A run that dies lets go of the store with its process.

Annotations, proposals and views are not facts, so re-indexing keeps them. An annotation is flagged stale when the code behind its evidence changes.

The spec loop

The intended way to change a mapped codebase: the person drives the design, an agent writes the code, and one page says what will be written, what it affects and how you will know it was done.

A change is an OpenSpec change folder. Leyline reads it and writes leyline.md back into it. leyline plan and leyline check (see Start here) run the loop; these are its steps one at a time, for when you want one alone:

leyline spec brief  openspec/changes/<id>     # before any code: the one-page brief, and the gaps in the spec
leyline spec facts  openspec/changes/<id> --reviewer logic   # what the graph says, as questions for reviewers
leyline spec verify openspec/changes/<id> --before before --after after   # after: was it done as agreed

plan is brief after re-indexing changed code, and records the test output you pass it under before:spec-<id>. check re-indexes, records its test output under after:spec-<id>, and runs verify with both. Before re-indexing, both compare each source file's hash with the store, so an unchanged repository is not indexed again.

node --test's TAP writes a tab in a test's name and a backslash followed by t the same way (\\t, and so for \n, \r, \b, \f, \v); Leyline reads it as the tab, so a name with a literal \t in it may not match its test.

The brief ties each task to code and each scenario to a test, by three conventions and no markup:

  • Code named in backticks in tasks.md is looked up on the map (`Vehicle.Speed`). A name not on the map is new code: `Owner.NewName` says where a member goes, and `module.new_func`, `path/to/file.py: new_func` or `new_func` in `file.py` where a top-level function goes. A path that is on the map counts with or without backticks. Other words in backticks (an issue code, a value, a doc file) are noted, not checked. A task that names no code is left for the person to check; a change whose tasks all name no code (docs only) is planned and checked all the same, and check says whether any code changed with it.
  • A task that starts with add, remove, rename or "change the signature" is read that way. Anything else is a change in behavior.
  • A scenario is proven by a test with the same name: one on the map, or one made at run time (a name built from a template, a pytest parameter) whose result in the test run carries that name. A scenario that states an invariant ("for any", "for every", "always", "never") is marked "invariant: a property test fits", with the library for its language (Hypothesis, fast-check, FsCheck). When such a test fails, check reads the shrunk counterexample from the runner's output and gives it as the reason: "contradicted: fails for amount=-1".

It then lists what the change reaches that no task covers, the patterns it sits in, the gaps that block implementation, and the code that uses the same things and that no task names: other callers of a changed function, other users of a field it uses, and users of an existing member that a new one is named like (a new EmergencyQueues beside EntryQueues). Each of those lines is either right to leave alone or a missing task. So is the last such list, which comes from git history rather than the map: files that usually change with the files the tasks touch and that no task names (see Change coupling). Reviewers (the leyline-adversarial-review skill, one run for logic and one for performance) file findings with node ids as evidence; only the person resolves them (leyline spec resolve <finding> accepted|rejected|deferred "why"). A reviewer that passes its kind (--reviewer, or reviewer on the spec_review_facts tool) is recorded as having run, so the page can say a review found nothing rather than that none ran.

After implementation and a re-index, verify (inside check) marks each task from the graph diff and each scenario from its test's recorded result, lists edits outside the spec, new links between modules and rules newly broken, and appends the result to leyline.md. It exits 0 only when the change was done as agreed. A new function that only code named in the spec calls is listed as a helper, not as an edit outside the spec.

Each task and scenario gets one verdict, shown in the tables, counted under them ("7 proven, 1 needs you, 1 inconclusive"), and given as verdict and verdict_why in the MCP check answer:

Verdict Task Scenario
proven the code it names changed its test passes, and failed or did not exist before, or passed both times while running the changed code
partial some of the code it names changed, some did not some results that carry its name pass, some fail
contradicted the code it names did not change (or what it adds is missing) while other code did its test fails after the change
inconclusive the map cannot place the code it names, or nothing changed at all no pass or fail was recorded for it
needs a person it names no code (docs, say) its test passes, but does not reach the changed code, or Leyline cannot tell whether it does

A task that removes code is judged by what is left, since edited is not removed: "Remove X" is contradicted while X still exists, partial when X is gone but code that called it still does ("X is gone but caller still calls it"), and proven only when it is gone and uncalled. "Rename X to Y" also needs Y there.

Partial, contradicted and inconclusive hold up "done as agreed"; an item that needs a person is listed and does not. A project can change that in openspec/leyline.toml, committed beside the changes:

[check]
blocking = ["contradicted", "inconclusive", "partial", "needs a person"]   # every item proven by the code

A tick in tasks.md never clears an item: the agent ticks tasks as it goes.

A spec can change part-way. Once the code has moved on, brief keeps the picture of the code from the first brief, so verify still compares with the code as it was; --new-baseline starts over. The code has moved on when any source file on the map has changed: a constant at the top of a module counts, and so, to be safe, does a comment. That picture is .leyline/snapshots/<change id>.db: one per change, taken once, holding only what the comparison reads (nodes and their hashes, links, flows, a hash per source line) and what a diagram of the code as it was needs (the line of each call, the order of each flow, each channel link's channel). It stays after the change is checked done as agreed, so check can run again after a later edit, and is deleted with leyline spec forget <id> or once the change folder is archived or removed (a folder that a local branch not checked out still holds is not removed: it is back when that branch is). A baseline deleted any other way (by hand, or with the snapshots folder) is lost, not forgotten: plan will not quietly take the code as it is now as the start, which after the change is implemented would leave check nothing to find, and asks for --new-baseline to take a new one. leyline map writes .leyline/.gitignore, so the store stays out of git without touching your own .gitignore.

skills/leyline-spec/SKILL.md tells an agent how to write the folder and run the loop.

Spec drift

Specs name code in backticks, and the code moves on without them: a function is renamed, a method gets a parameter, a class moves to another file. leyline drift reads every backticked name in the living specs (openspec/specs/<capability>/spec.md) and in finished changes (openspec/changes/archive/<date>-<id>/), compares each with the map, and prints one page, spec by spec:

## openspec/specs/engine/spec.md

- `Engine.start` has changed signature since the spec was written: was `def start(self)`, now `def start(self, loud=True)`.
- `core.make_engine` is gone: py/src/pkg/core.py has no `make_engine` now.
- `Journal.note` has moved: it is now in py/src/pkg/journal.py (was in py/src/pkg/core.py).

A name is gone, renamed, moved (another file or owner), signature changed, ambiguous (it now names several things) or changed inside (same signature; worth a read, not drift by itself). It exits 1 when something is gone, renamed or changed signature. Renamed is a gone name with one new node beside it (same owner, same file or module) that has its body without the name, or its declaration without the name when git or the change's baseline shows the node is new; or a file git records as renamed: "Engine.shout has been renamed to Engine.yell". Names that are not code (true, GET) are counted and left alone, and so is code a task removes or renames.

To see a changed signature, Leyline must know what a name meant when it was right. When check finds a change done as agreed, it records an anchor for each code name in the change's tasks and spec deltas: the node, its kind and file, and fingerprints of its declaration (a function's signature line, a type's declaration and member names, a field's type) and of its body. Anchors are kept in the store (table spec_anchors) and in openspec/leyline-anchors.json: canonical JSON, sorted keys, keyed by change id (or specs/<capability>), with node ids written without the repository's id so another clone reads them. Commit it with the specs; it survives a fresh map, and wins over the store. A living spec's name is read against its own anchors, or those of the change that wrote it. A name with no anchor is checked by name only. When the specs and the code agree, leyline drift --accept records the code as it is now; code that is gone stays reported until the spec stops naming it.

leyline plan reads the same anchors: a new change whose tasks touch drifted code lists, under "Specs that no longer match code this change touches", lines such as "The living spec engine/spec.md names Engine.start, which has changed signature since it was written."

Assessing a change

  1. You describe a change to an agent that has the Leyline MCP server connected.
  2. The agent finds the nodes the description refers to and calls propose_change.
  3. Leyline walks callers, interface links, channels and flows from those nodes and stores the result as a draft proposal with a saved view.
  4. You refresh the map and open the Views tab: the change, what must be edited with it, what it reaches, the tests to run and the risks.

skills/leyline-change-impact/SKILL.md tells an agent how to do steps 2 and 3 well. The same save_view tool lets an agent save any other slice of the code as a view.

Reviewing a pull request

A spec says what a change will do before it is written. A pull request comes the other way round: the code exists, and what it was meant to do is a title and a paragraph. leyline pr reads the change from the code:

gh pr checkout 123
leyline pr main --about "what the pull request says it does"    # or: leyline pr --github 123

It maps the commit the branch left main at (from git archive into a temporary folder, so the repository and its worktrees are not touched; files that did not change keep their parse output, so this costs a few seconds), compares it with the checkout, uncommitted edits included, and writes .leyline/reviews/pr-<id>.md:

  • What changed: functions edited, added and removed, a changed parameter list as it was and as it is, and the files the map does not read (docs, styles, data), so you know where its view stops.
  • What it reaches and did not change: callers of a changed signature that were not edited, removed code that is still called, and each channel the edit touches (an edited line within a few lines of the route, table, event or program it names) with every other end that must agree. A channel the changed function sits on but the edit does not touch is left out.
  • Shares a caller, a field or data with the change: weaker leads, for a reviewer to read.
  • Usually changes with what it changed, and it did not change: from the history before the branch (see Change coupling).
  • Tests: whether it edits any, which changed code no test reaches, and which tests run it.
  • New dependencies between modules, rules that now fail, and the repository's own rules for reviewers (AGENTS.md, CLAUDE.md, CONTRIBUTING.md ...) to review against.

With no --about, the commits' messages stand for the description. The review is stored as pr-<id> (the pull request's number, else the branch name), and everything that takes a change id takes it: leyline spec facts pr-123 --reviewer logic gives the adversarial reviewers their facts, leyline spec finding pr-123 ... files one, and the page lists them. A finding whose evidence is nowhere near the change (not on its blast radius, nor one call from it) is kept but marked, so the person questions it first. leyline spec forget pr-123 deletes the base's map.

leyline pr exits 0 whatever it finds. With --gate it exits 1 while something that blocks is left, so it can hold up a merge in CI. The page opens with a Gate section: whether it passes, what blocks under which config, one line for each thing that blocks, and a Next: line saying what to do about the first. The MCP review_pr answer carries the same lines as blocking, and gate_passed. Each run judges the gate again from the code at the checkout and the findings as they stand, so a commit that fixes a caller, or a finding the person resolves, clears it on the next run.

By default four things block, the ones the map shows are broken: a caller of a changed signature that was not edited, removed code that is still called, a confirmed error-level rule (see Rules) that now fails and did not at the base, and an open high finding. A line that rests only on a link the map guessed by name never blocks; the Gate section counts those so you can read them. A project picks its own set in openspec/leyline.toml, as it does for check:

[pr]
blocking = ["unedited-callers", "still-called", "failing-rules", "open-high-findings"]   # the default

The kinds are unedited-callers, still-called, failing-rules, open-high-findings, open-medium-findings (medium or high), open-findings (any severity), other-ends (the other end of a channel the edit changed, not edited) and untested (changed code no test on the map reaches). blocking = [] lets everything through.

In CI, check the pull request's head out with enough history to find where it left the base:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- run: pip install leyline-code
- run: leyline pr origin/${{ github.base_ref }} --gate

A quick change

A spec folder is too much for "make the retry count 3". leyline quick gives the same three answers with no folder:

pytest -rA | leyline quick "make the retry count 3" --about RETRIES fetch --tests -
... edit ...
pytest -rA | leyline quick --done quick-make-the-retry-count-3 --tests -

Before the edit it looks up the named code as a task's names are looked up (--about, or names in backticks in the sentence; a constant the map has no node for is found where it is set, with the functions that read it), assesses what it reaches, records the test run, keeps a baseline, and prints a short page: what it will touch, what must be edited with it, the channels it touches or is reached across, the tests that run it, and the command to run when done.

After the edit, --done maps the code again and compares it with the baseline as leyline pr compares a branch with its base. One line gives the verdict, then four items, each proven, partial, contradicted, inconclusive or needs a person: the edits stayed in the named code (a new helper only it calls, and a caller updated to match a changed signature, count as part of it), no caller was left broken, no test broke against the run from before, and a test that passed ran the changed code. It exits 0 when nothing that blocks is left (by default partial, contradicted and inconclusive; the project's openspec/leyline.toml applies). An edit that belongs but was not named is named afterwards, keeping the baseline: leyline quick --done <id> --about <name>.

When the change has grown past quick (more than three functions edited, a channel crossed, or a caller that must change left as it was), the page says so and gives the way into a spec: write openspec/changes/<id>/, run leyline quick --to-spec <id> quick-<slug> to hand it this baseline and the test run from before, then leyline plan <id> and leyline check <id>.

The change is stored as quick-<slug>, and the review steps take it as they take pr-<id>: leyline spec facts quick-<slug> --reviewer logic, leyline spec finding, leyline spec findings, leyline affected-tests and leyline spec forget quick-<slug> (which deletes its baseline). The MCP tool is quick.

Run it again after new commits and the page opens with Since the last review (<sha>, <when>): the functions edited, added and removed between the two heads (not since the base), the facts that are new (a caller newly broken) and those that are gone, and each open finding marked "may be fixed" (the code its evidence names changed since it was filed: re-check it) or "still applies" (do not file it again). Each run is kept in the store with its head, its time and its facts, and a slim map of the code at that head (.leyline/snapshots/pr-<id>.head-<n>.db, the last three and any an open finding was filed against); running it again on the same code changes nothing. The reviewers' facts carry the same as since_last_review.

Both leyline pr and leyline plan list Earlier changes to this code: up to five finished OpenSpec changes (archived, or checked) and earlier pull request reviews that touched the same functions or types, newest first, with the names they share; the facts carry them as related_changes. A repository with no such history gets the commits before the change that changed the same files instead (of the last 500 that touched them), most overlap first. "Checked" means its latest check found it done as agreed: a check that fails after one that passed makes it unfinished again, and so does planning it again (it is then reopened; the store keeps each check's time and verdict).

Learning from rejected findings

Many review findings that people reject are correct about the code but miss a choice made on purpose. So when you reject a finding and say why (leyline spec resolve <finding> rejected "why"), Leyline keeps a learning: your reason in your words, the finding's claim, its kind of review, and the code it is about (the evidence nodes, and the type, file and module each is in). A rejection with no reason keeps nothing.

Learnings go in a file in the repository, so you commit them, the team shares them, and a fresh map keeps them: openspec/leyline-learnings.json when the repository has an openspec folder, else .leyline-learnings.json at its root. A file stays where it was first made. It is JSON with sorted keys and a two-space indent, one entry per learning: id, status (active or retired), reviewer, claim, reason, scope, fingerprint (the code it is about, see below), source (the change and finding it came from), created, confirmed (when a person last said it holds for the code as it is), hits, dismissals, accepted, and findings (the later findings it matched, with what people decided). Node ids in it leave out the repository's id, so a clone in a folder of another name reads them. In a workspace of several repositories each keeps its own file, and a learning applies only to code in its own repository.

  • Reviewers read them first. The facts for a spec or a pull request (leyline spec facts, or spec_review_facts) start with learnings_that_apply: active learnings about code the change touches or reaches, closest first.
  • A repeat is marked, not dropped. A new finding of the same kind of review, on the same node, type or file, whose claim shares at least 45% of its words with a learning's claim (60% when only the module is shared), comes back with learned, naming the learning and its reason. The page shows it as "Matches a past decision: ..." and the person still decides it. Words are compared loosely: lower case, code names split (parseArgs and parse_args are one name, app.use.count is count), the names of the code both claims are about left out (they say where, not what), common words dropped, endings cut, words a review uses for the same thing made one (about forty groups, such as argument and parameter, remove and delete, null, None and undefined, caller and call site; SYNONYMS in learnings.py), and words found in nearly every claim (file, read, value) counted half.
  • A wrong learning retires itself. Each later decision on a finding it matched is counted. Once people have accepted at least two of them, and more than they rejected, the learning is retired and the file says why. It is also retired if the finding it came from is later marked anything but rejected.
  • A learning about code that has changed says so. A learning keeps a fingerprint: for each evidence node, the hash the map keeps of that node's own lines (trimmed, so moving or re-indenting it does not count; the file's hash for a node with none). Each time a learning is used, in learnings_that_apply, in learned on a new finding, on the page and in leyline learnings, it is compared with the map. If a node was edited or is gone, the learning is stale and names them in edited and gone. It still applies and still marks findings, and is not retired. The page says "Matches a past decision, but the code it was about has changed since: ..." and asks whether it still holds. That is the person's call: leyline learnings confirm <id> takes the fingerprint again for the code as it is now, and leyline learnings retire <id> ends it. A learning kept before Leyline recorded fingerprints has none. Whether its code changed is unknown, not stale, and it says so; confirming it gives it one.

leyline learnings lists them, and exits 1, naming the file, when a learnings file cannot be read; leyline learnings retire <id> "why" retires one by hand; leyline learnings confirm <id> says one still holds for the code as it is now.

How it runs: sequence diagrams

leyline.md (under "What code will be written", and again after check) and the pull request page carry a Mermaid sequence diagram of the changed code, which GitHub draws from the ```mermaid block: how execution reaches it from the nearest entry point (along the stored flow, in source order), and what it calls. Participants are types, or the file for a function at the top of a file. Every arrow is an edge on the map: a call, a call through an interface into an implementation, or a channel link, drawn with an open arrowhead (-)) and named (http GET /api/x, writes table orders, which load() reads later); a link the map guessed by name is dotted. The changed code is shaded. A diagram keeps to about 8 participants and 25 arrows, and says how many calls it left out.

After a change, the check page and the pull request page show two diagrams, Before and After, then list what changed in how it runs: calls and channel links into or out of the changed code, added and removed since the baseline. The Before diagram is drawn from the baseline alone, never from the map as it is now, and is shown only when every one of its arrows is a link the baseline holds (diagrams.unbacked(baseline, d); given the map, it checks a diagram of the code as it is). A baseline taken by an older Leyline kept which pairs were linked but not in what order: it still compares, and the code as it was is listed, not drawn. Keeping the order made a baseline of Parlance about 22% larger (8.2 MB to 10.0 MB).

Reviewing a change after it is made

The first propose_change of a change keeps a baseline in .leyline/snapshots/<change id>.db (what the comparison reads, not a copy of the store). Once the change is implemented:

<run the tests> > before.txt        # before the change, on the old code
leyline record-tests before before.txt
... implement ...
<run the tests> > after.txt
leyline index .
leyline record-tests after after.txt
leyline review <change id> --before before --after after

review compares the snapshot with the graph as it is now and reports:

  • which predicted edits were made, which edits were not in the proposal, and which predicted edits did not happen. A method whose parameters changed gets a new id; it is paired with the old one by owner and name, so it counts as one edited method.
  • links between modules that are new or gone, and the flows whose path changed
  • rules that fail now and held before
  • tests that fail now and passed before

It saves the result as a view, which the map shows under Views as "Review: ...". record-tests reads one PASS name or FAIL name: message line per test; an agent can pass results in any other format through the record_test_run tool.

Rules

A rule is a constraint on the graph, checked with no inference: forbid (nothing in A may use anything in B), no_cycle (between modules or systems), must_be_tested (every function in A is on some test's path). Selectors are module:Name, system:Name, external:Name, path:prefix, id:prefix and *. A rule an agent adds is stored as suggested until you confirm it with leyline rules --confirm ID. leyline rules checks all of them and exits 1 when a confirmed error-level rule fails, so it can run in CI. A forbid rule on an external package sees imports (using, import), not fully qualified names used inline.

Exact references

The syntax resolvers below work without a compiler and are sometimes wrong. Where a compiler's view is available, leyline index lets it overrule them (--exact auto, the default; --exact off to skip):

  • C#: if dotnet is on the PATH, a small program built against the Roslyn that ships inside the .NET SDK binds every module and reports each call and field access it resolved. It needs no package restore. A module whose packages are missing still binds whatever refers to source in the repository; the rest stays as the syntax pass left it. The first run builds the program into ~/.cache/leyline (about ten seconds).
  • Other languages: a SCIP index, from --scip FILE or index.scip in the repository root. Tested with scip-python. Run the indexer in an environment where the package resolves to the source tree (an editable install), or references through the installed copy are lost.

A link the compiler confirms is stored as exact. A link it contradicts is removed, but only where it bound every call of that name in the function (C#) or bound that name to something else on the same line (SCIP, which does not say what it failed to bind). Links it found and the syntax pass missed are added. overview reports the counts under exact:roslyn and exact:scip.

Measured coverage

Flows are static: what a test can reach. leyline coverage FILE imports what did run:

  • coverage.py's data file. With pytest --cov=<package> --cov-context=test, each function is tied to the tests that ran it.
  • Cobertura XML (coverlet, coverage xml). A function ran or did not; no per-test detail.
  • Istanbul's coverage-final.json (vitest --coverage.reporter=json, jest --coverageReporters=json). It covers a whole run, so run one test file at a time and import each with --test <that file> (test on the coverage tool): each function is then tied to the test file that ran it, not to the one test inside it.
  • lcov (lcov.info, from c8, vitest or jest with the lcov reporter, and most other tools). Lines (DA:) and functions (FN:, FNDA:) that ran; records under a TN: test name are tied to that test, and a file with no test names is a whole run, imported with --test as Istanbul's is. Paths may be absolute, relative or Windows-style.

Scenarios proven by what ran

A scenario's test passing says the behavior holds; it does not say the test ran the code the change edited. Pass the coverage of the same run to check, and each scenario also says whether its test ran the changed code:

pytest -rA --cov=src --cov-context=test > after.txt
leyline check <change> --tests after.txt --coverage .coverage

Each scenario result gets ran_changed_code (true, false, or null when it cannot be told) and a one-line ran_changed_code_note. A test that passed without running the changed code is listed under Weaker proof: it would pass whatever the change did. With no per-test coverage, check reads as it always has.

The tests a change needs

leyline affected-tests <change> (the affected_tests tool) lists the tests to run for a planned change or a pr-<id> review, each with why, and prints a command that runs them (pytest path::test ..., npx vitest run <files>, npx jest --runTestsByPath <files>, go test -run). With per-test coverage it takes the tests measured running the changed or must-edit code, the change's own new tests, and, from the map, tests that reach changed code no measured test ran or that the measured run left out; without it, the tests whose path on the map passes through the change. Feed that smaller run to check. leyline pr lists the tests measured running the changed code the same way.

With coverage imported, a test's flow dims the steps that did not run and lists what ran without being on its path (reached through a link the map does not have, such as a framework calling back into the code). A change assessment adds the tests that were measured running the changed code, and must_be_tested counts measured functions as tested. Only a function's body counts: the def line runs when the file loads. An import outlives a re-index; leyline coverage flags one taken at another commit.

Field reads and writes

Each function is linked to the fields it reads and the fields it assigns (reads and writes edges, with a count and the first line). expand on a field lists its readers and writers; on a function, the fields it touches; on a type, each field with how many functions set and read it and which other types set it. leyline state (the shared_state tool) ranks the fields assigned from outside the type that declares them: the mutable state with no single owner.

  • An assignment is x.f = v, x.f += v, x.f++, out/ref x.f and x.f[i] = v. A value set while creating an object (new Foo { f = 1 }) is recorded as construction and left out of the shared-state ranking, as are constructors, subclasses and test code.
  • A call on the field to a method that changes a collection in place (list.Add(x), items.append(x), queue.push(x)) is a read and a write. The methods are named in a short list per language (MUTATORS in each adapter); any other method call on a field is a read, since the map cannot tell what it does.
  • Properties count as fields. Enum members and events do not. In Python, reading an attribute that a getter computes (@property, or a decorator that is a descriptor class, such as Werkzeug's cached_property) is also a call to the getter; on self, so is a subclass's getter of that name.
  • In C#, a field on a receiver of unknown type is linked by name only when one field has that name, and marked as a guess. In Python there is no such guess: attribute names repeat too often.
  • A change assessment whose target is a field marks every reader and writer, and review reports field access that is new.

Design patterns

Every index run looks for nine shapes in the graph and labels what it finds, with the nodes that play each role, a sentence saying why, and a confidence:

Pattern The shape that is matched
strategy an abstraction with two or more implementations, held in a field by another type that calls it
decorator a type that implements an abstraction and holds one more of it
composite a type that implements an abstraction and holds a collection of it
template method a concrete method on a base type that calls methods its subclasses override
observer an event with a raiser and subscribers in other code
factory a function that creates two or more types sharing a supertype (higher confidence when it returns that supertype)
builder a type with chaining methods that return itself and a Build or Create that returns something else
singleton a static field of the type's own type and no public constructor
process boundary one program launching another and talking over its pipes

A label says the code has the shape. It does not say the author meant the pattern, and a pattern built another way is not found. Labels in tests, samples and benchmarks are kept apart. An agent that has read the code can add a label the matchers missed with label_pattern; it goes stale when the code behind it changes.

Tours

A tour is an ordered list of stops, each pointing at something on the map (a module, a function, a flow, a pattern, a saved view) with a few sentences on what to notice. leyline index writes an orientation tour from the graph alone: what the repository is, where it starts, the modules from the most depended-on outward, the main abstractions and boundaries, one test path worth tracing, how it is tested, and what the map cannot see. Every sentence in it is a count or a name the store can back. An agent can write further tours with save_tour; skills/leyline-tour/SKILL.md says how.

Change coupling

Some files have to change together with nothing in the code to say so: a doc and the rule it describes, a JSON schema and its reader, a test fixture, the other side of a protocol. Git history shows them. leyline coupling <file> lists what usually changed in the same commits as a file, and leyline coupling alone the most coupled pairs:

$ leyline coupling tooling/validate.py
`tooling/validate.py` changed in 12 commits (from the last 206 commits, leaving out 17 that changed more than 50
files). What usually changed with it:

- files in `editor/core/src/validation/`: 7 of 12 (58%)
- `tooling/conformance/validator/build_cases.py`: 7 of 12 (58%)
- files in `tooling/conformance/validator/cases/`: 7 of 12 (58%)
- `tooling/scripts/mutation_probe.py`: 6 of 12 (50%)

That is the rule a contributor's guide states in words (a validator rule changes both validators, adds a conformance case and a mutant), found from history alone.

A file is listed when it changed together with the first in at least 3 commits and in at least half of the first file's commits (--min-together, --min-confidence). A folder is listed when its files did, though no one file did (a rule that comes with a new test case each time), unless it is at the top of the repository. Read: the last 1,000 commits within two years, merges and commits that changed more than 50 files left out, renames followed. It is worked out once per commit and kept in the store. plan lists the files that usually change with the files the tasks touch and that no task names, and pr those that usually changed with what the branch changed and that it did not; leyline spec facts has the whole list.

The same is worked out by function, for the functions the tasks name (in plan) or the branch edited (in pr), up to 6 of them: "spec.py.verify changed in 8 of the 9 commits that changed spec.py.brief". A past commit's line numbers say which function they were in only against the file as it was then, so this reads, for each function, the last 20 commits that changed its lines (git log -L), parses its file as it was at each with the same parser the map uses, and puts each changed line in the innermost function around it; then reads the same way the 4 other files that changed in most of those commits. The same thresholds apply (3 commits, half of them). Which functions a commit changed in a file is kept in the store, so only the first plan pays for it (about a second on this repository; a function renamed or moved counts only from then).

An outline for an agent

leyline context <focus...> [--tokens N] (the context tool) gives an agent about to edit some code a short outline of the code around it, cut to a token budget (2,000 by default, 200 to 8,000; a token is counted as four characters):

$ leyline context registerLoreRoutes --tokens 1000
Code around registerLoreRoutes: declarations, the most related files first; > marks the focus.

editor/host/src/lore-routes.ts
  const relOf = (params: unknown): string
      -- called by registerLoreRoutes
> function registerLoreRoutes(server: FastifyInstance, opts: { index: () => ReferenceIndex }): void
      -- answers GET /api/lore-files, GET /api/usages/:type/:id; called from 1 place, calls 12

editor/host/src/server.ts
  async function buildServer()
      -- answers GET /api/types, ... and 29 more; calls registerLoreRoutes
...
editor/client/src/lib/loreApi.ts
  const loreApi = { ... }
    listFiles: ()
        -- requests GET /api/lore-files
Shown: 30 symbols in 17 files.
Left out: 1 more symbol linked directly to the focus and 422 two links away, in 120 files; the nearest: ...

The focus is node ids, names (Owner.method), file paths, a change (spec-<id>, pr-<id>: the code its plan or review marked) or words to search for. Every function, type, field and test is ranked by personalized PageRank from the focus over calls, channel links, type use and containment, each way, with a small boost for code a test reaches and code an entry point runs first. The outline shows declaration lines, not bodies, file by file, most related file first: the focus marked >, each channel end in words ("answers GET /api/x", "writes table t"), and what calls the focus or what it calls said so. The last lines say how much was left out and name the nearest of it. The ranking visits only code near the focus, and the graph is read once per map run: on Parlance (160,000 lines) the first call takes 0.3 s and later ones 0.05 s.

Asking how something works

Leyline holds no language model; an agent asks it in words and reasons over what comes back. Two commands (and the tools of the same names) make "how does a vehicle get spawned" or "what happens when a writer saves a dialogue" a question with an answer on the map, and the leyline-explain-flow skill drives them:

$ leyline find-flows "what happens when a writer saves a dialogue in the editor"
 1. DialogueScriptEditor.save  [UI event handler]  editor/client/src/surfaces/DialogueEditor/DialogueScriptEditor.tsx:338
      matched: saves in its name; dialogue in the name of what it is in; editor in the name of what it is in
 2. DialogueCanvas.onInspectorSave  [UI event handler]  editor/client/src/surfaces/DialogueEditor/DialogueCanvas.tsx:764
 ...
$ leyline explain-path DialogueCanvas.save
Walk from DialogueCanvas.save: 40 of 154 steps shown.
  1. DialogueCanvas.save  (editor/client/src/surfaces/DialogueEditor/DialogueCanvas.tsx:230)
  2.    call -> useAppStore.saveEntity  (editor/client/src/store.ts:754)
           at editor/client/src/surfaces/DialogueEditor/DialogueCanvas.tsx:233: return saveEntity(updated, label, ...
 ...
 17.             http PUT /api/entities/:type/:id -> the PUT /api/entities/:type/:id handler  (editor/host/src/server.ts:361)
                    crosses to another process or service
 ...
 31.                call -> scheduleValidation  (editor/host/src/validation.ts:161)

find-flows ranks every function and test by the words it shares with the description: in its name, its route, the test's name, the name of the type or component it sits in, its file's name and folders, its language, and the comment just above it (or a Python docstring). Code names are split (saveDialogue is save and dialogue), endings are cut (saves, saved, saving), a short table makes words that mean the same in code one (save, write, persist, store, put; create, add, spawn; run, execute, play, start; about twenty groups, SYNONYMS in explain.py), a word rare on the map counts for more, and a word for who does it (a user, a writer) counts little. Route handlers, UI event handlers, program entries, message handlers and commands are ranked up, then tests. Each candidate says why it matched and which flows start there; one where none starts names the entry points' flows that reach it (reached_from) and the tests' (tests_reaching). ambiguous is set when the first ones score close in different files, so the agent asks the person which they mean. The words index is built once per map run (about a second on Parlance) and kept: a later question takes a few milliseconds.

explain-path <start> walks from a function, a route ("PUT /api/x"), a test or a flow id. Alone, it follows the stored flow that starts there, or walks the same way now (depth first, in the order the code makes its calls, 8 calls deep at most), and shows the shallow steps first and every hop across a channel, up to --steps (40); leaves called from 8 or more places are counted as helpers, not shown. --to gives the shortest path (without file, table and key hops when there is one), --through the path through a node and on from it. Each step says how it was reached (a call, http PUT /x, starts <program>, a message, an interface's implementation), the calling line and the callee's declaration line, and later, elsewhere for a table, file or key it writes that other code reads later. The walk ends with what the map could not see: guessed links, interfaces, the depth limit, and a program a type in the walk started and talks to over its pipes (the messages are not calls the map can follow, so it names the program to walk next). Its diagram has one arrow per step, each a link on the map. leyline diagram <ids> draws the usual sequence diagram for any functions or types.

Finding your way around a large module

On a large repository a module is still huge: Parlance's editor/client holds 249 files and the map draws it as one box. leyline outline <module> (the module_outline tool) splits it into at most twelve parts, largest first, and says for each what it holds and how it connects. With no module it lists the repository's modules. Here is editor/core/src after an agent named its parts:

$ leyline outline parlance:dir:editor/core/src
Core library (folder, editor/core/src/): 67 files, 669 functions, 20,380 lines.
The project model and every engine on it: storage, validation, explore, play, prose, review, export.
Split by folders and groups of files. 11 of 12 parts have a name.

1. Storage and rename (projectStorage.ts group)  [system]: 7 files, 104 functions, 3,098 lines
   id: parlance:dir:editor/core/src#system:projectStorage.ts
   Canonical JSON in and out, the one atomic write path, loading a project, and entity-id renames.
   entry points: none; 103 of 104 functions on a test's path
   uses: editor/runtime (25), Validation rules (8), Validator (8), Review threads and localization (6), ...
   used by: Core tests (496), editor/host (175), editor/mcp (64), editor (16), editor/client (10), projectDiff.ts (6)
   channel: file from editor/host, 5 links (e.g. threads/*.json)
   busiest: stringify (887 flows), parse (753 flows), ownValue (738 flows)
   key types: StorageContext, RenamePlan, EntityType, RenameInput
   risky: busy: its functions are on the most flows
2. Validation rules (validation/): 9 files, 78 functions, 2,850 lines
   ...
12. 12 more parts  [more]: 18 files, 85 functions, 2,565 lines

A part is a folder when the folder tree means something; a folder that holds nearly all of a module is shown instead of it (editor/client is shown from editor/client/src), with the few files beside it as parts of their own. Where a folder is flat, its files are grouped by Louvain community detection over calls, type use and inheritance between them, each file going to the group that holds most of its code; when few of them link to each other (a folder of tests), they are grouped by the code they use instead. A module that is one flat folder reuses the systems the map proposed. A level of more than twelve parts shows the eleven largest and a @more part holding the rest. A file or a group of one file is a leaf: its key functions, most flows through them first.

For each part: its size; its entry points (routes, UI handlers and components, programs, commands, message handlers) and tests, and how many of its functions are on a test's path; what it uses and what uses it (its siblings, the folder next to it, other modules), as counted calls and type links; the channels that cross its edge; its busiest functions (most flows through them) and the types other code leans on most; and risky beside its siblings: the busiest, the most depended on, and those few tests reach. --depth 2 (the default) also lists the parts inside each part; drill in with a part's id. Ids hold across maps: <repo>:dir:<path> for a folder, <path>#files for the loose files beside its folders, <path>#system:<anchor> for a group (named after its most linked type or file), a file's or a module's node id for those.

An agent names a part after reading its code: name_part(part_id, name, summary, evidence), or leyline name-part <part id> "Validation rules" --summary "..." --evidence <node ids> (--intent when the person said it). Names are kept in the store (table part_names) with the part's files at the time, so they survive a re-map: a group whose anchor changed takes the name of the old group of its folder it shares most files with, and a name whose part kept less than half its files, or most of whose evidence is gone, is shown as "may be stale". Outlines, overview (module titles and named_parts), explain_path (each step says the named part it enters) and the map page show the names. Naming a system the map proposed also sets its name and responsibility.

The leyline-explore-module skill walks an agent through it: outline the module, read each part's key code, name it, and give the person a one-screen guide (the parts, how they connect, where to start reading for common tasks, which parts are risky), offered as a tour.

The map

view and export open the same page. It has up to four zoom levels:

  1. Modules. One box per module, with an arrow for each dependency and a count of the links behind it.
  2. Inside a module. A module of 30 files or more is drawn as its parts, as leyline outline splits it (see Finding your way around a large module), with their names and sizes; open a part for the parts inside it, then its types and files. A smaller module shows one box per system where it was split, otherwise one per type or file.
  3. Inside a system. One box per type, or per file where functions sit outside any type.
  4. Inside a type or file. One box per function, with the outside callers and callees around it.

Select a box or an arrow to see detail in the side panel: members, callers, callees, the links that make up an arrow, and source text. Double-click a box to open it. Solid arrows are exact, dashed arrows were worked out from syntax, dotted arrows are guesses, and blue dash-dot arrows are events and process launches.

The Matrix tab shows the same level as a dependency matrix, ordered so that cycles appear above the diagonal. The Flows tab steps through one flow at a time, with the source of the selected step beside it. The Views tab shows saved views: for a proposed change, the marked code colored by role beside the impact report.

The exported page embeds the graph and the source text of every indexed file, so share it with the same care as the repository. Pass --no-sources to leave source text out.

MCP tools

Tool Returns
map(paths?) Step 1: index the code (or map again what the store holds); counts, the map page, next
plan(change, test_output?, test_results?, new_baseline?) Step 2: the one page for a change, status.blocking, next; records the tests from before; new_baseline compares from the code as it is now
check(change, test_output?, test_results?, coverage_path?) Step 3: re-index, record the tests from after, the verdict and next; with the run's coverage, whether each scenario's test ran the changed code
affected_tests(change) The tests to run for a change or a pr-<id> review, and the command that runs them
overview(scope?, limit?) Repos, modules with sizes, module-to-module dependencies by edge kind, systems, external packages, extractors that ran
cross_repo(limit?) In a workspace of several repositories: links between them, functions most called across, flows that cross and come back
search(text, kind?, limit?) Node ids matching a name, qualified name or path
expand(node_id, limit?) One node in detail: contents, dependencies, dependents, callers and callees
neighbors(node_id, direction?, kinds?, limit?) Raw edges around a node, by kind
source(node_id, max_lines?) The node's source text
context(focus, budget_tokens?) A short outline of the code around a focus (ids, names, paths, a change, or words), most related first, cut to a token budget
module_outline(module?, depth?) A large module split into at most 12 parts (folders, or groups of files where a folder is flat): each part's size, entry points, links in and out, channels, busiest functions, risks and name; drill in with a part's id
name_part(part_id, name, summary?, evidence?, layer?) Name a part from the outline with a one-line summary; kept across maps, shown as "may be stale" when the part changes a lot
flows(kind?, through?, limit?, offset?) Flows walked from each entry point and test; through keeps flows that pass a node
flow(flow_id, max_steps?, offset?) One flow step by step, in source order, with call depth
trace(from_id, to_id) The shortest chain of calls and channels between two functions
find_flows(description, limit?) Where a behavior described in words could start: entry points, route, UI and message handlers, tests named for it, functions; each with why it matched, its kind, file:line and the flows that start there, or else the entry points' flows (reached_from) and the tests' (tests_reaching) that reach it
explain_path(start, to?, through?, max_steps?) An ordered walk across calls and channels from start (its main flow, the shortest path to a node, or the path through one), each step with how it was reached, the calling line and the declaration; data written for later marked; a Mermaid diagram of the walk
diagram(ids) A Mermaid sequence diagram of how execution reaches some functions or types and what they call
impact(node_id, max_depth?, limit?) What can reach a node: callers by module and the flows through it
annotate(node_id, key, value, evidence?, confidence?, layer?) Write an inferred or intent statement about a node
propose_change(intent, targets, title?, depth?) Assess a change without an OpenSpec folder and save its blast-radius view
save_view(title, narrative, marks, legend?) Save any set of marked nodes as a view
review_change(change_id, before_run?, after_run?) Compare a change assessed with propose_change with what was done, and save a review view
record_test_run(run, results) Store one test run under a label, for review_change
add_rule(kind, selector_from, selector_to?, edge_kinds?, severity?, reason?, confirmed?) Add an architecture rule, suggested unless the user stated it
check_rules() Evaluate every rule against the graph
review_pr(base?, about?, github?, review_id?, path?) Review a checked-out branch or pull request with no spec: what changed, what it reaches and did not change, tests; returns change_id (pr-<id>), blocking (one line for each thing that holds up the merge) and gate_passed
quick(what?, names?, done?, test_output?, test_results?, coverage_path?, change_id?) A small change with no spec folder: before (what, names), what it touches and the tests that run it; after (done), one verdict, and grown when it needs a spec
spec_review_facts(change, reviewer?), spec_finding(change, reviewer, severity, claim, evidence, proposal?), spec_findings(change), spec_resolve(finding_id, status, resolution?) Adversarial review of a planned change, or of a pull request by its pr-<id>
learnings(retire?, why?, confirm?) Past decisions on review findings, kept from rejections with a reason, each marked stale when its code changed since; retire one the person says no longer holds, confirm one they say still holds for the code as it is now
spec_brief(change, new_baseline?), spec_verify(change, before_run?, after_run?) The steps inside plan and check, one at a time; rarely needed
drift(path?, accept?) Code the living specs and finished changes name that is gone, moved, changed signature or ambiguous; fails, the page, next
shared_state(scope?, limit?) Fields assigned from outside the type that declares them
coupling(path?, min_together?, min_confidence?, limit?) Files (and folders) that usually change in the same commits as a file, from git history; with no path, the most coupled pairs
coverage(node_id?, flow_id?, import_path?, test?) Measured coverage: what ran, set against the static paths; import_path reads a report in first, and test names the one test file that ran for a report with no per-test detail
patterns(pattern?, node_id?, include_tests?, limit?) Design patterns found by shape, with roles, rationale and confidence; include_tests adds those inside test code
label_pattern(pattern, roles, rationale, confidence?) Record a pattern the matchers missed
tours(), tour(tour_id) List tours, or read one stop by stop
save_tour(title, stops, audience?) Save a tour written for the user
views(), view(view_id, limit?) List saved views, or read one
skills(skill?) The skills that ship with Leyline, each with when to use it, or one skill's text. Each is also an MCP prompt of the same name

change is a change folder or its id. A tool that cannot answer returns an error saying what to do instead (no store yet: call map; an unknown id: find it with search). Each answer is at most about 24,000 characters (30,000 for module_outline and explain_path, 36,000 for context): lists show their first items and their total, cut names any list that was shortened, and more says how to see the rest (limit, offset, scope, or a narrower id).

What is indexed

Which files

The files git lists: tracked ones, and untracked ones .gitignore does not exclude. A directory that is not a git repository (or one git refuses to read, such as a checkout owned by another user) is walked instead, leaving out what git would: the patterns of your global excludes file (core.excludesFile, else ~/.config/git/ignore), of .git/info/exclude and of each .gitignore, escapes and trailing spaces read as git reads them, so a file kept out of git such as .env stays off the map and its page. Some files are left out, and map names them with the reason: other people's code (node_modules, a Go or Composer vendor), submodules and nested repositories, symlinks to directories or out of the repository, files git lists that are gone, and source files that are binary, minified, unreadable or larger than 5 MB (LEYLINE_MAX_FILE_MB changes that). A file that does not parse is kept on the map as a file, without its contents, and named too.

Large repositories are parsed in worker processes (LEYLINE_JOBS sets how many). A worker that crashes or is still on one batch of files after ten minutes (LEYLINE_PARSE_TIMEOUT, in seconds) costs only the file it was on. Workers start by spawn on macOS and Windows and by fork on Linux; LEYLINE_START_METHOD picks one. The compiler step (--exact) gives up after half an hour (LEYLINE_EXACT_TIMEOUT) and keeps the syntax-based links.

Languages

Any language with a tree-sitter grammar is indexed by one generic adapter that knows no language. It reads what grammars have in common: node names (function_declaration, class_definition, call_expression), the grammar's own tags query where it ships one, and how typed languages write a variable's type (Foo x, x: Foo, x = new Foo(). From that it gets declarations, nesting, calls with what the text shows of the receiver, imports matched to files by path, tests by naming convention, and main. Adding a language is one line in adapters/generic.py and a pip install tree-sitter-<language>.

Installed by default: C#, Python, TypeScript and JavaScript. pip install 'leyline-code[languages]' adds Go, Rust, Java, Kotlin, Swift, C, C++, Ruby, PHP, Scala, Lua, Bash and GDScript.

C#, Python and TypeScript also have hand-written adapters that see more: receiver types, overloads, field reads and writes, events, routes. They are used for those languages unless LEYLINE_GENERIC=1. For other languages, a SCIP index (from the language's own indexer, --scip FILE) replaces the generic adapter's guesses with the compiler's links.

leyline grade <repo> <index.scip | roslyn> measures either adapter against a compiler. Precision counts only call sites the compiler resolved; recall counts the compiler's links between functions on the map.

Language (repository) Compiler Hand-written: right / found Generic: right / found
TypeScript (Parlance, 160k lines) scip-typescript 100% / 98% 99% / 97%
Python (Flask) scip-python 99% / 68% 96% / 87%
C# (a private project) Roslyn 99.7% / 97% 98% / 83%

Where the generic adapter loses, it is on calls made on a variable whose type it cannot read from the text. The hand-written Python adapter misses calls inside decorators (@app.route(...)).

Record Precision Notes
Modules, files, types, callables, fields, entry points exact A module is a directory with a project file, or a source file's own directory
contains, has_field, exposes exact
imports, depends_on exact From using and import statements and from .csproj references
extends, implements, uses_type, instantiates heuristic Type names resolved by name, limited to what the project can reference
calls heuristic or guess See below
overrides heuristic A method to the interface or base method it implements, by name and arity
communicates heuristic or guess Events and process launches; see Channels
Tests exact xUnit, NUnit and MSTest attributes, pytest names, and inline runners of the form Run("name", () => { ... })

How calls are resolved

Without a compiler, a call is linked only when its receiver's type can be worked out from what is written: fields, parameters, typed locals, new expressions, collection element types, static type names, and the declared return type of the call a value came from (a.Make().Run(), var x = a.Make(); x.Run()). In C#, extension methods are matched on the type of their this parameter, and overloads of equal length are narrowed by the arguments: a lambda's parameter count, the type of a literal, a new expression or a typed local, and explicit type arguments. In Python, packages under a source root are imported by their own name, with or without an __init__.py (a namespace package), names re-exported through __init__.py are followed, and a test parameter filled by a pytest fixture takes the fixture's return type. In TypeScript and JavaScript, an import through the paths of the nearest tsconfig.json or jsconfig.json (@ui/*) is the local file, not a package; extends is not followed. Three outcomes are counted per run:

  • resolved: linked to a callable in the workspace
  • external: the receiver's type is outside the workspace, or nothing in the workspace has that name
  • unresolved: the name exists in the workspace but the receiver's type is unknown

A call on a receiver of unknown type is linked by name only when exactly one declaration of that name is visible and the name was never seen on an outside type. In Python it is linked only to a method, and never by a name the built-in types have (add, get, close, split). Those links are stored with precision guess, not heuristic, and the map draws them differently.

Channels

A communicates edge records code talking to code without a direct call. It points the way data moves, and carries a channel and an address.

  • event (C#): from the function that raises an event to the function that handles it. The address is the event. Subscriptions to events declared outside the workspace are counted, not linked.

  • process (Python): from a subprocess call to the entry point of the program it launches, when the command names a project, a build output or a script in the workspace. If only the surrounding file names it, the edge is a guess.

  • http: from a request to the route that serves it. Routes are read from decorators (@app.route("/x"), @app.get), ASP.NET attributes ([HttpGet("x")]) and MapGet-style calls; requests from .get("/x")-style calls and HttpClient methods with a literal path. A request is linked to a route declared inside the same test first, then to the only route in the repository that matches. When several match, in every language, the route that names more of the path outright wins (/items/special over /items/{id}, /api/review/health over /api/review/:id); if that leaves more than one, it is counted as ambiguous and left unlinked. In TypeScript (Fastify, Express, Hono, Koa routers), a route's inline handler (server.get("/api/x", async (req) => ...), server.route({ method, url, handler }), or server.get(path, { schema }, handler)) is a function of its own, named for its route (GET /api/x) and nested in the function that registers it: the request lands on it, what it calls is its own, and the registrar registers it (kept as a call, so startup still reaches it). A handler given by name (server.get("/x", listThings)) is that function. When several handlers' routes still fit a request after that, a request with no method written is taken as a GET. In a pull request, an edit inside a handler touches its route and no other. An ASP.NET controller's [Route("api/[controller]")], and a minimal API's MapGroup("api/x"), start the routes declared under them. A path handed to something the name does not give away is linked only when exactly one route serves it: a wrapper (apiFetch("/api/x"), api_get("/api/x/1"), Get<T>("/api/x")), server.inject({ url }), a session's request("GET", "/x"), a test client's open("/x", method=...), a base address in a variable (f"{BASE}/api/x", BASE + "/api/x"), HttpClient with a relative path (GetAsync("api/x")), RestSharp's new RestRequest("api/x") and new HttpRequestMessage(...).

  • file: from a function that writes a file to one that reads it, when the path fragments written in the two agree: the same file name, or the same directory and the same extension (policies/*.bin). These are always a guess, since paths are usually built at run time. Flows do not follow them: writing a file does not run its reader.

  • di: from a method of an interface to the same method of the implementation a container registers for it (services.AddScoped<IFoo, Foo>() and typeof forms, Autofac's RegisterType<Foo>().As<IFoo>(), Python container.register(IFoo, Foo) and bind(IFoo).to(Foo), Angular and NestJS { provide: X, useClass: Y }, Inversify bind<X>(…).to(Y)), with registered_in and registered_at naming the registration. An implementation read off a factory (sp => new Foo(), sp.GetRequiredService<Foo>()) is a guess. Also from the registration to a hosted service's ExecuteAsync, and from a FastAPI endpoint to the function its Depends() names. Flows try the registered implementation before the other implementations.

  • queue: from code that publishes to the handlers of the same topic, queue or message type: Kafka, RabbitMQ, Redis, NATS-style publish/subscribe with a literal or constant name, Node emit/on, NestJS @EventPattern/@OnEvent, graphql-subscriptions, Celery and RQ tasks (send_receipt.delay() reaches send_receipt), Django signals, and C# messages by type (MediatR Send(new X()) and AddDomainEvent to IRequestHandler<X>/INotificationHandler<X>, and the like). A message type that is created with a handler in sight but not visibly sent (handed to an outbox or a wrapper) is linked as a guess with created_only. Event names that streams and sockets raise themselves (error, data, exit) are not linked.

  • db: from a function that writes a table to one that reads it: SQL in string literals and constants, SQLAlchemy and SQLModel, Django managers, EF DbSets and generic repositories (IRepository<Order>), Prisma, Knex, TypeORM, Mongoose, Sequelize and document stores (db.users.find). Table names come from __tablename__, [Table], ToTable, @Entity and the like, or the class name. A match only after folding singular and plural (user ~ users) is a guess. Sites in tests and migrations are counted, not linked. Flows do not follow these.

  • rpc: from a call on a gRPC or Thrift stub (GreeterStub, Greeter.GreeterClient, getService('X')) to the method of the same name on a class that implements the service (GreeterServicer, Greeter.GreeterBase, @GrpcMethod). When several servers implement it, the ones nearest the caller by directory are linked.

  • format (TypeScript, Python, C#): from a function that builds keys of one shape to a function that takes them apart, where no call joins the two. The address is the shape (dialogue/*/nodes/*/text). A builder is a template with holes and two or more fixed parts between / or : separators, starting with a fixed part: a template literal, an f-string, an interpolated string, "%s/x/%s" %, String.Format or a chain of +. A hole that calls another builder (${nodeKey(d, n)}/text) takes that builder's shape. A reader is a regular expression over the shape (where a constant holding it is used), a startsWith test of a literal with two fixed parts, or code that compares a split key's pieces by position (parts[2] === "nodes", through slice, aliases and destructuring). They are linked when two or more of the reader's fixed parts sit at the same places in the builder's shape and none disagrees; when the pieces come from a parameter and two shapes share a tail, the reader is taken to read the shape its own file builds, else the one most files build. Routes, file paths, git's refs/... and keys built in tests are left out. A guess unless three fixed parts agree. Like files and tables, flows do not follow these, and a pull request that edits the builder's template lists the readers as "must agree".

All five meet by a name or a shape written in code, so each link is heuristic at best. Each change brief lists the channels a change crosses, with their address.

Systems

A module is split into systems by Louvain community detection over calls, type use and inheritance between its units: each top-level type, and each file for the functions that sit outside any type. A module with fewer than 12 units holding code, or one that does not split cleanly (modularity under 0.3), is left whole. Each proposed system is named after its most connected unit (a type, or a file) until something better is written with annotate, using the keys name and responsibility.

The grouping is deterministic. The names are not: they belong to the inferred layer.

Annotations

annotate is the only write. An inferred annotation must list the node ids it is based on; the store hashes the files behind them and marks the annotation stale when any of those files changes. An intent annotation is the user's own statement and needs no evidence. Facts cannot be written.

Flows

A flow is a walk of the call graph from one entry point or one test, depth-first, in source order, listing each function once. Flows follow calls, calls through an interface into its implementations (the registered one first), process launches, HTTP requests, messages to their handlers, remote calls, and events whose handler was subscribed earlier in the same flow. They do not follow files or tables: writing one does not run its reader. They stop at 8 calls deep or 300 steps.

A flow's id is flow:<start>. A start that is two kinds of start at once (an entry point that is also a test) has a flow for each: the entry point's keeps that id, and the other's is flow:<start>#test.

Flows are static: they show what can run, not what did run. Per-test coverage will replace them with observed paths where it is available.

SCIP indexers will replace these heuristics with exact references. That extractor and test coverage are recorded as not_analyzed in extractor_coverage, so missing analysis is never mistaken for an empty result.

How well this holds up

Measured on three repositories, counting call sites whose target is inside the repository:

Repository Files Index time Call sites linked Of those, by guess Left open
A private project (C# and Python) 59 1 s 99% 4% 18
Flask (Python) 83 1 s 91% 9% 101
Polly (C#) 801 7 s 94% 2% 915

With the compiler pass on, the syntax links can be scored. On the private project the compiler confirmed 2,081 of them, removed 16 and added 38 it had missed. On Polly, where packages could not be restored and only part of the code binds, it confirmed 4,844 and removed 1,286, nearly all extra overloads of the right method.

Without that pass, "linked" is not "correct". In a random sample of 42 links across the three, read against the source by hand, all 42 pointed at the right method; 4 of the 22 from Polly pointed at the wrong overload of it. Polly's fluent API has up to twenty overloads per name, which is the hard case for this approach. A call the indexer classes as leaving the repository is not in these counts, and some of those are misses: a call on a value whose type comes from outside (Flask's test client) cannot be followed back in.

Identity

Node ids are stable across file moves within a project:

  • C#: repo:csharp:Project::Namespace.Type.Member(ParamTypes). The project is part of the id because two projects may declare the same type name.
  • Python: repo:python:package.module.Class.method
  • TypeScript, JavaScript and the generic languages: repo:language:dir.file.Name, the file named without its extension. Two files of one language in one directory that differ only in their extension (a.js beside a.ts, m.c beside m.h) would name their contents alike: the first by path keeps that id, and the others keep their extension in it (repo:typescript:dir.a.ts.Name). Adding or removing such a neighbour can change the ids of the files after it.
  • Files and modules: repo:file:path and repo:module:path

A name defined twice in one file (def load() in both branches of an if or a try, a property's getter and setter) is one node: which definition runs is not known from the text. Its span runs from the first definition to the last, its definitions attribute lists each one's lines, its text hash reads every body, and its calls come from all of them.

Tests

pytest

A new skill is a folder under skills/ with a SKILL.md. That folder is the only copy: the wheel carries it as leyline/skills/, and leyline skills, the MCP prompts and the skills tool pick it up with no other edit. tests/test_skills.py checks each skill's frontmatter and that the wheel's copy matches.

License

Copyright (C) 2026 Matthew Burke.

Leyline is free software, licensed under the GNU General Public License, version 3. See LICENSE for the full text. It comes with no warranty.

Metadata

Release files for leyline-code 0.8.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for leyline-code 0.8.1
File Size Uploaded
leyline_code-0.8.1.tar.gz 843.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for leyline-code 0.8.1
File Interpreter ABI Platform
leyline_code-0.8.1-py3-none-any.whl Python 3 none any Details

Total release size: 1.5 MB

Release files / leyline_code-0.8.1.tar.gz

Download URL leyline_code-0.8.1.tar.gz
Size 843.5 kB
Tags Source
SHA-256 checksum
How to use checksums
2424ffa08e107f21d14261b225f86c23c34c485c5181d81e72479d118a8f24e2
BLAKE2b-256 checksum
How to use checksums
af03c098d699f44063511a4f54b3a95643b574dc1ec914be09d8a3108d5ea196
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release files / leyline_code-0.8.1-py3-none-any.whl

Download URL leyline_code-0.8.1-py3-none-any.whl
Size 657.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e8281129ccdbe2b1708678f991588834719dcc32e247058d1dd46af96d2a1468
BLAKE2b-256 checksum
How to use checksums
feaeb61f935409b2b2b70b7ae1597489e9fa4cf75a16e711ac269818d9b69fe6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.9.1

2 release files

This release

0.8.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page