Introduction
Fairlead keeps a coding agent on course in your repository. It enforces your project’s rules at the moment the agent acts, gives it only the context that applies to the files in front of it, runs only the tests a change can reach, keeps project memory small, and measures whether all of that is working.
It is a single Rust binary with no project-specific logic: everything about
your repository lives in your own fairlead.toml.
This book documents Fairlead 0.12.0, the latest release. The book for an
earlier release is its docs/ folder at that release’s tag, such as
v0.5.1.
It covers installing Fairlead and its configuration; the import graph it builds without an install, with coverage maps for what imports can’t show; the test plan and how to run it in CI, with the run’s report and the escapes the default branch finds; the guard rules and the hooks that run them as an agent works; the change loop an agent follows: the brief before an edit, the done gate before it’s finished, and the receipt after; and replay, which measures the plan against real CI failures, with the benchmarks that come from it. The roadmap says what comes next.
Install
Shell (macOS and Linux)
curl -fsSL https://github.com/mahkassem/fairlead/releases/latest/download/fairlead-installer.sh | sh
PowerShell (Windows)
powershell -c "irm https://github.com/mahkassem/fairlead/releases/latest/download/fairlead-installer.ps1 | iex"
npm, bun or pnpm
bun add -d fairlead
The package pins the version in package.json, so everyone on the project
runs the same Fairlead.
The package downloads its binary when it installs. Bun runs no install
script it doesn’t trust, so there the first fairlead command downloads it
instead; add "trustedDependencies": ["fairlead"] to package.json to have
bun install do it. Since 0.12.0: two first runs at once, such as parallel
git hook jobs, no longer race on that download: each extracts on its own
and the first to finish is used (#211).
Check it
fairlead --version
fairlead doctor
doctor prints the version, the platform and the config file Fairlead would
use from the current directory.
doctor --score rates how ready the repository is, out of 100, and names the
fixes worth the most (Readiness score).
A first config: fairlead init
fairlead init # write fairlead.toml from what the repository shows
fairlead init --dry-run # print it instead
fairlead init --force # replace a fairlead.toml that's already there
fairlead init --pack next # also extend a framework pack (since 0.11.0)
init reads the repository’s manifests, lockfiles and test files, and writes
a fairlead.toml with a [[tests.runners]] entry for each runner it finds,
each with a comment saying where it came from. A runner goes in only when its
match finds a test file.
| It finds | It writes |
|---|---|
vitest, jest, ava or mocha in package.json, or bun test or node --test in the test scripts, one npm run step deep | that runner, through the lockfile’s package runner (bun x, pnpm exec, yarn, npx), on the standard test-file names, ava.files for AVA, and any other file that imports the runner (from "vitest"), such as a test.ts beside each function |
go.mod at the root | go test {packages} on **/*_test.go |
pyproject.toml, setup.py, setup.cfg, pytest.ini or tox.ini | pytest on **/test_*.py and **/*_test.py, through uv run or poetry run when their lockfile is there |
composer.json | php artisan test, Pest or PHPUnit on the test directories phpunit.xml or phpunit.xml.dist names, else tests/**/*Test.php |
Cargo.toml, gradlew, pom.xml, a .csproj, or a Gemfile with spec/ (RSpec) or test/*_test.rb (minitest) | a [[checks]] entry that runs the whole suite on any change in that language, which the import graph doesn’t read yet |
[tests] match is written as { replace = [...] }, the runners’ patterns and
nothing else, so a test file no runner here covers (a JavaScript test in a
Python project, say) stays out of the plan instead of stopping it.
It then loads and validates what it wrote, and prints the test files each
runner matched. What it can’t tell, such as a runner’s options in
scripts.test or a Go module below the root, it prints as a note. It never
replaces a config without --force. When the manifests show a framework
with a pack, Laravel or Next.js, it names the pack
and writes it only with --pack.
Configuration
Fairlead reads one file at the root of your repository: fairlead.toml, or fairlead.yaml if you prefer YAML. Both formats mean exactly the same thing. Every key has a default, so a repository without a config file works too.
fairlead config check # validate every layer and the merged result
fairlead config show # the merged config
fairlead config show --origin # each value, with the layer that set it
fairlead config schema # JSON Schema, for editor autocomplete
Layers
Later layers override earlier ones:
- Built-in defaults, then any framework packs the files below name in
extends. - Since 0.10.0: the workspace file, when the repository is in a workspace folder. Its
[workspace]table is left out, and it’s ignored when theCIenvironment variable is set. - The project file:
fairlead.tomlorfairlead.yaml, found by walking up from the current directory, never past the repository root (the first directory holding.git). Two in the same directory is an error. - The environment’s shared file,
fairlead.<name>.toml(or.yaml), when an environment is named. See Environments. - The local file,
fairlead.local.toml(or.yaml), next to the project file. Keep it out of git. It’s ignored when theCIenvironment variable is set. - The environment’s local file,
fairlead.<name>.local.toml, also out of git and ignored in CI. - Environment variables:
FAIRLEAD_followed by the key path, with sections separated by a double underscore. SoFAIRLEAD_TESTS__UNREACHED=allsetstests.unreached. --set key=value, for one run:fairlead config show --set tests.unreached=all.
For environment variables and --set, a value that parses as a TOML boolean, number or array (true, 30, ["a", "b"]) is used as that type; anything else, dates included, is a string. Environment variables apply in name order.
Environments
Name an environment with FAIRLEAD_ENV=staging, or --env staging on any command, and Fairlead layers fairlead.staging.toml over the project file and fairlead.staging.local.toml over the local one. A file with no .local is shared and belongs in git; one with .local is yours and stays out of it, like fairlead.local.toml.
fairlead.toml every environment
fairlead.staging.toml staging, shared
fairlead.local.toml you, every environment
fairlead.staging.local.toml you, in staging
Environment files sit beside the project file, wherever you run from. An environment name is lowercase letters, digits and dashes, and can’t be local. Naming an environment with no file for it is an error, so a typo can’t fall back to the defaults silently. In CI only the shared file counts, so an environment CI uses needs one. replay run --config reads exactly the file it names, with no environment or local layers.
Lists
Lists append across layers, so your plan.run_all adds to the built-in one. To replace a list instead, write it as { replace = [...] }:
[tests]
match = { replace = ["test/**/*.ts"] }
So run_all = [] appends nothing and the built-in list stays; config check warns about an empty list written over one that already has items. { replace = [] } clears it.
Globs
* matches within a path segment and ** across segments. {a,b} is an alternation, and alternations nest: {src/**/*.test.{ts,tsx},tests/**}. A brace group of just a name, like {name}, captures that segment instead, where the key supports captures. [abc] is a character class. A backslash makes the next character literal, so a path with brackets is app/**/\[...slug\]/** (in TOML’s double-quoted strings the backslash itself is doubled: "app/**/\\[...slug\\]/**"). config check compiles every glob, so one that can’t compile fails there, not at plan time.
Errors
Unknown keys are errors, so a typo can’t silently switch something off. Every error names the file or layer it came from, and the key:
fairlead.toml: tests.unreachd: unknown field `unreachd`, expected one of ...
Keys
| Key | Default | Meaning |
|---|---|---|
fairlead | none | The oldest Fairlead version this config needs, such as "0.2". Since 0.7.0: a config that an older binary can’t read, because it uses keys added later, gets this version in the error instead of the unknown key. |
modules.discover | ["workspaces"] | Where modules come from |
modules.define | [] | Extra modules: { pattern = "services/{name}/src" } |
graph.tsconfig | "auto" | The nearest tsconfig to each file, or a path |
graph.type_imports | true | Count import type as an edge |
graph.unresolved | "warn" | warn or fail on imports that don’t resolve |
graph.conditions | ["import", "node", "default"] | Package exports conditions, in order |
graph.cache | true | Keep parse results under .git/fairlead so unchanged files aren’t parsed again |
extends | [] | Since 0.10.0: framework packs layered under this config: a built-in pack’s name, such as "laravel", or a pack file’s path. See Framework packs |
graph.edges | [] | { from, to } rules: each file matching from depends on the files to matches. See Import graph. Since 0.10.0: find, a regex searched in each from file’s text, whose capture fills {1} in to. See Names in the code |
graph.barrier | [] | Globs the walk reaches but doesn’t go past. See Import graph |
graph.providers | [] | { id, command, files, timeout_seconds }: external commands that print the graph for the files they claim. See Import graph |
tests.match | **/*.{test,spec}.{ts,tsx,js,jsx,mjs,cjs,mts,cts} | Test files |
tests.exclude | **/node_modules/** | Paths that are never test files |
tests.unreached | "module" | What a changed file nothing reaches selects: module, all or warn |
tests.runners | [] | id, match, exclude (files under match left to another runner), invoke (once or per-module), cwd, command. Since 0.7.1: all_command (the argv when everything runs) and exclude_arg (an argv fragment that leaves one file out), below. Since 0.8.0: from, the earliest CI stage it runs at, ready by default |
tests.owners | [] | Tests that don’t import what they test: match, covers, and overrides_run_all to let a covered plan.run_all path select only those tests |
tests.classes | [] | class (unit, own, demand, canary) for a match |
checks | [] | Steps that aren’t tests: id, command, paths, modules, files. Since 0.8.0: from, the earliest CI stage it runs at, draft by default |
quarantine | [] | A test (path) or check (check) that fails on one platform whatever the change: os, when (autocrlf, space-in-path), signature, reason, proved_in, until (Test plan) |
plan.run_all | lockfiles, root manifests, tsconfig, runner and CI config | A changed path matching one selects everything. Since 0.8.0: except a change that only moves dependency versions and a workflow run only by hand or on a schedule (Changes that don’t run everything) |
plan.lockfile | "all" | all: a changed lockfile selects everything; scope (opt-in): a changed root pnpm-lock.yaml selects the workspace packages whose resolved dependencies changed |
plan.ignore | root Markdown, the changesets tool’s folder (.changeset/**), docs/**, READMEs, changelogs, licences | A changed path matching one that reaches no test selects nothing, instead of falling to tests.unreached; one that reaches tests still selects them, and a changed test file always runs |
replay.provider | "github" | Where CI history comes from |
replay.window_days | 90 | How far back replay looks |
replay.min_failures | 30 | Failures needed before a replay result counts |
replay.failures | [] | runner, extractor (vitest, jest, bun, phpunit, pest, go, pytest, maven, gradle, regex), job, pattern |
replay.checks | [] | Map a CI job and step to a check |
replay.ignore | [] | CI job names (regexes) whose failures replay leaves out on purpose, such as a job that only aggregates others |
replay.quarantine | [] | Tests declared flaky in named jobs: path, job, reason, until (Replay) |
replay.ignore_steps | [] | CI step names (regexes): a job that failed only in such steps, such as an install, is left out |
guard.baseline | "fairlead-baseline.json" | Ratcheted counts by file and rule, relative to the project root |
guard.exclude | [] | Tracked files no guard rule reads |
guard.findings | "added" | Which findings count at a commit: added, only those the change adds; all, every finding in a file it touches |
guard.on_finding | "deny" | deny stops the commit on those findings; warn shows them and lets it through |
guard.budget_ms | 40 | The write hook’s own time; past it the edit goes ahead and the event log says timed_out |
guard.events | "local" | local records decisions in .git/fairlead/events.jsonl; off records nothing |
guard.cite | {} | A note appended to a rule’s messages, by rule id |
guard.size | off | files, exclude, file_lines, function_lines, test_hooks, and ratchet (default true). See Guard rules |
guard.test_names | off | files, exclude, file, titles_without, title_calls. See Guard rules |
guard.citations | off | files, exclude, pattern (with a code group), headings_in. See Guard rules |
guard.migrations | off | files, immutable (default true), base, unique_prefix. See Guard rules |
guard.commands | [] | match, reason: commands an agent may not run; unless, a regex that lets a matched command through (Since 0.8.0). See Guard rules |
guard.external | [] | id, command, stages (default ["check"], or commit), ratchet. See Guard rules |
hooks.claude | "shared" | Where fairlead hooks install puts the Claude Code hooks: shared, .claude/settings.json; local, .claude/settings.local.json |
done.tests | "planned" | What fairlead done runs of the plan’s test invocations: planned or none. See The done gate |
done.checks | "planned" | The same for the [[checks]] the plan selects |
done.always | [] | [[checks]] ids fairlead done runs for every change |
done.guard | true | Whether fairlead done ends with fairlead guard check |
done.on_stop | "ask" | What the Claude Code Stop hook does while the tree hasn’t passed: off, ask (send the agent back once) or require. See The done gate |
brief.per | "session" | Whether a second fairlead brief in the same session adds its paths to the first (session) or stands alone (call). See The brief |
brief.nudge | true | Whether fairlead hooks install adds the PostToolUse note after an edit made with no brief |
brief.resume | true | Whether fairlead hooks install adds the SessionStart hook that runs fairlead resume. See Context and resume. Since 0.8.0 |
memory.dir | ".fairlead/lessons" | Where the lesson files live. Since 0.8.0 |
memory.review_days | 90 | Days from added to the review_by a new lesson gets |
memory.max_lines | 12 | The longest body a lesson may have |
memory.cap | 5 | Lessons a brief lists before “N more” |
memory.learn | "write" | What fairlead learn does: write the file to the working tree, or ask, which prints it for a person to save |
skills.imports | 1 | Hops along what a changed file imports, for skills and lessons alike. Since 0.8.0 |
skills.importers | 0 | Hops along the files that import a changed file; off by default |
skills.cap | 8 | Skills a brief lists before “N more” |
skills.targets | ["claude", "agents", "cursor"] | The agents skills sync writes for |
skills.routes | [] | { skill, paths, modules, always }: the SKILL.md and the code it applies to. See Skills |
agents.write | "block" | What fairlead agents sync does: block keeps the marked block in each file; never writes nothing and prints it for a person to copy in. See AGENTS.md and CLAUDE.md. Since 0.8.0 |
agents.files | ["AGENTS.md", "CLAUDE.md"] | The files that carry the block, relative paths inside the repository |
stages.environments | ["main"] | Branches, by name or glob, whose pushes are the merge stage. See Stages. Since 0.8.0 |
stages.full_label | "run-everything" | A pull request label that makes its runs the full stage |
stages.reuse | true | At merge, skip the ready stage’s steps when the pull request passed them on the same tree |
ci.comment | false | Whether fairlead ci report also keeps one pull request comment up to date with the report. See Plans in CI |
ci.escapes | "report" | What a failing test the merged change’s plan left out does once ci run --judge finds it: "report" lists it, "fail" also makes ci report exit 1. See Escapes |
guard.comments | off | files, exclude, tests, migrations, block_length, density, history, item_codes, agent_phrases, block_marker, and ratchet (default false). See Guard rules |
Commands are always argv arrays, never shell strings, and {files} expands to one argument per file. {packages} expands to one ./dir per directory holding a selected test, or ./... when everything runs, for go test. Since 0.7.0: for Maven and Gradle, an argument holding {class} is repeated once per selected test class and {classes} joins them with commas; either is dropped when everything runs (Java and Kotlin). In tests.runners.cwd, {module} is the module’s root path and {module.id} its id.
Since 0.7.1: when everything runs, {files} expands to nothing, so bun test {files} becomes a bare bun test, which runs every test the tool finds, end-to-end specs included, not only the files the runner’s match claims. A runner’s all_command is the argv to use instead whenever everything runs, such as ["bun", "test", "test/unit"]; without one, command runs with no files, as before. It takes the same placeholders as command except {files}, {class} and {classes}, which have nothing to expand to there and fail config check: {packages} is ./..., and {module} and {module.id} are filled in per module. exclude_arg is an argv fragment holding {file}, such as ["--exclude", "{file}"] or ["--ignore={file}"], added once for each test a [[quarantine]] entry holds, so a run of everything can leave out the tests that run alone (Invocations).
config check validates every key’s type, the ids, placeholders and references between sections, and lists test files that match no runner or more than one. In tests.owners, a placeholder used in covers must be captured in match.
Blueprints
Since 0.10.0, a blueprint is a named starting point for a kind of project. fairlead init --blueprint NAME writes one into the repository once: config on top of what init detects, starter files such as a SKILL.md, and the CI workflow. From then on, the project owns those files.
A framework pack is different: a pack is a layer every load reads, and it changes with the release. A blueprint is written once, and it can name a pack in extends.
fairlead init --blueprint laravel-api
fairlead init --blueprint ./blueprints/ours # a folder holding blueprint.toml
fairlead init --blueprint vite-react --dry-run # print the config and the files it would write
Without --blueprint, init writes only what it detects. When the repository looks like a built-in blueprint, it says which one and the command that adds it.
Built in
| Blueprint | Detected by | Adds |
|---|---|---|
laravel-api | artisan | extends = ["laravel"]; migrations that never change once run; size limits on app/**/*.php; a SKILL.md for the HTTP layer, routed to app/Http/** and routes/**; [stages] and the CI workflow |
vite-react | vite.config.{ts,js,mts,mjs} | size limits on src/**/*.{ts,tsx}; a SKILL.md for components, routed to src/**/*.tsx; [stages] and the CI workflow |
The SKILL.md files are a start, meant to be replaced with the team’s own rules.
A team’s own
A blueprint is a TOML file: --blueprint takes its path, or a folder holding blueprint.toml. No blueprint is fetched over the network.
name = "payments-service"
description = "A payments service: the team's guard rules and its review skill"
detect = ["artisan"] # files that suggest it; any one is enough
ci_workflow = true # also write .github/workflows/fairlead.yml
config = '''
[guard.migrations]
files = ["database/migrations/*.php"]
[[skills.routes]]
skill = ".claude/skills/payments/SKILL.md"
paths = ["app/Payments/**"]
'''
[[files]]
path = ".claude/skills/payments/SKILL.md"
text = '''
---
name: payments
description: The rules for payment code.
---
...
'''
- Placement: the blueprint’s top-level keys, such as
extends, go at the top offairlead.toml, and its tables after whatinitdetected. - Checked first: the whole config is checked before
fairlead.tomlis written, so a blueprint whose config doesn’t load leaves nothing behind. - Files: a file that’s already there is kept unless
--forceis given. A file path must stay inside the repository.
Upgrading
Since 0.7.0: fairlead migrate brings a repository to the release you
just installed. It updates what an older release left behind: the hooks
hooks install wrote, the config’s fairlead version floor, and the
version pins in package.json and the workflows. It then lists the changes
since your release that need a person to decide.
Each step looks at what the repository has, not at which version it came from. So one run catches up from any older release, and a second run finds nothing to do.
Upgrade in five steps
-
Install the new release the way you installed the old one:
bun add -d fairlead@latest # or npm, pnpm, yarn; this updates package.json and the lockfile # or the shell or PowerShell installer from Install -
See what would change. Nothing is written yet:
fairlead migratemigrate: from ^0.4.2 (package.json) to 0.7.0 would update .claude/settings.json: Fairlead's hooks: adds the brief nudge and the Stop hook would update lefthook.yml: the commit stage runs `bun x fairlead guard check --staged` instead of `fairlead guard check --staged` would update fairlead.toml: `fairlead = "0.4"` becomes "0.6": the config uses `done`, which 0.6 added would update .github/workflows/ci.yml: Fairlead's action at v0.4.2 becomes v0.8.0 review (0.5.0): `plan.ignore` applies to source files too: ... review (0.6.0): `hooks install` adds a Stop hook, ... migrate: dry run; `fairlead migrate --write` makes these 4 change(s) -
Read the
reviewlines. They come from Release by release below, filtered to the releases since yours and to what applies to this repository. Settle each one before you write: for example, setbrief.nudge = falsefirst if you don’t want the nudge added. -
Write the changes:
fairlead migrate --writeIf it moved Fairlead’s version in
package.json, it says which command updates the lockfile (bun install,pnpm install,yarn installornpm install). Run that command; migrate never edits a lockfile. -
Check the result and commit it:
fairlead doctor fairlead config check fairlead migrate --check # exits 0 once nothing is left
--from VERSION names the release you came from, when migrate can’t tell
or guesses wrong. It reads the release from package.json, then from a
workflow’s pin, then from the config’s floor.
What migrate changes
| What | When it changes | How |
|---|---|---|
Claude Code hooks in .claude/settings.json and settings.local.json, and, since 0.9.0, Codex hooks in .codex/hooks.json and Gemini CLI hooks in .gemini/settings.json | Fairlead’s entries aren’t the ones this release’s hooks install would write for your config | Fairlead’s entries are replaced with this release’s, and every other setting and hook stays as it was. Uninstall still restores the file from before Fairlead, byte for byte, when nobody else changed it. |
The commit stage in lefthook.yml | It runs another command than this release writes, such as fairlead by name where the project has its own copy | The run: line is replaced in place, with its indentation, quotes and comments kept |
The fairlead = "X.Y" floor in the project config | The floor is older than a table or key the config uses, such as [done] (0.6) or [guard] (0.4) | The floor is raised to that release, so a teammate on an older binary is told which version to install |
package.json | dependencies, devDependencies or optionalDependencies pins an older release, as 0.4.2, ^0.4.2, ~0.4.2 or >=0.4.2 | The version moves to this release and keeps its ^ or ~ |
Workflows under .github/workflows/ | A step uses mahkassem/fairlead@vX.Y.Z, or passes version: vX.Y.Z, for an older release | Both move to this release |
Since 0.11.0: .github/workflows/fairlead-insights.yml | ci workflow --insights wrote it (it starts with the generated-file marker) and this release writes it differently, as 0.11.0 does with the scopes replay fetch needs | It’s rewritten as ci workflow --insights --write would. A file without the marker is the team’s own and is left alone. |
What migrate never does:
- It never installs anything or runs a package manager, and never edits a lockfile.
- It never adds hooks to a repository that has none; that’s
hooks install. - It never adds a floor to a config that has none, never changes a setting that decides what a plan selects, and never moves a pin to an older release.
- It leaves pins by branch, by commit or
latestalone.
In CI
fairlead migrate --check writes nothing and exits 1 when anything would
change, so a dependency bot’s version bump can’t be merged half done:
- run: npx fairlead migrate --check
It exits 0 when the repository matches the installed release, and 2 when the config doesn’t load or a file can’t be read.
Release by release
Every release with something to do, oldest first. Plan ids change with every release, so a plan made by one release isn’t reused by another.
To 0.4.0 (from 0.3.0)
[[guard.external]]refusesstages = ["write"], because the file isn’t written yet when the write hook runs. Give such a rulecommitorci.- A plan’s config digest leaves out
guardandhooks, so changing only those keeps plan ids. Nothing to do.
To 0.5.0
Migrate updates:
- The hooks of a repository that lists Fairlead as a package dependency, so
they run the project’s own copy instead of
fairleadon thePATH.
For you to decide:
plan.ignoreapplies to source files too. A changed source that nothing imports and that matches it now selects nothing, where it used to widen to every test. Under the defaults that’sdocs/**.- An owner rule whose
matchnames no test file no longer counts as covering its paths, so they fall totests.unreached.config checkwarns about each one; a rule meant to select nothing should be aplan.ignoreentry instead. key = []over a list that already has items changes nothing, because lists append.{ replace = [] }clears it, andconfig checkwarns where a layer does this.- A Go module’s
go.modandgo.sumare graph edges instead ofplan.run_alldefaults, so a dependency bump selects that module and its importers. If your config lists them inplan.run_allitself, it still runs everything on them; drop them to get the narrower plan. - Go, Python, PHP, Vue, Svelte and Astro files are in the import graph. A
repository that ran a whole suite through a
[[checks]]entry can give its tests a runner instead (Import graph). - Replay datasets fetched before 0.5.0 keep no lines for a bun test file that failed to load. Fetch them again to count those failures.
To 0.5.1
- A changed path that matches
plan.ignoreand reaches no test is ignored even when other files import it. In 0.5.0 it went totests.unreached. Plans for such changes get smaller; nothing to do.
To 0.6.0
Migrate updates:
- Hooks installed before 0.6.0 get the Stop hook and the brief nudge, which
hooks installadds since 0.6.0.hooks installitself leaves existing hooks alone, so a repository that only re-ran it kept the old set.
For you to decide:
- The Stop hook sends an agent back while
fairlead donehasn’t passed for the tree it leaves (The done gate). Setdone.on_stop = "off"beforemigrate --writeto leave it out, or"require"to ask every time. - The nudge notes an edit made with no brief (The brief). Set
brief.nudge = falseto leave it out.
To 0.7.0
Migrate updates:
- The config’s
fairleadfloor, to 0.7, where the config uses[[quarantine]].
For you to decide:
- Java and Kotlin files are in the import graph. A Maven or Gradle build that
ran everything through a
[[checks]]entry can give its tests a runner instead, with{class}or{classes}in its command (Java and Kotlin). fairlead migraterefreshes Claude Code’s hooks. Hooks for Codex (hooks install --codex) and Gemini CLI (hooks install --gemini) are new in 0.7.0, so there’s nothing older to bring along.
To 0.7.1
Migrate updates:
- The config’s
fairleadfloor, to 0.7.1, where a runner usesall_commandorexclude_arg.
For you to decide:
- A runner whose tool, given no files, finds more tests than its
matchclaims (a barebun testthat also finds end-to-end specs) can setall_commandfor the run of everything. A runner holding a test in[[quarantine]]can setexclude_argso that run stays whole (Configuration).
To 0.8.0
Migrate updates:
- Claude Code hooks installed before 0.8.0 get
Skillin the edit hook’s matcher, so a skill loaded through theSkilltool counts as a use, and aSessionStarthook that runsfairlead resume --hook. Setbrief.resume = falsebeforemigrate --writeto leave that one out (Context and resume). - The config’s
fairleadfloor, to 0.8, where the config uses[memory],[skills],[agents]orbrief.resume.
For you to decide:
- Lessons, skill routes and the AGENTS.md block stay off until a repository
adds them.
fairlead import lessonsturns a lessons document a team already keeps into lesson files, andfairlead import rulesturns path-scoped rule files into routed skills (Lessons, Skills). - CI stages stay off until the config sets
[stages]or a runner’s or check’sfrom. Thenci planreads the stage from the GitHub event, a merge skips what its pull request passed on the same tree, andfairlead ci workflowwrites a staged workflow or prints theif:lines for one you keep (Stages). - Three kinds of change no longer select everything: a bun dependency’s
version bump runs the tests of the files that import it, an edit to
fairlead.tomlplans with the new config, and a workflow that only runs by hand or on a schedule selects nothing by itself (The test plan). migraterefreshes Claude Code’s hooks only. Gemini CLI hooks installed by 0.7 don’t matchread_file, so reading a SKILL.md there isn’t counted as a use untilfairlead hooks uninstall --geminiandhooks install --geminiwrite this release’s.
To 0.12.0
Migrate updates nothing in your files.
For you to decide:
- The
nextpack’s end-to-end rule, every spec that callspage.goto(reaching every route andmiddleware, is its own pack now. Add"next-e2e"toextendsto keep it, or leave it out when your owner rules already map pages to specs (Framework packs, #212). - With more than one runner, a runner whose folder holds another runner’s
tests names its own when everything runs, so its command line lists
them;
all_commandkeeps the tool’s own discovery (Planning, #210). - Under Bun,
"trustedDependencies": ["fairlead"]hasbun installdownload the binary instead of the first command (Install).
To 0.11.0
Migrate updates:
.github/workflows/fairlead-insights.yml, whenci workflow --insightswrote it: it’s rewritten with thepull-requestsandchecksread scopesreplay fetchneeds (#201).
For you to decide:
- Sessions in a linked worktree count now: every worktree writes to the
main checkout’s event log, so
doctor --score,insightsandtokens reportmay show more than before. - A Next.js app can add
extends = ["next"], whichfairlead initnow names (Framework packs). - A workspace can declare
[[workspace.edges]]between its repositories (Edges across repositories). - Building from source needs Rust 1.95.
To 0.10.0
Migrate updates nothing in your files: every new table is optional.
For you to decide:
- Replay sets recurring failures aside: a test failing in one job across
three or more unrelated pull requests gets the outcome
recurringand leaves adjusted recall, so a gate can pass that failed before. Confirm the[[replay.quarantine]]entries it lists (Recurring failures). fairlead doctor --scorerates the setup out of 100 and names the three fixes worth the most (Readiness score);fairlead ci workflow --insights --writeadds the weekly summary (Insights).fairlead mcpserves read-only tools to an agent (MCP server),[tracker]puts a branch’s task in the brief (Trackers), andci.escapes = "file"opens an issue per escape (CI).
To 0.9.0
Migrate updates:
- Codex hooks in
.codex/hooks.jsonand Gemini CLI hooks in.gemini/settings.json, whichmigratenow refreshes as it does Claude Code’s. Gemini CLI hooks from 0.7 getread_filein the nudge’s matcher, so a SKILL.md read there counts as a use; the uninstall and install the 0.8.0 notes asked for is no longer needed.
For you to decide:
- A project whose pushes run the plan and whose full suite runs on a
schedule can add
--event scheduleto itsreplay fetch, so replay counts what the nightly caught and no push ran (Scheduled runs). fairlead graph whynames edge kinds in words (type import,path literal) where it printedtypeimportandpathliteral; a script that reads its output needs the new names.
Going back to an older release
Install the older release, then run its hooks uninstall and
hooks install, so the hooks are the ones it writes. A config whose floor
names a newer release than the binary fails to load with the release it
needs; lower the floor only if the config uses nothing newer.
Import graph
Fairlead builds a file-level graph of what imports what, and later milestones plan tests from it. You can inspect it yourself:
fairlead graph stats # files, edges, what didn't resolve, build time
fairlead graph why FROM TO # the chain by which FROM depends on TO
fairlead graph importers FILE # the files that depend on FILE directly
What counts as an edge
| Edge | From |
|---|---|
| import | import, export … from, import x = require(…) |
| type import | import type and export type … from; off with graph.type_imports = false |
| dynamic | import("…") with a plain string |
| require | require("…") and require.resolve("…") |
| mock | vi.mock, jest.mock and their relatives, naming a module |
| path literal | any string literal that names a file in the repository, so a test that spawns a CLI by its path depends on it |
| snapshot | __snapshots__/x.test.ts.snap to x.test.ts |
| rule | a [[graph.edges]] rule, below |
Single-file components are sources too. In a .vue or .svelte file every <script> block is read, <script setup> and Svelte’s context="module" included, and in an .astro file its --- frontmatter and its <script> blocks. Each block is parsed as its lang says (ts, tsx, js), else as JavaScript in Vue and Svelte and TypeScript in Astro, and a <script src="…"> counts as an import. The component file is the node, so import Card from './Card.vue' resolves to it like any other import, and a change to a component reaches the tests that import it, directly or through other components.
An import() or require() whose argument isn’t a plain string is counted as an unknown dynamic import; the planner treats the file as depending on its whole module.
Edges the imports don’t show
Some tests reach their code without importing it. An API test boots the server and calls it over HTTP, so no import joins the test to the area it exercises. A rule adds that edge, and the planner follows it like an import:
[[graph.edges]]
from = "apps/api/test/api/{area}{,-*}.test.ts"
to = ["apps/api/src/{area}/**"]
Each file matching from depends on every file its to globs match. A {name} stands for one path segment, with the same value on both sides, so orders.test.ts and orders-refunds.test.ts depend on src/orders/. Its value is read from the side where it’s a whole segment: {area}* alone would also take orders-refunds as the name. config check refuses a {name} that is never a whole segment, or a to glob that names different ones than from (a to with none links to the same files for every match). graph stats counts rule edges, and names any rule that linked no file.
Names in the code
Since 0.10.0: some dependencies are named in a file’s text rather than imported: a view by name, a config file by its key, a model’s factory by convention. A rule with find searches each from file’s text with a regex, and links only the files it matches in. Its capture, if it has one, fills {1} in to, and {1|path} fills it with its dots as slashes:
[[graph.edges]]
from = "**/*.php"
find = '''\bview\(\s*['"]([\w.\-/]+)['"]'''
to = ["resources/views/{1|path}.blade.php"]
[[graph.edges]]
from = "tests/**/*.php"
find = '''\bRefreshDatabase\b'''
to = ["database/migrations/**"]
The first makes a controller that calls view('users.index') depend on resources/views/users/index.blade.php. The second has no capture, so it links each test that uses RefreshDatabase to every migration. from may still use {name}s, as whole segments, and to may use them beside {1}. A filled to without wildcards is looked up directly; one with wildcards is matched like any glob. A name only a running program knows, such as view("admin.$page"), never matches, so it adds no edge. graph stats names each rule whose captures matched no file, with an example, which is where to look when replay finds a miss.
Framework packs
Since 0.10.0: a framework makes dependencies by convention, the same in every project that uses it. A pack bundles the rules for one framework, and a config names it in extends:
extends = ["laravel"]
Since 0.11.0: fairlead init names the pack a project’s manifests point to (artisan and laravel/framework for laravel, next in package.json for next), and init --pack NAME writes it. graph stats counts a pack’s rules that linked no file in one line rather than listing them, since a pack covers layouts a project needn’t use.
A pack is a layer under the project’s own config files, so a project’s rules and barriers come after its, and { replace = [...] } drops one of them (graph.barrier = { replace = [] } keeps none). A pack sets only graph.edges, graph.barrier and plan.run_all. A built-in pack ships in the binary, so it changes with the release, and the plan id changes with it. Any other name is a pack file’s path from the project root, such as extends = ["packs/ours.toml"], which is how a team writes its own.
| Pack | What it adds |
|---|---|
laravel | tests/Feature/** depend on app/Http/**, routes/** and resources/views/**, since HTTP tests reach controllers through routes no import shows. Views named in view(), View::make() and Blade’s @include, @extends, @each and @component; Blade components (<x-forms.input>); config('mail.from') to config/mail.php; Song::factory() to database/factories/SongFactory.php; tests using RefreshDatabase, LazilyRefreshDatabase or DatabaseMigrations to every migration. A barrier on bootstrap/app.php and bootstrap/providers.php, so the boot path, which names every route file and service provider, doesn’t tie each test to the whole app; a change under bootstrap/ or app/Providers/ runs everything instead. |
next | Since 0.11.0. App Router routes (page, route, default) depend on the layout, template, loading, error and not-found files of every folder above them, five deep, and the root’s global-error; Pages Router pages depend on _app and _document. app/ and src/app/ alike. A change to next.config.* runs everything. Since 0.12.0: the end-to-end rule below moved to next-e2e. |
next-e2e | Since 0.12.0. With next: an end-to-end spec that calls page.goto( depends on every route and on middleware. It is coarse, every spec reaching every route, so a project whose owner rules map pages to specs leaves it out (#212). |
PHP
.php files are scanned too (Blade templates, .blade.php, are left as plain files), and graph stats lists them as the php provider. PHP refers to classes by name, not by path, so each reference is first made fully qualified the way PHP does it: through the file’s namespace, its use imports (grouped and aliased ones included), and \ or namespace\ prefixes. A file depends on:
- each class it names in a type,
new,::,extends,implements, a traituse, an attribute orinstanceof, and each name it imports withuse; - each function it calls by name, tried in its namespace and then globally, as PHP does, so a call to a helper reaches the file that declares it;
- each file an
includeorrequirenames with a literal:'x.php',__DIR__ . '/x.php'ordirname(__DIR__, 2) . '/x.php'; any other string that looks like a path counts as a path literal, as in JavaScript.
A name becomes a file through composer’s autoloading, read from every composer.json in the tree, so path repositories count: the longest psr-4 or psr-0 prefix under autoload or autoload-dev whose file exists, else any file that declares the class or function, which covers classmaps and files. A name under one of the repository’s own prefixes that no file declares is kept as the path autoloading would load, so deleting that file still reaches the files that named it. Anything else is a vendor or built-in name and is left out.
PHP tests aren’t matched by default. Name them, and the runner that runs them:
[tests]
match = ["tests/**/*Test.php"]
[[tests.runners]]
id = "phpunit"
match = ["tests/**/*Test.php"]
command = ["vendor/bin/phpunit", "{files}"]
In a Laravel application every test boots the app, and booting it loads bootstrap/app.php, which names every route file, which names every controller, so a change to one controller reaches every test. That’s the safe answer, and the one this gives without a pack. The laravel framework pack stops the walk at the boot path and links feature tests to the HTTP layer, views, config files, factories and migrations instead. An app that loads its routes another way can have the opposite problem, a controller almost nothing reaches, which the pack’s feature-test rule covers too.
Go
.go files are scanned as the go provider. A Go package is every .go file in one directory, so:
- a file that imports a package depends on each of its non-test files;
- a test file depends on every other
.gofile in its directory, the package’s own and its other test files, since they’re compiled together; - a
//go:embedpattern depends on the file it names, every file under a directory it names, or the files a glob matches.
Import paths resolve through every go.mod in the tree, so a go.work workspace’s modules are all found. The module whose path is the longest prefix of the import owns it, and a replace with a local path (=> ../lib) points a module at that directory. An import no module in the tree owns is looked for under the importing module’s vendor/, and otherwise is the standard library or a dependency, left out. Build constraints aren’t read, so a file for another platform still counts, which only ever adds tests.
go test takes packages rather than files, so its runner uses {packages}, which expands to one ./dir per directory holding a selected test, or ./... when everything runs:
[tests]
match = ["**/*_test.go"]
[[tests.runners]]
id = "go"
match = ["**/*_test.go"]
command = ["go", "test", "{packages}"]
Each Go file depends on its module’s go.mod and go.sum (edge kind manifest), since they pick the versions of everything it imports. A dependency bump in one module therefore selects that module’s tests and the tests of every module importing it, not the whole repository. Under a go.work, which picks versions across every module it spans, a file depends on all of those modules’ manifests instead. A change to go.work or go.work.sum, which spans the workspace, runs everything by default (plan.run_all), as a lockfile does. To have a go.mod change run everything anyway, add **/go.mod to plan.run_all.
Python
.py files are scanned as the python provider, with tree-sitter, so an import inside a function or a try counts like one at the top. A file depends on:
- the module each
import a.bnames, and the package__init__.pyfiles above it, which importing it runs; - for
from a.b import c, the modulea.b.cwhen there is one, elsea.b, whose namecis;from a.b import *namesa.b; - relative imports (
from . import x,from ..models import y) from the file’s own package; - any string that looks like a path, as in JavaScript.
Top-level modules are looked for under the repository root, each folder holding a pyproject.toml, setup.py or setup.cfg, and the src folder beside any of those, the ones above the importing file first. A module found under none of them is the standard library or an installed package, and is left out.
pytest loads every conftest.py from the test’s folder up to the root before the test, so a test file (test_*.py or *_test.py) and a conftest.py depend on each conftest.py above them. A conftest.py that imports much of the package makes most changes reach every test, which is what pytest does too.
[tests]
match = ["tests/**/test_*.py"]
[[tests.runners]]
id = "pytest"
match = ["tests/**/test_*.py"]
command = ["pytest", "{files}"]
A change to pyproject.toml, setup.py, setup.cfg, requirements*.txt, poetry.lock, uv.lock or Pipfile.lock runs everything by default (plan.run_all).
Java and Kotlin
Since 0.7.0: .java and .kt files are scanned as the jvm provider, with
a small lexer of its own rather than a grammar, so it costs the binary almost
nothing. Java and Kotlin refer to a class by its qualified name, and a class
in the same package needs no import, which is how a JUnit test usually
reaches the class it tests. So every file’s package line and top-level
declarations (classes, interfaces, enums, records, annotations, and Kotlin’s
objects, type aliases and top-level functions) make an index of qualified
names, and a file depends on:
- each class an
importnames, or the class that holds a nested class or a static member it names (import static a.b.Money.formatisa.b.Money); - each class it uses by name that its own package, or a wildcard import
(
import a.b.*), declares; - in Kotlin, each top-level function it calls that its package declares.
A name nothing in the tree declares, such as the JDK’s or a dependency’s, is left out. Paths and build files don’t matter: the index is built from what the files say, so a multi-module Maven or Gradle build resolves across modules. Names used only in comments or strings, and reflection, don’t count.
Maven and Gradle take test classes rather than files. An argument holding
{class} is repeated once per selected test class, and {classes} joins
them with commas; the class comes from the source-set layout, so
core/src/test/java/com/acme/OrderTest.java is com.acme.OrderTest. When
everything runs, the argument is dropped and the whole suite runs.
[tests]
match = ["**/src/test/**/*.java", "**/src/test/**/*.kt"]
[[tests.runners]]
id = "gradle"
match = ["**/src/test/**"]
command = ["./gradlew", "test", "--tests={class}"]
# Maven instead: command = ["mvn", "test", "-Dtest={classes}", "-Dsurefire.failIfNoSpecifiedTests=false"]
[plan]
# Build files pick every dependency's version, as a lockfile does.
run_all = ["**/pom.xml", "**/build.gradle", "**/build.gradle.kts", "settings.gradle*", "gradle/libs.versions.toml"]
Setting plan.run_all replaces the default list, so copy the defaults you
still want (fairlead config show --origin prints them).
On two public repositories, a change to each of 40 sampled main classes selected every test file that names the class, apart from names that appear only in a comment, a string or a test’s own class of the same name: gson (122 main and 142 test files) and moshi (Kotlin and Java, 95 and 48).
Other languages: external providers
The built-in scanners read JavaScript, TypeScript, PHP, Go and Python, and Java and Kotlin (since 0.7.0). For any other language, or a build tool that already knows its own graph, a [[graph.providers]] entry names a command that prints the graph for the files it claims:
[[graph.providers]]
id = "go"
command = ["./tools/go-graph.sh"]
files = ["**/*.go"]
- The command runs at the project root and reads the files it claims on stdin, one per line. It prints
{"version": 1, "edges": [{"from": "pkg/a_test.go", "to": "pkg/a.go"}]}: repo-relative paths, wherefromdepends onto. The shape is a public contract, committed asprovider-v1.schema.json. - Claims: a file a provider’s
filesmatch belongs to that provider, and the built-in scanner leaves it alone. When two providers match one file, the one whose pattern has the longer literal part before its first wildcard wins (src/go/**over**/*.go), the earlier one on a tie, andgraph statsreports the conflict. - Its edges count only from files it claims, to files in the tree; the rest are counted as ignored in
graph stats. They’re followed like imports, with their own edge kind,provider. - A provider that fails (it can’t start, exits non-zero, runs past
timeout_seconds, 120 by default, or prints something that isn’t a version 1 graph) tells nothing about its files. A change to one of them runs every test, with aprovider-failedwarning naming the provider and why, as other uncertainty does. graph statslists each provider with its files and edges, the built-in scanners astypescript,php,goandpython.
Build tools that already know their graph plug in the same way, through a command that turns their output into this shape, such as cargo metadata for Rust, or a monorepo tool’s project graph.
Coverage maps
Some dependencies only show up when the code runs: a container that builds a class from a string, importlib.import_module, framework wiring. A coverage run sees them all. A coverage map records which source files each test ran, and its edges (kind coverage) join the static graph. A change reaches a test through either, and a new file the map predates still reaches its tests through its imports. It’s off by default, since it needs a full coverage run to make.
Make the map from a coverage run’s report, in the same job:
# PHPUnit, with pcov or Xdebug
vendor/bin/phpunit --coverage-xml build/coverage-xml
fairlead coverage import --format phpunit-xml build/coverage-xml
# pytest
pytest --cov=src --cov-context=test
coverage json --show-contexts -o coverage.json
fairlead coverage import --format coverage-py coverage.json
and name it:
[graph.coverage]
map = ".fairlead/coverage.json"
max_age_days = 14
- PHPUnit names each test
Class::method, so the test file is the file that declares the class, through the same autoloading as PHP. coverage.py needs the per-test contexts pytest-cov records with--cov-context=test; lines run outside any test, such as imports at collection, count for none. - The map is
{"version": 1, "commit": ..., "created": ..., "source": ..., "tests": {"tests/a_test.py": ["src/a.py", ...]}}, stamped with the commit it tested and the day it was made. The shape is a public contract, committed ascoverage-v1.schema.json, so any tool can write one. A path from another checkout is matched by its longest tail that’s a file here. - A map older than
max_age_daysleaves acoverage-stalewarning on the plan, naming its date and commit; one that can’t be read leavescoverage-unreadable, and the plan uses the static graph alone. Neither fails the plan. A change to the map itself selects nothing. graph statsreports the map’s edges, tests, source, date and commit, and how many of its pairs name a file no longer in the tree.
Refresh it on a schedule, such as a nightly workflow that runs the suite with coverage, imports the report and commits the map or keeps it where CI can fetch it.
Barriers
In a typical server, every area imports a shared module for its routes or its auth, and that module imports every area. A walk through it reaches every test from any change. A barrier stops that:
[graph]
barrier = ["apps/api/src/{http,db,auth}/**"]
The same fan-out happens on the test side: end-to-end specs often import one shared module, such as a strings or fixtures index, only to build their expected values, and then any change under it plans every spec. A barrier on that index fixes it.
The walk reaches a barrier file, but doesn’t go on to the files that import it. That includes a changed barrier file, which reaches nothing and is left to tests.unreached, unless plan.run_all or an owner rule covers it. So list a barrier’s paths in plan.run_all when a change to them should run everything. graph why still shows a chain through a barrier and says the plan stops there, and test --explain names the barrier that kept a test out.
A rule edge is a claim the imports can’t check. Replay measures it: a failure in a test the rule doesn’t reach shows up as a miss.
Resolution, without an install
Specifiers resolve the way Node and TypeScript do: tsconfig paths and project references (the nearest tsconfig to each file), .js specifiers that point at .ts files, directory index files, and package exports with the conditions in graph.conditions.
Workspace packages, from workspaces in the root package.json or packages in pnpm-workspace.yaml, resolve without node_modules: Fairlead shows the resolver a virtual node_modules that points at each package’s folder. So a plan can run before npm install, and in CI before the install step.
- An import that lands in a package’s build output (its tsconfig
outDir, not in git) maps back to the same path underrootDir, whether or not a local build has put the output on disk. - A workspace import that still lands on a missing or git-ignored file becomes an edge to every file of that package, which is always safe.
- A tsconfig that
extendssomething that can’t be read (a shared config published as a package, before install) is skipped for that file, and counted ingraph stats. - Since 0.8.0: an import of a package from outside the repository is no edge, but the graph keeps its package name, from the specifier and, when it resolved into
node_modules, from the folder it landed in. A plan uses it to find the files a dependency’s version bump reaches, installed or not.
Files larger than 256 KB, almost always generated, are scanned for import strings instead of parsed. The scan can’t tell a comment from code, so it takes from, import and require only where they start a word, and only a specifier without whitespace, which no module name has: a doc comment reading from "My booking" isn’t an import.
Speed
A cold build of a 2,000-file workspace takes well under the 1.5 second budget on 4 cores, and a warm one under half a second. CI checks both on every pull request.
Parsing is most of a cold build. Fairlead keeps each file’s parse result in .git/fairlead/parse-cache.json, keyed by the file’s git blob id (the same id git hash-object prints) and its extension, so a file is parsed again only when its bytes change. The cache sits inside the git directory, so it’s never committed and needs no .gitignore line; in a worktree it goes in that worktree’s git directory. A new Fairlead version starts it over, entries for files that are gone drop out on the next build, and an unreadable cache is rebuilt. graph stats reports hits and files parsed. To turn it off, set graph.cache = false, or FAIRLEAD_GRAPH__CACHE=false for one run.
On the benchmark repositories, 4 cores:
| Repository | Cold | Warm |
|---|---|---|
| Effect-TS/effect | 1.19 s | 0.07 s |
| pnpm/pnpm | 1.02 s | 0.26 s |
| vitest-dev/vitest | 0.41 s | 0.05 s |
Test plan
fairlead plan works out which tests and checks the changes since a base commit can affect, and says why each one is in. It gates on recall: a plan must include every test the change can break. Selecting fewer tests is the goal, never the rule.
fairlead plan # changes since the remote's default branch
fairlead plan --base main --json # the plan as JSON (schema below)
fairlead plan --files src/a.ts # plan for these files or directories, no git needed
fairlead test --explain test/a.test.ts # why a test or check is in, or isn't
The working tree is always the head: in CI that’s the checked-out commit. The base is the merge base of --base (default: origin/HEAD, then origin/main, origin/master, main, master) and HEAD. If there’s no merge base, as in a shallow clone, the plan fails with exit code 2 rather than planning nothing; fetch more history (fetch-depth: 0) or pass --files.
How a plan is built
- Changed paths.
git diff --name-status --find-renamesfrom the base to the working tree, plus untracked files. A rename counts both paths; its old path counts as deleted. - Run everything. A changed path matching
plan.run_allselects every test and check. The defaults are lockfiles, root manifests, tsconfig files, test-runner and task-runner config, and CI workflows. Withplan.lockfile = "scope"(opt-in for now), a rootpnpm-lock.yamlis the exception when it can be read: see Lockfile changes. Since 0.8.0: a change that only moves dependency versions, an edit to Fairlead’s own config and a workflow that runs only by hand or on a schedule are exceptions too: see Changes that don’t run everything. - Ignored paths. A changed path matching
plan.ignorestill selects every test it reaches through the files that depend on it. When it reaches none, it’s listed underignoredand selects nothing, where it would otherwise fall totests.unreached. That holds for source files too, imported or not: a unit test’s fake, or a dev tool’s code, reaches no end-to-end spec. A changed test file is never ignored. So a layer that plans a different kind of test (say--env e2e) can list the unit tests, their fakes and dev tools here. The defaults are root Markdown, the changesets tool’s folder (.changeset/**),docs/**, READMEs, changelogs and licences. - Deleted files. A deleted file goes back into the graph as a phantom, joined to every import that now fails but would resolve to it, every path literal that names it, and its package, so whatever depended on it still counts.
- Package manifests. A changed
<package>/package.jsoncounts as every file in that package changing. - The walk. From every changed file to everything that depends on it, over import graph edges. A file with a non-literal dynamic import, a local import that doesn’t resolve, or a tsconfig that couldn’t be applied depends on every file in its module; at the root, on every file.
- Tests (below), unreached files, then checks and the invocations that run them.
Lockfile changes
With plan.lockfile = "scope", a changed root pnpm-lock.yaml (lockfile versions 6 to 9) is compared with the base’s. Each workspace package whose own entry changed, or that depends, in the base or the head lockfile, on a package whose entry changed, counts as if its package.json changed: every file in it starts the walk, and checks and owner rules watching that manifest see it. A package nested inside an affected one is affected too, since Node resolution walks up.
It still selects everything when:
plan.lockfile = "all", the default until the benchmarks have more evidence;- there’s no base (as with
--files); - pnpm hoists packages into a shared
node_modules(node-linker=hoisted,shamefully-hoistor apublic-hoist-patternin.npmrcorpnpm-workspace.yaml); - the root package’s dependencies changed, since every package sees them;
- an affected entry isn’t a workspace package in the head tree;
- anything outside
importers,packages,snapshotsandcatalogschanged, such asoverrides,patchedDependenciesorsettings; - or the lockfile doesn’t parse, has no importers (a single-project lockfile), or names a package it doesn’t list.
A lockfile change that reaches no package selects nothing and says so in a lockfile-scoped-to-nothing warning.
Other lockfiles (package-lock.json, yarn.lock, bun.lockb) always select everything, and so does a bun.lock change that’s more than versions moving (below).
Changes that don’t run everything
Since 0.8.0: three kinds of change match plan.run_all or reach no test, yet can’t change what a test does. Each says what it did in a warning, so fairlead plan and --explain aren’t silent about it.
Dependency versions moving (version-bump-scoped). A root or workspace package.json, or the root bun.lock, whose change only moves versions selects the files that import the moved packages, and the tests that reach those, instead of everything. Only moving versions means the parsed JSON differs only in the version strings of dependencies, devDependencies, peerDependencies and optionalDependencies entries, and in the package’s own version; for bun.lock, only in package entries’ resolved versions, integrity and dependency versions, and in each workspace’s dependency versions. A package that depends on a moved one, by name in the base or the head bun.lock, counts as moved too, since it loads the new version. The import graph keeps every bare import from outside the repository, whether it resolved into node_modules or the package isn’t installed, so an import names the package even without an install. A test’s chain starts at package.json (left-pad). A package nothing imports, such as a command-line devDependency, selects nothing, and a package’s own version alone selects nothing either.
It still selects what it did before (everything for the root files, the whole package for a workspace manifest) when:
- the change does more than move versions: a script,
workspaces,overrides, a package added or removed, a version that becomes aworkspace:,file:,npm:or git specifier, and anything that doesn’t parse; - there’s no base text (as with
--files), the file was added, deleted or renamed, or there’s nobun.lockat the head (npm, yarn and pnpm projects); - a moved package, or one depending on it, is how a
[[tests.runners]]command runs, by its name or a command it installs (version-bump-runs-everything); - a file matching
plan.run_all, such asvitest.config.ts, imports one (version-bump-runs-everything); - or one is named in text where no import shows it: a file matching
plan.run_all(other than apackage.jsonor a lockfile) or thescriptsof the root or a changedpackage.jsonholds its name, or a command it installs, as a whole word, bounded by quotes,/, whitespace, a shell or list separator or either end. That catches apreset: 'ts-jest', a plugin list,"types": ["vitest/globals"]and a CLI a script runs; the warning names the file. A devDependency named only in the manifest and the lockfile still selects nothing.
Fairlead’s own config (fairlead-config). fairlead.toml, fairlead.yaml or a layer such as fairlead.ci.toml, at the root, selects nothing by itself: the plan is made with the new config. It used to fall to tests.unreached, since no test imports it. A config file you list in plan.run_all still selects everything.
A workflow run only by hand or on a schedule (workflow-dispatch-only). A .github/workflows/*.yml or *.yaml file whose only triggers are workflow_dispatch and schedule can’t change a pull request’s or a push’s run, so it selects nothing by itself. The on: key is read at the head and at the base (a deleted file only at the base, an added one only at the head). If either side has any other trigger, workflow_call and workflow_run included, or doesn’t parse, or the base text is missing, it selects everything as before.
Checks and owner rules whose paths match one of these files still see it change.
Modules
A module is a unit a plan can widen to: each workspace package (modules.discover = ["workspaces"]), and each directory a modules.define pattern matches, named by its {name}. A file belongs to the deepest module above it; a file in none is at the root.
Tests and classes
Test files are those matching tests.match and not tests.exclude. When runners are configured, each test must match exactly one [[tests.runners]]; fairlead config check lists any that match none or two, and a plan refuses to run with them. A test’s class is set by the first [[tests.classes]] rule that matches, unit by default.
| Class | Selected when |
|---|---|
unit | it changed, it depends on a changed file, or an owner rule claims it |
own | it changed, or an owner rule claims it |
demand | it changed (never under run_all) |
canary | every plan |
An owner rule selects tests that don’t import what they test. A changed path matching one of its covers selects the tests its match names, with any {name} the change captured filled in, so a change under services/api/src/ picks services/api/test/ and not every service’s tests:
[[tests.owners]]
match = "services/{name}/test/integration/**"
covers = ["services/{name}/src/**"]
A path an owner rule covers still selects everything when it matches plan.run_all, unless the rule sets overrides_run_all = true. Then it selects that rule’s tests, plus whatever depends on it. That fits a fixture project’s runner config, which **/vitest.config.* matches but only one suite’s tests load:
[[tests.owners]]
match = "test/{suite}/**"
covers = ["test/{suite}/**/fixtures/**", "test/{suite}/vitest.config.*"]
overrides_run_all = true
Don’t set it for a file something outside the rule’s tests reads, such as a config another package’s config imports or a script names with --config. When the rule claims no test for the path, it runs everything as before.
Unreached files
A changed file that isn’t a test and has no test anywhere among the files depending on it is unreached (a deleted test file and a package manifest aren’t, since there’s nothing left to run for one and the other already stands for its package): something the graph can’t see uses it, like a setup file named only in a runner config. It’s always listed under unreached with a warning, so you can write the owner rule that covers it. Unless one already claims a test for it, tests.unreached decides what it selects. An owner rule whose match names no test file in this config, as can happen when a layer replaces tests.match, doesn’t count as covering the path, and config check warns about it:
tests.unreached | Selects |
|---|---|
module (default) | its module’s tests; everything when it’s at the root or its module has no tests |
all | everything |
warn | nothing |
Checks
A [[checks]] entry is selected when its paths match a changed file (deleted files included, since removing a file can break a typecheck), when its modules include one the change reaches, or in every plan when it names neither. {files} in its command expands to the changed files its paths match, deleted ones left out; with files = "matched", or under run_all, to every file they match.
Invocations
The plan lists what to run as invocations, each an argv and a working directory. {files} expands to one argument per file.
- A runner with
invoke = "once"gets one invocation with every selected test. - A runner with
invoke = "per-module"gets one per module holding selected tests, incwdwith{module}(the module’s root) and{module.id}(its name) filled in, with files relative to that directory. - Under
run_allor a widening to everything,{files}expands to nothing, so each runner runs its whole suite, per module for per-module runners. Since 0.7.1: a runner withall_commandruns that instead ofcommand, for a tool that, given no files, finds more tests than the runner’smatchclaims (Configuration). Since 0.12.0: a runner whose working directory holds another runner’s tests names its own instead, since the tool would load the others too, asbun testloads Playwright specs; a runner withall_commandstill runs that (#210). - Each selected check follows the runners.
- A test or check a
[[quarantine]]entry holds on this machine runs alone, after the rest (below).
Tests that lie on one platform
Since 0.7.0: some tests fail on one machine whatever the change. A test that splits a file on \n fails on a Windows checkout with core.autocrlf=true, since every line keeps its \r. A test that runs a process in new URL(..., import.meta.url).pathname fails with ENOENT on Windows, where that path is /C:/..., and wherever the checkout’s path has a space, which it spells %20. Each one looks like a regression until someone diagnoses it by hand, and the next agent on that machine starts cold and pays the same again. A [[quarantine]] entry records the diagnosis once:
[[quarantine]]
path = "test/headings.test.ts" # a test file, or `check = "<id>"` for a [[checks]] entry
when = ["autocrlf"] # conditions detected here, all of which must hold
signature = "has no heading" # a regex over the failure's output
reason = "it splits docs/decisions.md on LF only, so a CRLF checkout keeps a CR on every heading"
proved_in = "CI on Linux"
until = "2026-12-31"
[[quarantine]]
path = "test/tracked-files.test.ts"
os = "windows" # windows, macos or linux
signature = "spawnSync git ENOENT"
reason = "it runs git in a file URL's pathname, which is /C:/... on Windows"
proved_in = "CI on Linux"
until = "2026-12-31"
An entry holds where its os matches and every condition in when is detected, and only until its until date. The conditions are detected, never declared: autocrlf when git’s core.autocrlf is true for the repository, and space-in-path when the repository’s path has a space in it. An entry needs an os, a when or both, so it can’t hold everywhere. Elsewhere it does nothing, so the same plan on Linux runs the test as usual.
Where an entry holds:
fairlead planlists the test or check under not provable here, with what made the entry hold, its evidence, where it is proved instead and its date. The plan JSON lists it inquarantined, and the invocation that runs it carries its name inquarantined.- It still runs, alone, so its failure can be read by itself. Its runner’s other tests run together without it, and are named one by one even in a plan that runs everything. Since 0.7.1: in a plan that runs everything, a runner with
exclude_argkeeps its whole run (itsall_command, orcommandwith no files) and adds the fragment once per held test file, relative to the invocation’s working directory, so the tool still finds the rest. A runner without it names its other tests, which drops any the tool would find thatmatchdoesn’t claim and can make a long command line, so the plan warnsquarantine-narrowed-everything. A runner whose command has no{files}can’t run one test alone, so the plan warnsquarantine-not-separableand the test runs with the rest. fairlead doneandfairlead ci rundon’t count a failure whose output matchessignature. They report it as not provable here, so the agent can say so in the pull request instead of diagnosing it again or skipping it quietly. A failure with any other output is real, and counts. An entry excuses one failure: when the held file’s output names more than one failed test, read the way replay reads that runner (Vitest, Jest, bun, pytest, PHPUnit, Pest orgo test), the run counts. A runner whose output Fairlead can’t read is judged bysignaturealone.- The machine that runs a plan checks the entry again: a plan made where an entry holds excuses nothing on a machine where its OS or conditions don’t hold.
- After
until, the entry stops holding. The plan warnsquarantine-expired, and a failure counts again.
fairlead test --explain says the same for one test, or why an entry doesn’t hold here. fairlead config check names the conditions it detects on the machine and whether each entry holds, and fairlead doctor names the conditions.
[[replay.quarantine]] is a different tool: it keeps a test that flakes in named CI jobs out of replay’s recall (Replay).
Reasons
Every test and check carries the reason that put it in first: run-all, changed, import with the chain from the change to the test, owner, canary, unreached, and for checks paths, modules or always. fairlead test --explain prints the chain for a selected test, and for one that isn’t selected, why not. Since 0.9.0: each hop that isn’t a plain import names its kind, such as (type import), (path literal) or (coverage), and a hop the walk adds conservatively says why it’s there:
$ fairlead test --explain test/pricing.test.ts
test/pricing.test.ts is selected (unit):
src/a.ts
-> src/api.ts (unresolved import `./gen/missing`, so it depends on its whole module)
-> src/types.ts (type import)
-> test/pricing.test.ts (type import)
The other conservative hops read a dynamic import with no literal path or its tsconfig couldn't be applied, and a workspace import that resolved to no file names the specifier and the package it’s kept as a dependency on. graph why names each edge’s kind the same way.
Plan JSON, version 1
fairlead plan --json prints the plan; --out PATH also writes it. Write it outside the working tree, or ignore it in git, or the next plan counts it as a change. The shape is a public contract with a version field: new fields are additive, so a reader must accept fields it doesn’t know, and optional fields are left out when empty; anything else bumps the version. The schema is committed as plan-v1.schema.json and printed by fairlead plan --schema.
plan_id: stable for the same Fairlead version, config, tree, base and changes.config_digest:sha256:of the merged config.tree_hash:HEAD’s git tree id when the working tree is clean, elseworktree:and a hash of every file’s path and blob id.head:HEAD’s commit when the working tree is clean, elseworktree.base,all,changed,ignored,tests,checks,invocations,unreachedandwarningsas described above.quarantined: the selected tests and checks a[[quarantine]]entry holds on the machine that made the plan, left out when there are none. Each has itstarget(the test file or check id),kind, what made it hold (here),signature,reason,proved_inanduntil.
Plans in CI
fairlead ci plan makes the test plan for the commit a CI job checked out, writes it as JSON, and with --format github sets step outputs. fairlead ci run --plan runs the plan’s invocations. The plan reads the working tree, so check out the commit you want planned, with enough history for the merge base:
on: pull_request
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }} # the head, not the merge commit
fetch-depth: 0 # history for the merge base
persist-credentials: false
- id: plan
uses: mahkassem/fairlead@v0.12.0
with:
command: >-
ci plan --format github
--base ${{ github.event.pull_request.base.sha }}
--head ${{ github.event.pull_request.head.sha }}
- run: npm ci
if: steps.plan.outputs.tests != '0' || steps.plan.outputs.checks != ''
- run: fairlead ci run --plan "$PLAN"
env:
PLAN: ${{ steps.plan.outputs.plan }}
Pin the action and actions/checkout to a commit SHA in your own workflows.
fairlead ci plan
| Flag | Default | |
|---|---|---|
--base SHA | the remote’s default branch | The merge base of this and HEAD is what changes are measured from. |
--head SHA | none | Refuses to plan if HEAD is another commit. actions/checkout checks out the merge commit on pull_request unless you pass ref. |
--format json|github | json | github also writes step outputs. |
--out PATH | fairlead-plan.json in $RUNNER_TEMP or the system temp directory | Never the working tree, where the file would count as a change. A relative path is from the current directory. |
--set KEY=VALUE | Override a config value for this run. | |
--stage auto|none|draft|ready|merge|full | auto for a config that uses stages, else none | Since 0.8.0: the stage to plan for. none plans as before stages existed. |
--since-green JOB | none | Since 0.8.0: at the merge stage, plan from the last commit where the job JOB passed, so one run covers the batch merged since (batching). Repeatable. Ignored at other stages. |
A missing merge base fails with exit code 2, never an empty plan: fetch more history or pass --files.
Step outputs
| Output | Value |
|---|---|
all | true when everything is selected |
plan | the path to the plan JSON |
plan_id | the plan’s id |
invocations | the invocations as a JSON array; fromJSON makes it a job matrix |
checks | the selected check ids, space-separated |
tests | how many test files are selected |
stage | Since 0.8.0: the plan’s stage, empty without one |
reused | Since 0.8.0: at merge, the pull request whose passing run of this exact tree the ready steps were reused from, empty otherwise |
run_<id> | Since 0.8.0: true or false for every runner and check: whether this plan runs it. An id’s characters other than letters, digits and _ become _, so desktop-build is run_desktop_build; two ids that become one name fail the plan |
Nothing is keyed to a runner name or folder layout. invocations can feed a job matrix with fromJSON; pass each argv to a program through an environment variable rather than writing it into a run: line, which would let a path in the plan be read as shell syntax. For most projects, fairlead ci run --plan in one job is simpler.
Stages
Since 0.8.0: a change goes through stages, and each runner and check says the earliest one it runs at with from. It runs at that stage and every later one: draft < ready < merge < full.
[stages]
environments = ["main"] # pushes here are the merge stage; add "staging", "production"
full_label = "run-everything" # a pull request label that runs everything
[[tests.runners]]
id = "unit"
match = ["src/**/*.test.ts"]
command = ["bunx", "vitest", "run", "{files}"]
from = "draft" # draft | ready (a runner's default) | merge | full
[[tests.runners]]
id = "e2e"
match = ["e2e/**/*.spec.ts"]
command = ["bunx", "playwright", "test", "{files}"]
from = "merge"
[[checks]]
id = "desktop-build"
command = ["./scripts/build-desktop.sh"]
from = "merge" # a check's default is draft
ci plan reads the stage from the GitHub event:
| Event | Stage |
|---|---|
pull_request or pull_request_target, a draft | draft |
pull_request or pull_request_target, not a draft (any action, ready_for_review included) | ready |
a pull request labelled stages.full_label | full |
push to a branch in stages.environments (a name or a glob such as release/*) | merge |
merge_group | merge |
push to any other branch, a tag, or another event | ready |
schedule or workflow_dispatch | full |
Outside GitHub Actions, auto is ready. The first line of output says which stage and why, such as stage: draft (auto: pull_request, a draft).
The plan still decides which tests run; the stage decides whether a runner or check runs at all. A later stage’s steps leave the plan’s invocations, tests and checks and are listed in its deferred, each with its from and how many tests or checks it would have run, so a step waiting for its stage reads differently from one the change didn’t reach. full plans everything whatever changed. Each stage of a tree has its own plan_id. [stages] and from are left out of config_digest: they say when a step runs, not what it proves.
A config with no [stages] and no from plans exactly as before, with no stage and the same plan ids. So does --stage none.
Reuse at merge
With stages.reuse (on by default once [stages] is set), a merge doesn’t run again what its pull request already passed on the same bytes:
- At the ready stage, once
ci runhas run every step of the plan and all of them passed, it sets the commit statusfairlead/treeon the pull request’s head commit, saying which tree it tested and the config digest it planned with. That needsstatuses: writeon the job. Without it, or without a token, the run still passes and says the merge will run these steps again. - At the merge stage,
ci planfinds the pull request the pushed commit came from and reads that status. When the pushed tree and the config digest are exactly the ones it recorded, the ready stage’s steps leave the plan, the plan’sreusednames the pull request and the steps, and the run saysreuse: #7 passed this tree; skipping unit. The merge stage’s own steps, such as end-to-end, still run. - Anything else runs them: another tree (the base moved before the merge, so the merged bytes were never tested together), another config, a run that skipped some of its steps with
--onlyor--except, a failed or held step, a working tree that isn’t a commit, or an API error. A merge commit, a squash and a rebase all give the same tree when the base hadn’t moved.
Batching after merge: --since-green
Since 0.8.0: a slow step from the merge stage, such as an end-to-end runner, can run once per batch of merged changes instead of once per push. At the merge stage, fairlead ci plan --since-green e2e plans from the head commit of the newest earlier run of this workflow on this branch where the job named e2e concluded success. The plan then covers every change merged since that job last passed, whatever runs were cancelled, skipped or failed in between. JOB is the job’s name as the Actions API reports it: its name:, or its id when it has none. With the flag repeated, the base is the newest commit at which every named job had passed.
It reads GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_API_URL and a token from GITHUB_TOKEN or GH_TOKEN, which needs actions: read. It finds this run’s workflow, lists up to 100 of that workflow’s runs on the branch, newest first, and reads each run’s jobs until the job has passed. Only earlier push and merge_group runs on the same branch count: a pull request run tested a commit that never landed as such. The chosen base and its run are printed, so a failing run names its batch, everything between that commit and HEAD:
since-green: base 1f2e3d4c5b6a from run 912 (#41), the newest where e2e passed; this batch is 1f2e3d4c5b6a..HEAD (https://github.com/OWNER/REPO/actions/runs/912)
Any doubt about the base plans everything, with one line saying why and a plan warning:
| Warning | When |
|---|---|
since-green-unavailable | no token, not a GitHub Actions run, or the API failed or was rate-limited |
since-green-not-found | none of the last 100 runs on the branch has the job passing |
since-green-not-in-history | the commit isn’t in the clone (check out with fetch-depth: 0), or isn’t an ancestor of HEAD |
At any other stage the flag is ignored with a one-line note, so one plan command serves every event.
A staged workflow: fairlead ci workflow
Since 0.8.0: fairlead ci workflow prints a GitHub Actions workflow made from the config, and fairlead ci workflow --write writes it to .github/workflows/fairlead.yml. For the config above it has:
- Triggers:
pull_request(opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled and unlabeled, so the stage follows the pull request),pushtostages.environments, andworkflow_dispatch. plan: checks out the whole history, installs Fairlead with the action at this release, runsfairlead ci plan --format githubwith--since-greenfor each job of its own, and uploads the plan as thefairlead-planartifact. Its outputs arestageand everyrun_<id>.checks: runs the plan with--exceptfor each step that has a job of its own. A newer push to the same pull request or branch cancels it.- A job per runner or check whose
fromismergeorfull, heree2eanddesktop-build: it runs only whenneeds.plan.outputs.run_<id> == 'true', withfairlead ci run --only <id>. Its concurrency group is per workflow, job and branch, withcancel-in-progress: false, which is GitHub’s own batching: one run at a time and only the newest waiting. A waiting run a newer push replaces is covered by the newer run’s plan, since both plan from the last green run. - Permissions:
contents: readat the top and in each job;planaddsactions: readfor--since-green. Withstages.reuse,planalso getspull-requests: readandstatuses: readto find the tree a merge’s pull request passed, andchecksgetsstatuses: writeto record it. Every action is pinned to a commit, and nogithub.eventtext is written into arun:line: the pull request’s base reaches the shell through an environment variable.
The file starts with a comment marking it as generated by fairlead ci workflow. --write rewrites a file with that comment and refuses any other, naming it, so a workflow you wrote is never replaced. A gated id may hold only letters, digits, -, _ and ., since it is written into a run: line, and plan and checks are taken. The jobs check out the code and run the plan on ubuntu-latest; a step that needs a toolchain or dependencies the runner image lacks needs setup steps, and since --write replaces edits, such a workflow is better kept as your own.
For a workflow you wrote, ci workflow also prints the if: line for each gated job, to standard error when it prints the workflow:
if: needs.plan.outputs.run_e2e == 'true' # e2e
if: needs.plan.outputs.run_desktop_build == 'true' # desktop-build
Each such job needs needs: plan, and the plan job must export the outputs (run_e2e: ${{ steps.plan.outputs.run_e2e }}) from a step that runs fairlead ci plan --format github itself: the action’s outputs don’t include stage or run_<id>.
Required checks: GitHub counts a job that if: skipped as passing a required status check. A required e2e check therefore passes on every pull request where end-to-end tests wait for the merge stage; it proves only that the job wasn’t due. Require checks on pull requests, and watch the merge-stage jobs on the environment branch.
fairlead ci run --plan PATH
Runs each invocation’s argv in its working directory (relative to the repository root; a relative --plan is from the current directory), in order, without a shell, and exits 1 if any failed or has no argv, after running the rest. --fail-fast stops at the first failure. A plan of another version is refused with exit code 2.
Since 0.7.0: a program is found the way a shell finds it. On Windows, a name with no extension is looked up on PATH with each of PATHEXT’s .com, .exe, .bat and .cmd, and a relative path such as node_modules/.bin/eslint beside the working directory, so npx and other .cmd shims start. Before, they needed the full name, such as npx.cmd, which still works. The same goes for fairlead done, [[guard.external]] rules and graph providers.
An invocation a [[quarantine]] entry holds doesn’t fail the run when its output matches the entry’s signature and names no other failed test. It is reported as not provable here, and the last line counts it. Any other failure counts. The entry is checked again on the machine running the plan, so a plan made on Windows excuses nothing on Linux.
Since 0.8.0: --only ID runs just those runners and checks, and --except ID all but those, each repeatable, so one plan can feed a fast job and an end-to-end job. An id that no runner or check has fails with exit code 2. One the plan’s stage defers is not an error: the run says which stage it waits for and runs nothing for it.
--results PATH also writes each invocation’s id, working directory, argv, outcome and seconds, for fairlead ci report, with "quarantined": true on one that failed as its entry expects.
Escapes: --judge PATH
Once pull requests run only the plan, the default branch’s push run, which still runs everything, is where a missed test shows. --judge takes the plan the merged change got, made again against the push’s first parent, and judges each failing test file against it:
- planned: the merge’s plan selected it, so it should have failed on the pull request too;
- escaped: the plan left it out. The line names the
[[tests.owners]]rule that would have caught it, the same rulereplay reportsuggests for a miss, and in GitHub Actions it’s also a warning annotation on the test file.
A failing test file is read from the runner’s output through that runner’s [[replay.failures]] extractor and matched to a file in the tree, as replay does. A runner with no such entry is named and not judged. Output is still echoed as the runner prints it. With --results, the verdicts go in the results file and ci report lists the escapes.
on: push # the default branch
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2
persist-credentials: false
- run: |
bunx fairlead ci plan --base HEAD^ --out "$RUNNER_TEMP/merge.json"
bunx fairlead ci plan --base HEAD^ --set 'plan.run_all=["**"]' --out "$RUNNER_TEMP/all.json"
- run: bunx fairlead ci run --plan "$RUNNER_TEMP/all.json" --judge "$RUNNER_TEMP/merge.json" --results "$RUNNER_TEMP/results.json"
- if: always()
run: bunx fairlead ci report --plan "$RUNNER_TEMP/all.json" --results "$RUNNER_TEMP/results.json"
The run’s exit code is its tests’ either way. ci.escapes = "fail" also makes ci report exit 1 when there’s an escape, so the escape can be its own required check; the default, "report", only reports it.
Since 0.10.0, ci.escapes = "file" also opens a GitHub issue for each escape, so a person sees it after the merge:
- Each issue is labelled
fairlead-escape. It names the test, the runner, the merge commit and the run, and suggests the rule that would have selected the test. - An escape is keyed on its runner and test. If the same test escapes again while its issue is open, the issue gets a note instead, at most one a day. If the issue is closed, a new one is opened that names the old one. Closed issues are never reopened.
- One run opens at most five new issues and lists the rest as not filed.
- Filing is best effort: a failure to file is printed, and the run’s outcome stays its tests’.
The job that runs ci run --judge needs issues: write and GITHUB_TOKEN in its environment. fairlead ci workflow doesn’t add that job, so add the permission where you run the judge.
fairlead ci report --plan PATH
Writes one Markdown summary of the run. It goes to $GITHUB_STEP_SUMMARY when GitHub Actions sets it, else stdout. The summary has:
- the headline: passed, or how many invocations failed, and the time;
- the plan: how many changed files and what it selected, with a count of each reason a test was picked;
- changed files no test reaches, and what the plan did about each;
- the tests and checks not provable where the plan was made, each with its evidence and where it is proved instead;
- with
--results, a line per invocation with its outcome and time, and a block with the command to run each failed one again; - with
--receipt FILE, the receipt a branch carries, asfairlead receipt --outwrote it, folded away.
Results from another plan are refused with exit code 2.
- run: bunx fairlead ci run --plan "$PLAN" --results "$RUNNER_TEMP/results.json"
- if: always()
run: bunx fairlead ci report --plan "$PLAN" --results "$RUNNER_TEMP/results.json"
--comment, or ci.comment = true, also keeps one pull request comment up to date with the same report. The comment is found again by a hidden marker at its start, so each run edits it rather than adding another. It needs GITHUB_TOKEN with pull-requests: write, and it’s read from a pull_request event’s payload. A comment that can’t be posted is reported and never fails the step, since the summary already has the report.
[ci]
comment = false # also keep a pull request comment up to date
escapes = "report" # or "fail": `ci report` exits 1 on an escape `ci run --judge` found; or "file": a GitHub issue per escape
GitLab CI
ci plan, ci run and ci report read nothing GitHub sets, so a merge request pipeline runs them as they are. GitLab gives the merge base as CI_MERGE_REQUEST_DIFF_BASE_SHA and clones 20 commits by default, which can leave the merge base out: GIT_DEPTH: "0" fetches the history --base needs.
fairlead:
image: node:22
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
GIT_DEPTH: "0"
PLAN: /tmp/fairlead-plan.json
RESULTS: /tmp/fairlead-results.json
before_script:
# Pin a release in your own pipeline: releases/download/vX.Y.Z/ in place of releases/latest/download/.
- curl -fsSL https://github.com/mahkassem/fairlead/releases/latest/download/fairlead-installer.sh | sh
- export PATH="$HOME/.cargo/bin:$PATH"
- npm ci
script:
- fairlead ci plan --base "$CI_MERGE_REQUEST_DIFF_BASE_SHA" --head "$CI_COMMIT_SHA" --out "$PLAN"
- fairlead ci run --plan "$PLAN" --results "$RESULTS"
after_script:
- export PATH="$HOME/.cargo/bin:$PATH"
- fairlead ci report --plan "$PLAN" --results "$RESULTS" > fairlead-report.md
artifacts:
when: always
expose_as: fairlead report
paths: [fairlead-report.md]
The plan and results stay outside the checkout, where they’d count as changes; the report is written there last, because GitLab keeps artifacts only from inside it. Without $GITHUB_STEP_SUMMARY the report goes to stdout, so it’s saved to a file and shown on the merge request through expose_as. --comment and the ::warning annotations of ci run --judge are GitHub’s; on GitLab the escapes are in the report.
A project that installs Fairlead from npm can drop the installer and run npx fairlead instead, as on GitHub.
Workspaces
Many teams keep a folder of repositories side by side, one per service or app, rather than one monorepo. Since 0.10.0, a fairlead.toml in that folder makes it a workspace: fairlead plan there plans each repository against its own base, and every command inside a repository reads the workspace file as a layer under the repository’s own.
# ~/work/fairlead.toml
[workspace]
[[workspace.repos]]
path = "api"
base = "origin/main"
[[workspace.repos]]
path = "web"
[[workspace.repos]]
path = "libs/shared"
name = "shared"
# Anything else is shared by every repository, under its own file.
[[tests.runners]]
id = "vitest"
match = ["**/*.test.ts"]
command = ["vitest", "run", "{files}"]
| Key | Default | What it does |
|---|---|---|
workspace.repos | [] | The repositories, each a path from the workspace folder to a git repository inside it. Empty means every git repository one level down, in name order. |
workspace.repos.name | the folder’s name | What --repo and the plan call it. Two repositories with one name is an error. |
workspace.repos.base | the repository’s remote default branch | The branch or commit its changes are measured from, through its merge base, as --base would be. |
workspace.edges | [] | Since 0.11.0. { from, to } rules: the files from matches depend on the files to matches in another repository. Both are globs from the workspace folder that start with a repository’s folder. See Edges across repositories. |
Planning from the folder
fairlead plan in the workspace folder plans every repository it lists:
- Each repository’s changes are measured against its own base and merge base, so a repository with nothing new is left out of the plan and named under
unchanged. --baseoverrides every repository’s base for one run, and--repo NAMEplans only that one.--filestakes paths from where the command runs; each goes to the repository holding it, and a repository given none is unchanged.--jsonprints{"repos": [{"name", "path", "plan", "reached"}], "unchanged": [...]}, where eachplanis a repository’s plan exactly asfairlead plan --jsonprints it inside the repository, andpathis from the workspace folder.
fairlead test --explain FILE in the folder explains FILE in the repository that holds it; for a check, which has no path, name the repository with --repo. From inside a repository, --repo plans another repository of the same workspace.
Every other command works on one repository, so in the workspace folder it stops and names the repositories to run it in.
Inside a repository
Commands inside a repository find its config as always, walking up no further than the repository root. Then, outside CI, they look further up for a workspace file that lists the repository, or that lists none and has it one level down. That file is a layer under the repository’s project file (see Layers): its lists come first, and the repository’s file can add to them, replace them with { replace = [...] }, or set any value over them. A pack the workspace file names in extends is looked up from each repository’s root.
A repository’s own file can’t declare [workspace]. In CI, where the checkout is one repository, the workspace file is never read, so each repository’s CI plans itself exactly as it did.
Edges across repositories
Since 0.11.0: a dependency between repositories that no import shows, such as a contract one repository owns and another consumes, is a rule in the workspace file:
[[workspace.edges]]
from = "web/src/api/**"
to = ["api/openapi.yaml"]
When a planned change in one repository matches a to glob, the files in the other repository that from matches count as changed there, and that repository’s plan follows them through its own graph to its tests. The plan names each such file under reached, with the change that reached it, and the text prints reached across the workspace: src/api/client.ts from api/openapi.yaml. A repository with nothing changed of its own is planned for what the edge reached.
A rule takes one hop: what it reaches doesn’t reach further through another workspace edge, and inside one repository its own graph and [[graph.edges]] decide. The same rule writes a path dependency by hand, from = "web/src/**" and to = ["libs/shared/src/**"], when one repository builds another’s source.
What’s next
The rest of #52’s second phase: path dependencies found on their own (file:../x in npm, composer path repositories, a go.mod replace) rather than written as an edge, and owner rules in the workspace file.
Guard rules
The guard checks your project’s own rules with one engine at every stage a change passes through: as an agent writes a file, at commit, and in CI.
| Stage | Command | Reads | Fails on |
|---|---|---|---|
| write | fairlead guard hook, run by Claude Code | the file an edit would leave, before it’s written | a finding the edit adds; see The write hook |
| check | fairlead guard check | every tracked file, as it is on disk | any zero-tolerance finding, or a ratcheted count above the baseline |
| commit | fairlead guard check --staged | each staged file, at HEAD and in the index | a finding the staged change adds |
The check and commit stages also run the migration rules and the external rules configured for them.
Rules
Every preset is off until configured, and each one names the files it reads.
[guard]
baseline = "fairlead-baseline.json" # the default
exclude = ["vendor/**"] # tracked files no rule reads
findings = "added" # the default; "all" counts every finding in a touched file
on_finding = "deny" # the default; "warn" shows the findings and lets the commit through
events = "local" # the default; "off" records nothing
[guard.size]
files = ["src/**/*.{ts,tsx}"]
exclude = ["src/generated/**"]
file_lines = 1000 # a file over this many lines is a finding
function_lines = 120 # a function over this many of its own lines
ratchet = true # the default; false fails on any finding
[guard.cite]
file-length = "style guide, section 6" # appended to that rule's messages
A finding prints as file:line rule: message, with the rule’s cite after it
when there is one.
| Rule | Preset | Finds |
|---|---|---|
file-length | guard.size | A file over file_lines lines. A trailing newline doesn’t start a line. |
function-length | guard.size | A function over function_lines of its own lines. |
block-length | guard.comments | A comment block longer than its context allows. |
density | guard.comments | Comment lines over a share of comment and code lines. |
history | guard.comments | A date, a name, a narrative phrase, or a measurement beside “measured”. |
item-code | guard.comments | A ticket or item reference outside the pointer form. |
agent-instruction | guard.comments | A comment that addresses its next editor. |
block-marker | guard.comments | A continuation line of a multi-line /* */ that doesn’t start with *. |
test-file-name | guard.test_names | A test file whose name doesn’t match file. |
test-title | guard.test_names | A test title that matches titles_without. |
citation | guard.citations | A pointer in a comment that names no heading in headings_in. |
migration-edit | guard.migrations | A change, move or deletion of a migration that exists at the base. |
migration-prefix | guard.migrations | Two migrations with the same leading number. |
your id | guard.external | Whatever the command reports. |
Function length
A function’s own lines run from its first line to its last, minus the lines of
the functions directly nested in it, so a long callback is charged to itself
and not to every function around it. Declarations, expressions, arrows,
methods, constructors, getters, setters and generators all count, and a
function starts at an export or decorator in front of it. A callback passed
straight to a test hook isn’t measured itself, only what it nests; the hooks
are test_hooks, by default describe, test, it and the before and
after hooks, found through chains such as it.each([...])(...) and
test.only(...). JavaScript and TypeScript only, from a syntax tree.
Comments
[guard.comments]
files = ["**/*.{ts,tsx,sql,yml}"]
tests = ["**/*.test.ts"] # take the test limits
migrations = ["db/migrations/*.sql"] # one-line header, no density limit
block_length = { source = 8, test = 10, header = 12, inline = 2, migration = 1 }
density = { source = 0.25, test = 0.20 }
history = { dates = true, names = ["Ada"], phrases = ["used to", "no longer"], measured = true }
item_codes = { pattern = '(?-u:\b)[A-Z]+-[0-9]+(?-u:\b)', pointer = true }
agent_phrases = ['(?i)(?-u:\b)you must(?-u:\b)']
block_marker = true
ratchet = false # the default: any finding fails
The comment syntax comes from the extension: // and /* */ for JavaScript
and TypeScript, -- for SQL, # for YAML, TOML and shell.
- Blocks. A block is a run of lines that are comments once trimmed, block
comments included. Its context is the first that has a limit:
migration,inline(its first line is indented and the nearest non-blank line above is code),header(it starts on line 1),test, thensource. - What a comment says is read from its flattened text: each line’s marker
stripped and whitespace collapsed, so a phrase split across a wrap still
matches. Names and phrases match as whole words; phrases in any case. A
date is one from 2000 to 2099 written
YYYY-MM-DD. - Comments after code, such as
x() // why, are found from the syntax tree, so//in a string, a template or JSX text is never taken for one. They’re checked for what they say, and don’t count as blocks or towards density. - The pointer form: with
pointer = true, references are allowed as(ABC-1)or(ABC-1, ABC-2)followed by a full stop or the end of the comment. Each other reference is a finding, once per comment. - Patterns are Rust regular expressions, where
\band\dare Unicode aware. Write(?-u:\b)and[0-9]for the ASCII behaviour most other linters have, and(?i)for any case.
Test names
[guard.test_names]
files = ["test/**/*.test.ts"]
file = '^[a-z0-9]+(-[a-z0-9]+)*\.test\.ts$' # the file's name must match
titles_without = '(?-u:\b)[A-Z]+-[0-9]+(?-u:\b)' # no title may match
title_calls = ["describe", "test", "it"] # the default
A title is the first argument of a title call, found through chains such as
it.only(...), when it’s a string or a template with no ${...}.
Citations
[guard.citations]
files = ["src/**"]
pattern = '\((?P<code>[A-Z]+-[0-9]+)\)' # a pointer; the name is the `code` group
headings_in = "docs/decisions.md"
Each pointer in a comment, a block or after code, has to name a heading in the
Markdown file: a heading names the first word of its text, so ## ABC-12: why
names ABC-12. The file is read once per run, from the working tree.
Migrations
[guard.migrations]
files = ["db/migrations/*.sql"]
immutable = true # the default
base = "origin/main" # optional
unique_prefix = { allow = [["089_a.sql", "089_b.sql"]] }
A database that ran a migration won’t run it again, so once a migration exists
it may not change, move or go. “Exists” means present at the base: the merge
base with base when it’s set. Without it, the commit stage compares with HEAD
and the check stage needs --base REV, and says so. At the commit stage a base
that can’t be found, before the first commit or with the ref not fetched,
falls back to HEAD. unique_prefix makes each
file’s leading number unique, apart from the listed groups; the commit stage
fails only on a number the commit newly shares.
Commands
[[guard.commands]]
match = '(^|\s)git push --force(\s|$)'
reason = "Open a pull request instead; a forced push rewrites what others have."
A shell command an agent may not run, with the reason it’s told. The write
stage’s hook reads these; the check and commit stages don’t run commands.
match is a regex over the whole command line, so it also finds a command
inside a && b, a; b or a pipe.
Since 0.8.0: unless lets a matched command through when it also matches,
since a regex here can’t say “without this flag” (there is no lookahead).
config check checks it as it checks match. This rule makes an agent open
its pull request as a draft, which fairlead next also
tells it to do, so a CI that runs only fast checks on a draft holds the rest
until the change is ready:
[[guard.commands]]
match = '\bgh\s+pr\s+create\b'
unless = '\bgh\s+pr\s+create\b[^;&|\n]*\s(--draft|-d)([\s;&|]|$)'
reason = "Open the pull request as a draft: gh pr create --draft. Mark it ready after fairlead done passes: gh pr ready."
unless reads the same whole line, so it ties the flag to the gh pr create
part: [^;&|\n]* stops at the next command. git push && gh pr create --draft
and gh pr create -d go through; gh pr create --title x, and
gh pr create --fill && git branch -d old, are denied. It isn’t on by default.
External rules
[[guard.external]]
id = "design-lint"
command = ["npm", "run", "lint:design"]
stages = ["check"] # the default; also "commit"
ratchet = false # the default
Any tool that prints findings as file:line message or file:line:column message, one per line, becomes a rule with your id; other lines are ignored.
A tool that exits non-zero and prints no findings fails the check, since it
couldn’t run. At the check stage the command runs as written. At the commit
stage {files} expands to the staged files and every finding in them counts,
since there’s no before side to compare with. write isn’t a stage an
external rule can run at: when the hook runs, the file isn’t written yet.
The write hook
fairlead hooks install # the Claude Code hooks, and the git hook if lefthook is set up
fairlead hooks install --git # only the git hook, making lefthook.yml if there's none
fairlead hooks install --codex # the same hooks for Codex, in .codex/hooks.json
fairlead hooks install --gemini # the same hooks for Gemini CLI, in .gemini/settings.json
fairlead hooks status # where each is, and whether uninstall can restore the file exactly
fairlead hooks uninstall # take them out again
fairlead doctor # the hooks, the binary on the PATH, and what the event log recorded
hooks install adds four Claude Code hooks:
| Hook | Runs | Does |
|---|---|---|
PreToolUse | fairlead guard hook | The write stage below: denies an edit that breaks a rule, or adds a note |
PostToolUse | fairlead guard nudge | Once per session, after an edit made with no brief, says how to get one (The brief); off with brief.nudge = false. Since 0.8.0: after an edit, names the routed skill for the file once per session, and records a skill the agent loads (Skills) |
Stop | fairlead guard stop | Sends the agent back while the tree it leaves hasn’t passed fairlead done (The Stop hook); off with done.on_stop = "off" |
SessionStart | fairlead resume --hook | Since 0.8.0: starts a session with where the last one on the branch stopped: the last brief, what changed, the gate and next (The SessionStart hook); off with brief.resume = false |
The hooks go where hooks.claude says: "shared" (the default) is the
committed .claude/settings.json, so everyone who clones the repository and
every agent session is guarded; "local" is .claude/settings.local.json,
for you alone. --shared and --local choose for one run. The write hook
runs on Edit, Write and MultiEdit, and on Bash too when there are
[[guard.commands]]. Where fairlead isn’t installed the hooks do nothing,
so a teammate without it can still work.
When package.json lists Fairlead as a dependency, install writes hooks that
run the project’s own copy rather than one on the PATH, which a package
install doesn’t provide. The Claude Code hook calls the binary the npm package
unpacked, node_modules/fairlead/node_modules/.bin_real/fairlead, and falls
back to the package runner the lockfile names (bun x, pnpm exec, yarn,
else npx --no-install), which costs that runner’s start-up on every edit.
The lefthook entry runs through the same runner. The npm package unpacks the
binary in a postinstall script run with node; where that script isn’t
allowed to run, or there’s no node, the runner fetches it on first use.
Before an edit, the hook works out what the file would hold and lints it
with the same presets as the other stages. When the edit adds a finding it
denies the edit, and the agent reads the findings as the reason, fixes them
and writes again. With on_finding = "warn" it lets the edit through and
passes the findings to the agent as a note instead. It never answers
“allow”, which would also skip your own permission prompt. An edit to a
migration that already exists is denied outright. To tell which exist
without starting git, the hook reads HEAD’s commit from the ref files and
keeps the paths under the migration directories for it in
.git/fairlead/head-paths/, for the last four commits; guard check and
the commit stage fill it for the current HEAD, and the hook fills it with
one git ls-tree when HEAD has moved since.
The hook lets a call go ahead, and says so in the event log, when it can’t
decide: the edit’s old text isn’t in the file or occurs more than once, the
tool input has a shape it doesn’t know, the file is outside the project, not
UTF-8 or over 256 KB, the config has a problem, or it runs out of its own
time, guard.budget_ms (40 by default). The check and commit stages still
catch whatever it lets through.
Install keeps a manifest in .git/fairlead/backups/ with the settings file’s
original bytes and the bytes it wrote. Installing twice changes nothing.
Uninstall puts the original back byte for byte when nobody changed the file
since; otherwise it removes only Fairlead’s entries, keeps everything else,
and says the formatting may differ. On a fresh clone, where there’s no
manifest, it removes the entries by their command.
Codex
Since 0.7.0: fairlead hooks install --codex writes the same hooks to
.codex/hooks.json, which Codex reads in the same shape (since 0.11.0:
SessionStart included); status and
uninstall take --codex too. Codex edits files with one tool,
apply_patch, whose patch can add, change, move or delete several files at
once. The write hook reads the patch the way Codex applies it, lints each
file as the patch would leave it, and answers with one deny that lists every
finding. Moving or deleting a migration that already exists is denied, as
editing one is. A patch typed into the shell as apply_patch <<'EOF' is read
the same way. A patch that doesn’t parse, or whose lines aren’t in the file,
goes ahead: Codex refuses it on its own.
Codex starts a hook in the session’s directory, so the Codex entries change
to the repository’s root first. It runs a project’s hooks only once you trust
the project and approve them: it asks when it starts, and /hooks in Codex
lists them.
Gemini CLI
Since 0.7.0: fairlead hooks install --gemini merges the same hooks
(since 0.11.0: SessionStart included) into .gemini/settings.json, under Gemini CLI’s names for the moments:
BeforeTool for the write stage, AfterTool for the brief nudge and
AfterAgent for the Stop hook, which sends the agent back with its reason
as a new prompt. Its timeouts are in milliseconds, and the edit matcher is
write_file|replace; run_shell_command stands for Bash when there are
[[guard.commands]]. The write hook reads write_file as a write and
replace as an edit (allow_multiple as replace_all), and answers a
BeforeTool call in Gemini CLI’s shape: a top-level decision and
reason. Gemini CLI has no way for a hook to add a note for the agent
before a tool runs, so with on_finding = "warn" the findings are shown to
you as a system message instead. It runs a project’s hooks only in a folder
you trust, and warns once when it first sees them.
The git hook
The commit stage runs through lefthook:
install adds a fairlead-guard command running fairlead guard check --staged
(through the package runner where Fairlead is a package dependency)
to pre-commit in lefthook.yml (or lefthook.yaml, .lefthook.yml,
.lefthook.yaml, whichever is there). It adds lines at the indentation around
them, under commands, or as an item where the config uses jobs, and never
rewrites the rest, so comments and layout stay. A pre-commit or commands
written on one line (pre-commit: {}) is refused, to be added by hand.
Without --git, install adds the git hook only where a lefthook config already
exists. lefthook runs the command once lefthook install has put it in
.git/hooks; status and doctor say when it hasn’t. Uninstall restores the
file byte for byte when nobody changed it since, else removes the two lines
and any commands, jobs or pre-commit they leave empty, and a config
install made is removed.
Doctor
fairlead doctor reads the event log and reports the newest 500 write-hook
calls by decision, the rules that denied, the hook’s p50 and p95 time, any
calls that ran out of time or hit an error, and the commit stage’s runs. It
also says where each hook is installed, whether lefthook will run the git one,
and whether fairlead is on the PATH, since the hooks call it by name, or
that they run the project’s own copy.
Checking the guard against your linter
When the guard replaces a linter you already run, check that the two agree before you switch:
your-linter --list > theirs.txt # one finding per line: file:line rule
fairlead guard compare theirs.txt # fails on any difference
fairlead guard compare theirs.txt --map max-lines=file-length --rules file-length
compare reads file:line rule, with an optional :column and anything after
the rule, and matches findings on file, line and rule, so the wording of
messages doesn’t matter. --map renames the other linter’s rules to
Fairlead’s, and --rules compares only the ones named. It lists up to 20
findings each side has that the other doesn’t.
fairlead guard bench --since REV replays your history through the write
hook: for each commit since REV on HEAD’s first-parent line (a merge counts
as the change it brought in), it sends each changed file the guard reads to
fairlead guard hook as a write, in a temporary worktree with today’s config.
It reports the edits, p50, p95 and the slowest wall time, process start
included, and what the hook decided. --limit stops after that many edits
(200 by default), and --p95-under MS fails when p95 is over it. The main
event log is left alone.
The ratchet
A ratcheted rule is for debt you already have: it fails only when a file has more findings of that rule than the baseline allows.
fairlead guard check --write-baseline # record today's counts
fairlead guard check # fails if any file/rule count rises
fairlead guard check --list # every finding, held or not
The baseline is a JSON file of counts by file and rule, meant to be committed.
A count can fall freely; the check says when one did, and --write-baseline
lowers the file to match. Counts for a rule that is no longer ratcheted, or no
longer configured, are ignored.
Only what a change adds
The commit stage judges a change by what it adds, so fixing one line in a file with an old finding isn’t blocked by it; the check stage still reports it. Each staged file is linted as it is at HEAD and as it is in the index, never the working tree, and the two lists are compared:
- Most findings compare by rule, message and what they’re about (a comment’s text, a function’s name), never by line, so a finding that only moved isn’t new and a second identical one is.
- A measured finding, such as a long file, is new when the thing crosses its limit or grows while over it. Editing inside a long file without making it longer is never a finding.
A renamed file is compared with its old self, and before the first commit everything staged is new.
findings = "all" makes a file you touch pass every rule instead: the commit stage
fails on any finding in a staged file, old or new. It suits a codebase with no
debt to carry; the default suits one that has some.
on_finding = "warn" lets the commit through and shows the findings instead of
stopping it. The check stage still fails on them in CI.
Event log
Each write-stage and commit-stage run appends one line to
.git/fairlead/events.jsonl, inside the git directory so it’s never
committed. Every worktree of the repository writes to that one log, the
main checkout’s, so a session in a linked worktree counts in
doctor --score and insights and its events outlive the worktree. A log a
worktree kept before 0.11 is still read, merged in by time, until the
worktree is removed:
{"at":"2026-09-26T19:04:11.221Z","stage":"commit","files":3,"decision":"deny","rules":["file-length"],"added":1,"ms":7.4,"fairlead":"0.4.0"}
It holds rule ids, paths, counts, decisions and timings, and never file
contents, comment text, commands, reasons or error messages, since any of
those can quote code. The log moves aside at 5 MB, keeping one old file.
Writing to it never fails a check, and events = "off" turns it off.
The brief
fairlead brief answers, before the first edit, what a change to some paths will reach and what it takes to finish it. Every line names where its facts come from, so an agent can check any of them with the command on the right.
fairlead brief src/leave/Leave.ts # one file
fairlead brief src/leave src/payroll/new.ts # a directory, and a file that doesn't exist yet
fairlead brief src/leave --all # every item, not the first five of each section
fairlead brief src/leave --json # the same, as JSON
brief b-b578d149 base 0346ca7 1 path
reaches 1 file imports them directly, 2 through the graph graph importers, graph why
src/b.ts imports src/a.ts (import)
tests 1 test file plan --files
test/b.test.ts 2 hops from src/a.ts
checks typecheck plan --files
rules size fairlead.toml [guard.*]
lessons 1 lesson .fairlead/lessons (fairlead lessons list)
leave-settles-at-sign-off Leave settles at sign-off (used: src/b.ts imports src/a.ts)
skills 1 skill fairlead.toml [[skills.routes]]
forms How forms validate. (named: src/forms/form.ts)
done unit, typecheck, guard fairlead.toml [done], done --dry-run
next edit, then: fairlead done
The sections
- reaches: the files that import a named path themselves, then how many depend on it through any chain of the import graph.
fairlead graph why A Bshows one chain. - tests and checks: what the plan for these paths selects, with each test’s first reason. It’s
fairlead plan --filesrun ahead of the change, andfairlead test --explain FILEexplains any one of them. - rules: the
[guard.*]tables whose rules read a named path, such assize,commentsormigrations. These are the rules the write hook will hold the edit to. - lessons: the lessons whose scope covers a named path, a file a named path imports, or every change, each with why it was picked. It lists
memory.cap(5) of them. A lesson file that can’t be offered is named in awarningline. Since 0.8.0. - skills: the skills whose route covers a named path or a file it imports, or every change, each with its description and why. It lists
skills.cap(8) of them. Since 0.8.0. - done: the steps
fairlead donewill run, the same list asfairlead done --dry-run.
A path that isn’t in the tree yet is a new file. Nothing reaches it, and the brief still lists the rules its location falls under. Each section shows its first five items, then how many more there are and the command that lists them. --all lifts the cap. Without it a brief stays under 40 lines.
One brief per session
A brief is kept in .git/fairlead/briefs/, never committed, with the paths it named, the commit it started from and the plan it was made from. The receipt will compare what changed against it.
A brief belongs to the agent session that asked for it: --session, or the CLAUDE_CODE_SESSION_ID Claude Code sets for the commands an agent runs, which is the session_id its hooks receive. Under brief.per = "session" (the default), a second brief in the same session adds its paths to the first, so a change that grows is still one brief. A brief made from another base starts afresh. Under "call", each brief stands alone and the newest one counts.
[brief]
per = "session" # session | call
nudge = true # a note after the first edit made with no brief
The note after an edit
An agent only reads a brief if it asks for one. fairlead hooks install adds a Claude Code PostToolUse hook on edits, fairlead guard nudge, which checks for a brief after the session’s first edit. If the session has none, it adds one line to the agent’s context saying how to get one. It says so once per session and never denies anything. A session that asked for a brief is never told. brief.nudge = false installs no such hook.
The note is recorded as a nudge event in .git/fairlead/events.jsonl with the session id, and that event is how the hook knows it already spoke.
Trackers
Since 0.10.0, [tracker] links a change to the task it serves. When the branch name carries a task id, the brief shows that task at its top: its title, status, link and, where the tracker gives one, what “done” means for it.
[tracker]
kind = "command" # none, command, github or agent
id = "T[0-9]+" # every task id must match this whole
branch = "(T[0-9]+)" # finds the id in a branch name, such as feat/T1890-bump
get = ["bun", "run", "tasks", "--", "show", "--json"]
timeout = 20 # seconds
| Key | Default | What it does |
|---|---|---|
kind | none | Where tasks live (below). |
id | [0-9]+ for github, none otherwise | A regex every task id must match whole before Fairlead uses it. Required for command and agent. |
branch | none | A regex that finds the id in the branch name: its first capture group, else the whole match. |
get | [] | For command: the argv that prints one task as JSON. The id is appended as its last argument. |
timeout | 20 | How long one tracker call may take, in seconds (1 to 120). |
Kinds
command: the team’s own command. Fairlead runsgetwith the id as its last argument and reads one JSON object from its output:id,title, and optionallyurl,statusanddone_when. The command runs without a shell, inherits the environment as runners do, and is killed at the timeout. Only its first 64 KB of output is read.github: issues in the repository thatoriginpoints at (orGITHUB_REPOSITORY), read withGITHUB_TOKENorGH_TOKENwhen set. The id is the issue number.agent: Fairlead calls nothing. The brief names the task and tells the agent to look it up with its own tracker tool. This is for a team whose tracker is only an MCP tool the agent already has.
Tracker text is data
A task’s title and fields come from outside the repository, so before the brief shows them:
- control characters, terminal escape sequences, text-direction marks and zero-width characters are removed, and line breaks become spaces;
- the title is cut at 200 characters, the status at 40, and
done_whenat 1,000; - only
https://links are kept; - the brief quotes the title and labels
done_whenas coming from the tracker.
A tracker that answers for a different id than the one asked for is refused.
An id reaches a command only after it matches id whole, and never when it starts with -. So a branch named task/--delete-all names no task, whatever the pattern allows.
When it runs
The task is read once per brief, when the brief is first made, and kept with it, so a slow tracker costs one call per change. A tracker that fails, hangs or prints something else leaves a tracker: warning in the brief, and the brief is made without the task.
Lessons
A lesson is one thing a project learned the hard way, kept as a small file a person can review in a pull request. fairlead brief offers the lessons a change reaches, the way the plan picks the tests it reaches. Since 0.8.0.
fairlead learn --title "Leave settles at sign-off" --path "src/leave/**" \
--evidence https://github.com/acme/app/pull/812 \
--source person --confirmed-by a-reviewer \
--body "A decision that costs money happens at the final stage, and nowhere else."
fairlead lessons list # every lesson and its scope
fairlead lessons review # the ones past their review date
fairlead lessons check # fail on a file that can't be offered, for CI
fairlead import lessons docs/LESSONS.md --write # one lesson per heading of a document you already keep
A lesson file
Each lesson is a file in .fairlead/lessons/ (memory.dir), named after its id:
---
id: leave-settles-at-sign-off
title: "Leave settles at sign-off"
paths: ["src/leave/**", "src/domain/settlement.ts"]
added: 2026-09-12
review_by: 2026-12-11
evidence: ["https://github.com/acme/app/pull/812"]
check: "guard:migrations"
source: person
confirmed_by: "a-reviewer"
---
A decision that costs money happens at the final stage, and nowhere else.
- Scope:
paths(globs),modules(module names),always: true, orsearch: truefor a lesson that names no code:fairlead findturns it up, and no brief offers it. At least one is needed. - Evidence: at least one link to where it was learned: a pull request, a CI run, a review.
- Source: where it came from. Something learned has to be confirmed or seen to fail:
person: a person confirmed it, andconfirmed_bynames them;mistake: an obvious mistake taught it, and the evidence is the failure, such as a CI run, a receipt or a failing check;imported: brought in from a document the team already kept.
- check (optional): the rule or test that enforces it. An enforced lesson is cheap to trust, so it’s listed first.
- The body: at most
memory.max_lineslines (12). Anything longer is a doc, and the lesson links to it. - Review:
review_byisaddedplusmemory.review_days(90). Past it, the lesson is still offered, marked due, andfairlead lessons reviewlists it. Nothing fails because a lesson is due.
A file that breaks any of these, or that looks like it carries a secret or personal data (a key, a token, a password, an email address, a card number), isn’t offered. The brief names it as [bad-lesson] path: reason, fairlead doctor lists it, and fairlead lessons check exits 1 on it. A finding names the kind and the line, never the value.
How a change picks its lessons
A lesson is offered when its scope covers, in this order:
- named: a path the change names;
- used: a file a named path imports, one hop away. The walk goes along imports, not importers: a file needs the contracts it uses, and nothing imports a test, so a test’s lessons are only reachable forward. It stops at a
graph.barrier, so a barrel that re-exports a package doesn’t pull in everything behind it; - always: an
alwayslesson, for every change; - fallback: when the plan runs everything, such as for a lockfile, every lesson is offered and marked as the fallback.
Within each group, enforced lessons come first, then the newest. The brief lists memory.cap (5) of them, then how many more and fairlead brief --all.
Writing one: fairlead learn
fairlead learn writes one lesson from flags, reading the body from --body or stdin. It refuses a lesson with no scope, no evidence, no source, --source person without --confirmed-by, a body over the cap, an id that’s already taken, or text the secrets check flags.
An agent can call it. The file lands in the working tree only, never committed, so a person reviews it in the pull request it rides in. With memory.learn = "ask", learn prints the file instead of writing it, for a team that wants a person to save each one.
Importing a lessons document
A team that already keeps its lessons in one document, a LESSONS.md or a page of the contributing guide, can bring them in with fairlead import lessons. It makes one lesson per ## heading:
fairlead import lessons docs/LESSONS.md # a dry run: what it would write
fairlead import lessons docs/LESSONS.md --write # write them to .fairlead/lessons
fairlead import lessons docs/LESSONS.md --json # the lessons it would write, as JSON
- id: the heading as a slug. A heading used twice gets
-2,-3, and the dry run says so. - title: the heading, unless the heading only names the lesson, such as a code (
AB12) or a slug (keep-diffs-small). Then the title is the first sentence under it, at most 100 characters, and the heading stays the id. - paths: each backticked path in the heading or its text that names a file or directory in the repository. A directory becomes
dir/**, and a backticked glob counts when it matches a file. A path that doesn’t exist, or text in a fenced code block, doesn’t count. With no path, the lesson issearch: true:fairlead findturns it up, but no brief offers it, since a document whose lessons name no code would otherwise put the same few in every brief. The dry run flags each one: “no path in the text; found only by search”. Give itpathsto have briefs offer it. - evidence: the document itself at the heading’s anchor, such as
docs/LESSONS.md#ab12, so it’s never empty, then every link in the text. - added: the date
git blamegives the heading’s line, in UTC, or today when the document isn’t committed. An imported lesson has noreview_by, so it’s never due until a person reviews it and sets one. A date from the import day would change on every re-import, and one from blame would make an old document due all at once. - source:
imported. - The body: the text under the heading. Longer than
memory.max_lines, it keeps the first lines and ends withFull text: docs/LESSONS.md#ab12, still within the cap.
Text before the first ## heading, such as the document’s title and introduction, is skipped, and so is text under a later # heading; the dry run says how many lines. A ### heading stays in the lesson above it.
Every lesson goes through the same checks as one fairlead learn writes, the secrets check included. A heading whose lesson fails is listed with its reasons, such as docs/LESSONS.md:15: looks like a cloud access key, and isn’t written; the other headings still are. --write never replaces a lesson file that’s different, and reports each one it leaves; a file that’s the same is left as it is, so importing again changes nothing. The document is only read, never written.
memory.learn doesn’t apply here: the dry run is already the default, and --write is the choice to write.
It exits 0 when every heading became a lesson and nothing was refused, 1 when a heading was skipped or a different file was in the way, and 2 when the document or the config can’t be read.
Skills
A skill is a SKILL.md in the open format agents already read: a name, a description and the instructions. Fairlead doesn’t change the file. It routes it: [[skills.routes]] in fairlead.toml says which code each skill applies to, and the brief lists the skills a change reaches, the way the plan lists its tests. Since 0.8.0.
[skills]
imports = 1 # hops along what a changed file imports
importers = 0 # hops along the files that import it; off by default
cap = 8 # skills a brief lists before "N more"
[[skills.routes]]
skill = ".claude/skills/forms/SKILL.md"
paths = ["src/forms/**"]
[[skills.routes]]
skill = ".claude/skills/house-style/SKILL.md"
always = true
A route needs a scope: paths (globs), modules (module names) or always = true. Each skill is routed once. Fairlead reads only the skill’s name and description from its front matter, falling back to its directory’s name.
How a change picks its skills
Skills are picked by the same router as lessons:
- named: the scope covers a path the change names;
- used: the scope covers a file a named path imports, within
skills.importshops. The walk stops at agraph.barrier; - importer: only with
skills.importersabove 0, the scope covers a file that imports a named path; - always, then the fallback when the plan runs everything.
Imports are the default because a file needs the contracts it uses, and nothing imports a test, so a test’s skills are only reachable forward. On one project’s history, routing along imports caught slightly more of the rules a change needed than routing along importers did, and the two together caught no more than imports alone. Importers stay one setting away, for a repository whose history says otherwise.
The brief’s skills row lists skills.cap (8) skills, each with its description and why it was picked. A route whose SKILL.md can’t be read is named in a warning [bad-skill] line.
While the agent works
The PostToolUse hook fairlead hooks install adds does two more things when routes exist:
- After an edit, it names the skill whose scope covers the edited file, with its description, once per skill per session.
- When a skill is loaded, through Claude Code’s
Skilltool or a Gemini CLIread_fileof a SKILL.md, it records auseevent in.git/fairlead/events.jsonl. Each brief records anofferevent with what it listed. Together they give routing a hit rate: what was offered, what was used, and what was used without being offered;fairlead skills reportreads it.
Codex has no hook that sees a file being read, so a skill it loads isn’t seen; its use is unmeasured rather than counted as a miss.
Writing skills for each agent: skills sync
Agents look for skills in different places. fairlead skills sync writes each routed skill where each agent in skills.targets loads it, so a team keeps one SKILL.md per skill:
| Target | File | What it holds |
|---|---|---|
claude | .claude/skills/<name>/SKILL.md | the SKILL.md, for Claude Code |
agents | .agents/skills/<name>/SKILL.md | the SKILL.md, in the open cross-agent location Codex reads |
cursor | .cursor/rules/<name>.mdc | a Cursor rule: the skill’s description, the route’s globs and the SKILL.md’s body |
[skills]
targets = ["claude", "agents", "cursor"] # the default
<name> is the skill’s name. A skill that already lives in a target’s place is the copy that agent loads, so a SKILL.md under .claude/skills/ gets no second Claude Code copy. A copy keeps the SKILL.md byte for byte, front matter included, and adds one line after the opening ---:
---
# Generated by fairlead skills sync from skills/forms/SKILL.md; edit that file, not this one.
name: forms
description: How forms validate.
---
That header is a YAML comment, so the front matter still parses, and it’s how Fairlead knows the file is its own. A Cursor rule carries the same header. Its globs are the route’s paths, then <root>/** for each module in modules. An always route becomes alwaysApply: true. A route with only modules that don’t resolve gets no globs, so Cursor attaches it by its description.
fairlead skills syncwrites every target file and printswrote,unchangedorremovedfor each. A generated file whose route or target is gone is removed. A file at a target path without the header is someone’s own: it’s reported as aconflict, left alone, and the command exits 1.fairlead skills sync --checkwrites nothing and exits 1 when a file is missing, stale (hand-edited, or its SKILL.md changed) or orphaned, naming each, for CI.fairlead skills sync --cleanremoves every file with the header and nothing else.
Only SKILL.md is copied. Files beside it, such as scripts/ or references/, stay where they are, and sync lists each as not copied. A route whose SKILL.md can’t be read, a name that can’t be a file name, or two skills with the same name stop the sync before anything is written. fairlead doctor adds a skills sync line when the files have drifted.
Commit the generated files, so every agent finds them on a fresh clone, and run fairlead skills sync --check in CI.
Starting from rule files: import rules
A team that already scopes rules by path, in Claude Code’s .claude/rules/*.md (front matter paths) or Cursor’s .cursor/rules/*.mdc (globs, alwaysApply), can start from them:
fairlead import rules .claude/rules # dry run: prints each skill and the routes
fairlead import rules .claude/rules --write # writes them
Each rule file becomes .claude/skills/<name>/SKILL.md, named after the file (a file in a subdirectory gets the directory in its name), with name and description front matter and the rule’s body unchanged. The description is the rule’s own, or the opening of its body: sentences, without bold or italic markers, until they reach 40 characters, on one line of at most 200. Its scope becomes a [[skills.routes]] block: paths from paths or globs, given as a list or a comma-separated string, or always = true for alwaysApply: true. A Cursor glob without a slash, such as *.tsx, matches at any depth, so it’s written as **/*.tsx. A Claude Code rule with no paths is one Claude Code loads in every session, so it becomes always = true. A Cursor rule with no globs and no alwaysApply is one Cursor applies only on request, so it’s skipped with “no scope”.
Without --write nothing is written. With it, the skills are written and the route blocks are appended to the end of fairlead.toml under a comment naming the command; the existing text isn’t touched. A rule whose skill is already routed is left alone, so running it again changes nothing. A different file already at a skill’s path stops the run before anything is written. The rule files stay where they are, and their agents still load them: remove them once the skills replace them.
Measuring routing: skills eval
fairlead skills eval # the last 500 first-parent commits
fairlead skills eval --since 2026-01-01 --json
skills eval scores the router on the repository’s history. A commit that changes code and modifies a routed SKILL.md needed that skill; a SKILL.md the commit adds isn’t counted, since there was nothing to offer yet. For each such commit, the router runs on the commit’s other changed files four ways:
| method | imports | importers |
|---|---|---|
paths | 0 | 0 |
imports (the default) | 1 | 0 |
importers | 0 | 1 |
both | 1 | 1 |
Always routes count as offered. Each method reports the commits scored, recall (needed skills offered over needed), the commits with every needed skill offered, skills offered per change, and precision (needed skills offered over offered). Recall at caps 5, 6, 8 and 10 cuts the default method’s list where a brief with that skills.cap would, in the brief’s order. --json prints the same numbers.
The import graph is built once, from the working tree, so each commit is routed along today’s imports rather than its own; a file that has since moved or gone is still matched by path. Commits come first-parent from HEAD, newest first: --limit (500) of them, or every one since --since.
Measuring routing: skills report
fairlead skills report sets the offers against the uses, per session, so a team sees whether routing offers what agents load:
$ fairlead skills report --since 2026-10-01
skills report (since 2026-10-01): 4 sessions
skills: offered in 3 sessions, an offered skill used in 1 (hit rate 33%); 1 miss (used before any offer)
unmeasured: codex, whose hooks don't see a skill load; 1 session not counted as unused
1 offer with no session, not counted
skill offered used missed unused
forms 3 1 0 1
money 2 1 1 1
lessons: offered in 3 sessions; use of lessons isn't measured: there's no event for it
lesson offered
settle 2
tone 1
Each column counts sessions:
- offered: a brief or an edit nudge offered the skill;
- used: a
useevent named it; - missed: it was used before any offer of it in that session. An offer later in the session doesn’t turn a miss into a hit;
- unused: it was offered and never used.
The hit rate is the share of sessions with a skill offered in which an offered skill was used after its offer. A brief made with no session is counted apart, since no use can be set against it.
Lessons reach the agent in the brief’s text, and nothing records an agent reading one, so the report gives lessons’ offers only. Codex’s hooks see edits but not reads: a session whose events carry its apply_patch tool is listed as unmeasured, its offers count, and its skills are never counted as unused. With .codex/hooks.json installed, the report names Codex as unmeasured.
--since YYYY-MM-DD (a UTC day) and --session ID narrow the window, --all lists every row instead of the first ten, and --json prints the same numbers. With no events yet, the report says how they get recorded: briefs record offers, and the PostToolUse hook records uses.
Find
fairlead find searches what a project knows in one place: its lessons, its routed skills, the headings of its Markdown docs, and the classes and functions its PHP, Java and Kotlin files declare. Hits near the files the session’s brief named come first. Since 0.8.0.
fairlead find retry budget # the 20 best hits, best first
fairlead find retry budget --json # the same, as JSON
fairlead find --symbol Invoice # where a name is declared
fairlead find ledger --limit 50 # up to 50 hits
Each hit is one line: its kind, its name, where it is and its score.
lesson Round once .fairlead/lessons/money-rounding.md 4.12
doc Rolling back a release docs/deploy.md#rolling-back-a-release 2.87
skill web-forms .claude/skills/forms/SKILL.md 1.94
symbol App\Models\Invoice app/Models/Invoice.php 1.31
What it searches
The index is built in memory on every run from the tree as it is, so there’s nothing to keep up to date.
| Kind | Name | Text | Path |
|---|---|---|---|
lesson | its title | its id and body | the lesson file |
skill | its name | its description | the SKILL.md |
doc | the heading | the first paragraph of its section | the file and the heading’s #anchor |
symbol | the qualified name | the declaring file |
Docs are the # to #### headings of every .md file in the tree, outside code fences and front matter. Files the graph’s tree scan ignores are left out, and so are node_modules, vendor, .git, target, dist and build, and the lessons’ own folder (memory.dir). Symbols come from the declaration indexes the graph already builds for PHP, Java and Kotlin. Those indexes keep no line numbers, so a symbol’s path is its file. TypeScript, Python and Go declarations aren’t searched yet.
A lesson or skill file that can’t be read is left out with a warning on stderr; fairlead lessons check names it.
How hits are ranked
The ranking is BM25 (k1 = 1.2, b = 0.75). A word matches the same word, so retryBudget and max_retry_count are found by retry as well as by the whole name, and a handful of common words such as “the” and “is” are ignored. A word in the name counts twice as much as one in the text, and a rare word counts for more than a common one.
Nearness to the brief
When the session has a brief (--session, or CLAUDE_CODE_SESSION_ID, as for fairlead brief), each hit’s score is multiplied by 1 + 1 / (1 + d), where d is how many import edges, in either direction, separate it from the nearest path the brief names:
| Distance | Multiplier |
|---|---|
| a path the brief names | 2 |
| 1 | 1.5 |
| 2 | 1.33 |
| 3 | 1.25 |
| further, or no brief | 1 |
A doc or symbol is as near as its file. A lesson or skill is as near as the nearest file its scope covers within those three hops, so a lesson scoped to src/billing/** is lifted by a brief for a file that imports billing code. A scope of only always = true covers no file, so it isn’t lifted. The walk stops at a graph.barrier, as the brief’s does.
The text output says when a brief was used. In the JSON, brief names it and is null without one.
Finding a declaration
fairlead find --symbol NAME lists only declarations named exactly NAME, either in full (App\Models\Invoice, com.shop.Order) or by their last part (Invoice, Order). Case-sensitive matches are listed if there are any; otherwise the ones that match when case is ignored. A partial name finds nothing.
Options
--limit N: show up toNhits, from 1 to 50 (20 by default).--json: print{ "query", "brief", "hits": [{ "kind", "name", "path", "score", "snippet" }] }. The snippet is the start of the lesson’s body, the skill’s description, the heading’s paragraph or the symbol’s name.--session ID: rank by this session’s brief instead ofCLAUDE_CODE_SESSION_ID’s.--set KEY=VALUE: override a config value for this run.
A query that matches nothing prints nothing matches and exits 0. A config with problems is searched as read, with a warning.
Size
On a synthetic project with 650 lessons, 220 docs headings and 200 source files, a query answered in a median of 72 ms (77 ms with a brief, slowest 117 ms) on a 4-core Linux machine, scan and process start included. The index has no dependency of its own. If a corpus grows past 10,000 entries or a query past 200 ms, a persistent index is worth a look.
AGENTS.md and CLAUDE.md
fairlead agents sync keeps a block in the files an agent reads before anything else, AGENTS.md and CLAUDE.md, so it knows the change loop, the lessons that apply to every change and which skills exist before it has run a command. The block sits between two markers, and nothing outside them is written. Since 0.8.0.
fairlead agents sync # write or update the block in each file
fairlead agents sync --check # write nothing; exit 1 naming each file whose block is missing or stale
fairlead agents sync --clean # remove the block and its markers
[agents]
write = "block" # or "never": write nothing, print the block for a person to copy in
files = ["AGENTS.md", "CLAUDE.md"] # from the repository root
files appends to the defaults like any list, so files = { replace = ["AGENTS.md"] } keeps the block in one file. A path outside the repository is a config check problem.
The block
<!-- fairlead:begin -->
<!-- Generated by `fairlead agents sync`: edit fairlead.toml or .fairlead/lessons instead, then sync again. -->
**Fairlead: the change loop**
- Before an edit: `fairlead brief <paths>` names what they reach, the tests that will run, and the lessons and skills that apply.
- Before you finish: `fairlead done` runs the gate the change has to pass.
- After it: `fairlead receipt` compares the change with its brief, and `fairlead next` names the step that's due.
- The pull request: open it as a draft, `gh pr create --draft`, and mark it ready, `gh pr ready`, once `fairlead done` passes.
- `fairlead find <words>` searches the lessons, skills, docs headings and symbols.
- `fairlead learn` records a lesson a change taught, with its scope and evidence.
**Lessons for every change**
- Leave settles at sign-off (leave-settles-at-sign-off)
**Skills: load the one for the code you change**
- forms: How forms validate. (.claude/skills/forms/SKILL.md)
<!-- fairlead:end -->
- The loop: the commands of the brief, the done gate, the receipt and next,
findandlearn, and when to open the pull request as a draft and mark it ready, asnextsays. - Lessons for every change: the lessons with
always: true, astitle (id), by id. A lesson scoped to paths stays in the brief, which offers it when a change reaches it. - Skills: every routed skill, as
name: description (path), by name.
The block is at most 40 lines, markers included. Past that, the longer list gives up lines first and ends with … N more and the command that lists the rest: fairlead lessons list or fairlead brief --all. The same lessons, skills and config always give the same block, so --check in CI fails only when one of them changed and the block wasn’t synced.
A lesson or skill file that can’t be read is left out and named in a warning.
What it writes
- A file with the markers: only what’s between them is replaced.
- A file without them: a blank line and the block are added at its end.
- No file: it’s created with the block alone.
The file keeps its line endings (a CRLF file gets a CRLF block) and its final newline, or the lack of one. A file whose markers don’t make one block, such as a begin marker with no end marker, or two begin markers, is an error naming the file, and it’s left untouched; the other files are still synced.
--clean removes the markers, what’s between them and the blank line sync added before them, so a file it appended to comes back byte for byte. A file sync created, which holds only the block, is deleted.
Writing nothing: write = "never"
For a team that wants a person to own every line of those files. agents sync prints the block and writes nothing, and --clean removes nothing. --check still fails while a file is missing the block or holds a stale one, and fairlead doctor names each such file in either mode:
agents: AGENTS.md has no fairlead block; copy in the block `fairlead agents sync` prints
agents: CLAUDE.md's fairlead block is stale; copy in the block `fairlead agents sync` prints
[agents] is left out of the plan digest, so plan ids don’t move.
Context and resume
fairlead context is the brief with what an agent should read before the edit. fairlead resume is for a new session on a branch: where the last one stopped. Since 0.8.0.
fairlead context src/forms/form.ts # the brief, then the lessons, skills, README and history
fairlead context --all src/forms # everything, past the 120-line cap
fairlead context --json src/forms # the same, as JSON
fairlead resume # the last brief on this branch, what changed, the gate and next
fairlead resume --json # the same, as JSON
Context
context makes the same brief fairlead brief makes for the paths, kept in the session’s store the same way, and prints it. Then, in this order:
- lessons: each lesson the brief offered, with why, and its body. A lesson’s body passed the secrets check when it was loaded.
- skills: each skill the brief routed, with its description and the SKILL.md to load before editing.
- docs: for each named path, the first section of the nearest
README.mdup the tree, from the path’s directory to the root; three READMEs at most, twelve lines of each. A section that looks like it holds a secret is left out. - history: for each named file, the last three commits that touched it,
git log -3 --format='%h %ad %s' --date=short. With the lessons, that answers most of “why is this code like this”.
brief b-575006d4 base e41ad7c 1 path
…
next edit, then: fairlead done
lesson blur Validate on blur (named: src/forms/form.ts)
Validate on blur.
Not per key.
skill forms How forms validate.
load it before editing: .claude/skills/forms/SKILL.md
docs src/forms/README.md Forms
Every form posts through one helper.
history src/forms/form.ts
e41ad7c 2026-10-06 make the form add one
The text stops at 120 lines. A lesson, skill, section or history that doesn’t fit is left out with everything after it, and the last line says how many: … 4 more (fairlead context --all). --all lifts the cap. The JSON has no cap: brief, lessons (with path and body), skills (with path), docs and history.
Resume
resume finds the brief to pick up: the session’s own (--session, or CLAUDE_CODE_SESSION_ID), else the newest brief made on this branch by any session. A brief records the branch it was made on; one from before that counts when it has the same base. Then it prints, in 20 lines at most:
resume brief b-575006d4 base e41ad7c updated 2026-10-06 14:02 (the newest on this branch)
paths src/forms/form.ts
changed 2 files since its base: 1 in the brief, 1 outside
outside src/money.ts
gate not run for this tree
lessons 1 added on this branch (fairlead lessons list)
validate-on-blur Validate on blur
next done: `fairlead done` hasn't passed for this tree; no pull request yet? `gh pr create --draft`
- changed: every file that differs from the brief’s base, untracked files included, split into the ones the brief named and the rest.
- gate: the newest
fairlead donerun for the tree as it is now, as the receipt reads it. - lessons: lesson files under
memory.diradded since the base. - next: the step
fairlead nextwould name.
With no brief at all, it says so in one line and suggests fairlead brief <paths>.
The SessionStart hook
fairlead hooks install adds a SessionStart hook that runs fairlead resume --hook when a session starts, resumes, clears or compacts: for Claude Code, and (since 0.11.0) for Codex and Gemini CLI with --codex and --gemini, which send and read the same JSON. It reads the hook’s JSON on stdin (session_id, cwd) and answers with the resume text as context for the agent:
{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "fairlead: where the last session on this branch stopped.\nresume brief b-575006d4 …"}}
It prints nothing when there’s no brief, or when anything fails, and always exits 0. brief.resume = false leaves the hook out. fairlead migrate --write adds it to hooks installed before. Fairlead’s Codex and Gemini CLI hooks don’t include it yet.
MCP server
Since 0.10.0, fairlead mcp serves Fairlead’s read-only commands to an agent as MCP tools, over stdio. An agent that speaks MCP can ask for the plan or a brief without a shell, and gets the same JSON the command prints with --json.
Register it with the agent, from the repository root:
claude mcp add fairlead -- fairlead mcp # Claude Code
For another client, the server is the command fairlead mcp with no arguments, started in the repository. Where the project runs its own copy of Fairlead, use that command instead, such as bun x fairlead mcp.
Tools
| Tool | Runs | Returns |
|---|---|---|
plan | plan --json | the plan, as in the plan’s schema |
explain | test --explain | text: why a test or check is in the plan, or why not |
brief | brief --json | the brief for the paths a change will touch |
context | context --json | the brief, plus what to read around the paths |
find | find --json | hits from lessons, skills, headings and declared names |
receipt | receipt --json | what the change did against its brief, and the done gate |
next | next | text: the next step before the change is done |
lessons | lessons list --json | the lessons, with scope and review dates |
skills_report | skills report --json | skill routing’s hit rate |
Every tool is read-only. done isn’t one of them: it runs the tests, which takes minutes, and an MCP call can’t be stopped midway. Run fairlead done through the agent’s shell, where the hooks see it.
Paths are from the repository root and must stay inside it. Branch names, sessions and other values can’t start with -, so none can pass for an option. A refused or failed call comes back as a tool result with isError, with the reason as its text, so the agent can correct the call.
Sessions
Briefs and receipts join up by session. A command run by Claude Code reads the session from CLAUDE_CODE_SESSION_ID, but an MCP server may not be given it. So every tool takes an optional session argument. Without one, each brief is its own, and receipt and next compare with the base instead.
What the server guarantees
- Protocol output only. The server writes nothing to stdout but protocol messages. Each tool runs as a separate
fairleadprocess, which also keeps a crash in one call from ending the server. - Bounded calls. A call is killed after 60 seconds, and output past 256 KB is cut and marked
[truncated]. A cut result carries nostructuredContent. - Repository text is marked. Results that carry the repository’s own text (lessons, skill descriptions, headings) have
_meta: {"fairlead/repository-text": true}. The tools’ descriptions say that text is data, not instructions. - Calls are logged without their arguments. Each call adds an event at stage
mcpto the event log, with the tool’s name, the session when given, andok,refusedorerror. - Protocol handling.
- Protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 are echoed back, and any other version is answered with 2025-06-18.
- Notifications get no reply.
- Batches, which the 2025-06-18 revision removed, are refused.
- The server offers tools only, so
resources/listandprompts/listare unknown methods.
The done gate
fairlead done runs what a change must pass before it counts as finished, and records the outcome against the exact tree it checked. An agent runs it before it stops; a person can run it before they push. Since 0.8.0: until it passes, fairlead next also says to open the pull request as a draft, gh pr create --draft; once it has, to mark it ready, gh pr ready.
fairlead done # plan the working tree against the default branch and run the gate
fairlead done --base main # against another base
fairlead done --dry-run # print the steps without running them
fairlead done --keep-going # run every step even after one fails
fairlead done --check # exit 0 only if this tree, as it stands, has passed
The steps
In order:
- The test invocations the change’s plan selects, as
fairlead ci runwould run them. - The
[[checks]]the plan selects. - The checks
done.alwaysnames that the plan didn’t select. Run outside a plan, a check’s{files}stands for the change’s files. fairlead guard checkover the whole tree, as CI runs it.
Each step prints its command, then whether it passed and how long it took. The gate stops at the first failure unless --keep-going is given; either way it passes only when every step passed.
Since 0.7.0: a test or check a [[quarantine]] entry holds on this machine is the exception. When it fails with the output its entry expects, the step is not provable here: it doesn’t fail the gate, and the gate’s last line says how many steps weren’t provable, so the agent can say so in the pull request. Any other failure, or any failure after the entry’s date, counts. This covers a done.always check too. A command in a step is found the way a shell finds it, so on Windows a .cmd shim such as npm or pnpm starts.
[done]
tests = "planned" # planned | none
checks = "planned" # planned | none
always = ["typecheck"] # [[checks]] ids that run for every change
guard = true
A check done.always names needs no paths or modules, since it always runs.
The record
Every run appends a done event to .git/fairlead/events.jsonl: the plan’s tree hash, whether it passed, and each step’s id, outcome and seconds, never its output. A step that wasn’t provable here is recorded with "quarantined": true. A result belongs to one tree. The tree hash is HEAD’s tree when the working tree is clean, else a hash of every file’s path and blob id, so any edit after a pass makes it stale, and --check says so. Nothing needs clearing by hand, and switching branches or rebasing can’t make an old result look current.
[done] never changes a plan, so it’s left out of the plan’s config digest, like [guard] and [hooks].
The Stop hook
fairlead hooks install also adds a Claude Code Stop hook, fairlead guard stop, unless done.on_stop = "off". When the agent ends its turn, the hook checks whether the tree it leaves has passed fairlead done. It never runs the gate itself, which would outlast the hook’s timeout; it reads the record.
- Nothing changed from the default branch’s merge base: it lets the stop through, as for a session that only read.
- A pass for this tree: it lets the stop through.
- Otherwise it exits 2 with the reason on stderr, which Claude Code gives the agent instead of stopping: the gate hasn’t passed for the tree as it is now, or its last run failed, and to run
fairlead done. on_stop = "ask", the default, sends the agent back once: when Claude Code says a Stop hook already did (stop_hook_active), the next stop goes through."require"sends it back every time until the gate passes; Claude Code’s own cap on consecutive blocks ends a loop.- On any error, such as no base branch or an unreadable config, it lets the stop through. Each time it sends the agent back it records a
stopevent with the tree, never the reason’s text.
[done]
on_stop = "ask" # off | ask | require
The receipt and next
After a change, fairlead receipt compares what changed with the session’s brief and the done gate. fairlead next says which step the change is waiting for.
fairlead receipt # compare the working tree with the session's brief
fairlead receipt --out receipt.md # also write it to a file a commit or pull request can carry
fairlead receipt --json # the same, as JSON
fairlead next # the one step that's due
receipt for brief b-b578d149 base 72087d5..worktree
changed 3 files: 1 named in the brief, 1 in its reach, 1 outside
outside src/auth.ts adds 1 test (test/auth.test.ts)
tests planned now 2, briefed 1: 1 added by files outside the brief
gate passed for this tree at 16:01 (1 step) in 9 s
next ready: the gate passed and the receipt is written; `gh pr ready` if the pull request is a draft
Inside, reach and outside
Each changed file falls into one of three groups:
- named in the brief: the brief listed it.
- in its reach: the file depends on a path the brief named, so the brief already counted it.
- outside: neither. This is the drift a receipt is for. A change that named billing and also edited auth says so, with the tests the extra file adds to the plan.
The tests line compares the plan for what changed with the plan the brief was made from. The gate line reads the newest fairlead done run for the tree as it is now, so an edit after a pass shows as not run. Since 0.7.1: a pass that excused a failure a [[quarantine]] entry expects says how many steps were held, as (3 steps, 1 held), and the receipt’s JSON carries the count in gate.held, so it doesn’t read as a clean pass.
With no brief for the session, the receipt still lists every changed file and the gate’s state, and says there was nothing to compare against.
A receipt is kept in .git/fairlead/receipts/, never committed, beside the brief it belongs to. --out writes it to a file: JSON for a .json name, the text otherwise.
next
One line, from the state of the change:
| State | next |
|---|---|
| Nothing has changed | nothing due |
| Changed, no brief for the session | fairlead brief <paths> |
| Briefed, the gate hasn’t passed for this tree | fairlead done, and gh pr create --draft if no pull request is open yet |
| The gate failed | the failing step’s command, then fairlead done |
| The gate passed, no receipt for this tree | fairlead receipt, then gh pr ready |
| The gate passed and the receipt is written | gh pr ready if the pull request is a draft |
The session comes from --session or CLAUDE_CODE_SESSION_ID, as for the brief.
Since 0.8.0: next also says when to open the pull request as a draft and when to mark it ready, so a CI that runs only fast checks on a draft runs the rest once it’s marked ready. Fairlead never runs gh and doesn’t ask whether a pull request is open, so the line reads right either way. A [[guard.commands]] rule can refuse gh pr create without --draft.
Replay
Replay is how Fairlead proves a plan’s recall: it takes real CI failures from a repository’s history, plans each failing commit again, and counts every failing test file or check the plan would have left out. It runs in two phases, so the report can always be reproduced from committed data.
fairlead replay fetch --repo owner/name --since 2026-07-01 --workflow ci.yml --data bench/data/owner_name.jsonl --clone ../name
fairlead replay run --data bench/data/owner_name.jsonl --clone ../name --config bench/owner_name.toml --fetch-missing
replay fetch
Lists every completed pull_request and merge_group run since --since, a week at a time since one listing returns at most 1,000 runs, and records one row per run attempt, passing or failing, so a job that failed and then passed on a re-run shows up as flaky. --workflow FILE (repeatable; a workflow’s file name, such as ci.yml, or its id) lists only those workflows’ runs, so other workflows cost no requests, and a first attempt that was cancelled or skipped is left out: it ran nothing, and each recorded attempt costs an API request. It needs curl and a token in GITHUB_TOKEN or GH_TOKEN; in GitHub Actions the workflow’s own token can read other public repositories’ runs and logs. A workflow that sets permissions: grants contents: read, actions: read, pull-requests: read (the pull request each run belongs to) and checks: read (annotations). Without checks: read, the fetch warns once and records runs without annotations; without the others it stops at the first refusal, keeping the rows so far.
--event push also records the default branch’s completed push runs. Once pull requests run only the planned tests, a pull request run can’t show a miss, since only the plan ran; the evidence is on the default branch, which usually still runs everything after a merge. A test that fails there and that the merged change’s plan left out got past the plan: an escape.
Since 0.9.0: --event schedule records the default branch’s completed scheduled runs, for a project whose pushes run the plan too and whose full suite runs on a schedule (a stage-full nightly, say). There, an escape is a test the nightly caught that no push’s plan reached. See Scheduled runs.
- Failed jobs keep the extractor’s input, not its output: failure-level annotations (GitHub’s
.githubexit-code note left out) and the log lines around eachFAIL,●or bun(fail), with the bun file header each failure sits under, each PHPUnit failure or PestFAILEDline with the stack frames after it and each⨯line under aFAILheader, undergo test -vthe lines a failed test logged before its--- FAIL, and Surefire’s<<< ERROR!lines and closing lists, the Gradle task header eachFAILEDline sits under, Gradle’s failed-task lines and Kotlin and Java compile errors. Logs expire after 90 days; the dataset keeps enough to re-extract with a better extractor later. - Each row records its pull request and base. For a pull request run, the pull request comes from the run, the commit’s associated pulls, or, for a fork, the pulls whose head is the fork’s branch; the base is the base branch’s first-parent commit when the run started, or the default branch’s when no pull request is found. For a merge queue run, both come from the queue branch’s name (
gh-readonly-queue/<base>/pr-<N>-<sha>). For a push, the pull request is the one the commit’s associated pulls say merged it (the first one when none does, none for a direct commit), and the base is the pushed commit’s first parent, the branch before the merge; without--cloneit’s left forreplay runto read. A scheduled run records neither: its base is the last green scheduled run, whichreplay runfinds among the rows. - With
--clone, each run’s head commit is fetched into the clone, so it’s there whenreplay runneeds it. - Rows already in the dataset are skipped before any of their jobs are fetched, so a weekly fetch is incremental.
--limit Nstops after N new attempts. - GitHub’s secondary rate limit, which refuses bursts with a 403 or 429 however much of the hourly budget is left, is waited out, for API calls and log downloads alike: up to three retries, after the
Retry-AfterGitHub sends or one, two and three minutes. Each wait prints a line. A 500, 502, 503 or 504 is GitHub failing for a moment and is retried too, after 5, 10 and 15 seconds. An exhausted hourly budget (X-RateLimit-Remaining: 0), any other refusal, or a server error that persists stops the fetch with GitHub’s message; the rows fetched so far are kept. - The last line says how it ended:
fetch complete,fetch partial(the limit was reached; run it again to continue), orfetch stoppedwith the API’s error and exit code 2. The rows gathered so far are written in every case, and a report from a partial dataset shouldn’t be read as the whole window. - The token never reaches curl’s arguments, only its standard input, and a redirect to log storage is followed without it.
The dataset is JSON lines, appended and never rewritten.
replay run
With --fetch-missing, the recorded heads and bases the clone lacks are fetched first. --json-out PATH writes the JSON report as well as printing the text one. While it works it prints progress on stderr, as plain lines a CI log keeps: every 25 planned runs and at least once a minute (412 of 806 runs planned · 93 failures judged · 3 misses · 10 min, about 10 min left), and a line for any run that takes over 10 seconds to plan and judge. --quiet turns that off; the report on stdout and in --json-out is the same either way. For each failed row in the window, replay checks out the head commit into a worktree beside the clone (created once and reused, so the parse cache carries over), plans the change from the merge base of the recorded base (or, for a row without one, the clone’s default branch as it stood when the run started) and the head, and classes every failure the row names:
| Outcome | Meaning |
|---|---|
| hit | the test file or check is in the plan, or the plan selects everything |
| miss | it isn’t, and nothing below explains it; recall is hits over hits and misses |
| flaky | another attempt of the same run passed the same job; decided before hit, so a flaky failure never raises recall |
| unconfirmed | a later run of the same change passed the job: the same head again, or the same patch rebased onto a newer base (equal git patch-id) |
| unattributed | the job failed, but no test file or check could be named from its annotations or log |
| unavailable | the head commit, or the history to its merge base, isn’t in the clone |
| error | the planner refused the commit, such as a test file no runner matches |
| unwatched | no [[replay.failures]] or [[replay.checks]] entry names the job; listed by job name so a gap in the config can’t raise recall |
| quarantined | a [[replay.quarantine]] entry declares the test flaky in this job (below) |
| environment | the job failed alike across unrelated pull requests once its runner image changed (below) |
| inherited | the failure came with the base branch: the base’s own push run failed the test in the job, or three or more unrelated pull requests on the same base did (below) |
| recurring | the test failed in the job across three or more unrelated pull requests on different bases, none of them touching it (below) |
| ignored | replay.ignore names the job, such as one that only aggregates others, or it failed only in steps replay.ignore_steps names, such as an install |
Recall is hits over hits and misses. Strict recall also counts unconfirmed failures as misses.
Failing test files come from annotations and from the log, through the extractors named in [[replay.failures]]: vitest, jest, bun, phpunit and pest (since 0.10, each reads both formats: PHPUnit’s own, and Collision’s, which Pest, Laravel’s php artisan test and PHPUnit under Collision’s printer print; a FAILED line cut to the terminal’s width takes its class from the FAIL header the test was listed under), go (go test, plain or -v, and gotestsum), pytest (its FAILED and ERROR summary lines, -v and xdist output, and modules that failed to import), maven and gradle (since 0.7.0, below), or regex with a pattern that has a named file group (and optionally project and title). PHPUnit and Pest name a failing test by its class, so the test file is the stack frame or at path whose file name is the class’s, and otherwise the class name as a path: an error thrown in the code under test doesn’t pass for the test file. A path on the runner, such as /home/runner/work/app/app/tests/Unit/FooTest.php, is matched by its longest tail that is a file in the repository. go test names a failed test’s file without its directory, so it’s printed under the package’s import path, /example.com/app/cart/cart_test.go, and matched the same way, or, when the module sits in a subfolder, by the longest tail of two segments or more that ends exactly one file. The file is the one the test logged, else a test file in its panic, else a compile error in a test file. Since 0.11.0: a package whose tests didn’t build (FAIL <package> [build failed]) with none of these is attributed to its first test file, since go test runs a package whole and every test file of it has the same reach; a [setup failed] package, a module download that failed, is the environment’s. Any other failure with none of these is unattributed. A printed path is matched to a file at that commit: as it stands, under the named project or package, by a unique suffix, or among the suffix matches by the one whose source contains the failing test’s title. Anything still ambiguous is unattributed. Failing checks come from [[replay.checks]], which map a job and step name to a check id.
Since 0.7.0: maven reads Maven’s Surefire and Failsafe, and gradle reads Gradle’s test output, plain or with GitHub’s timestamps. Both name a failing test by its class, so the class becomes a path and the file is the Java or Kotlin one whose path ends with it, under any module’s source root: com.acme.FooTest is …/com/acme/FooTest.java or .kt. A nested class, Outer$Inner or Outer.Inner, is its outer class’s file. Kotlin may leave a package’s leading folders out, so failing that, a shorter tail counts for a file that declares the class’s package.
- Maven: each failed test’s
[ERROR] com.acme.FooTest.method -- Time elapsed: … <<< FAILURE!(or<<< ERROR!;method(com.acme.FooTest)before Surefire 3), and a class whoseTests run: … <<< FAILURE! -- in com.acme.FooTestline names no test, such as one that failed in@BeforeAll. The closing[ERROR] Failures:andErrors:lists repeat these by simple name, so an entry counts only for a class nothing above named. Flaky tests, which Surefire prints as warnings, don’t count. - Gradle:
FooTest > method() FAILED, with the class’s simple name under JUnit 5 and its qualified name under JUnit 4, and the nested class or parameterized case between them (FooTest > Nested > method() > [1] a FAILED). The project is the one in the> Task :module:testheader the line sits under, which Gradle’s grouped output prints again whenever another task’s output follows, so--parallelbuilds attribute too. When several modules hold a class of that name, the one under the project’s folder is the file, and after that the one holding the test’s name. A task whoseThere were failing testsreport follows noFAILEDline, as under--quiet, is unattributed. A task that compiles tests, such ascompileTestKotlinorcompileTestJava, fails on the file that doesn’t compile: itse: file.kt:44:13orFile.java:31: error:line names the test file. A compile error in main code is no test’s failure, so it stays unattributed.
The window is the replay.window_days days ending at the newest recorded run, or at --until, so the same dataset always gives the same report. The report says whether replay.min_failures attributed failures were reached.
[replay]
window_days = 90
min_failures = 30
ignore = ["^all-green$"]
[[replay.failures]]
runner = "vitest"
extractor = "vitest"
job = "^test"
[[replay.checks]]
job = "^lint$"
step = "^Typecheck$"
check = "typecheck"
Inherited failures
A pull request can fail a test only because its base branch already did. Replay groups failures by base commit, job and test file, and calls a group inherited when either holds:
- The base’s own run failed it. A push run recorded with
--event pushwhose commit is the base failed the same test in the same job. That’s direct proof, so one pull request is enough. - Unrelated pull requests failed it alike. Pull requests on the same base failed it, and three or more of them changed no file in common with each other. A stack of related changes shares a cause, so it counts once.
Either way, a pull request that changed the test, or a file beside it that isn’t another test (a fixture, a helper), stays out of the group: its own change may be what broke it. Hits and misses are grouped alike, so the rule can’t be chosen to raise recall. An inherited failure keeps what it would have been; it’s left out of adjusted recall and the min_failures gate and counted in raw recall, as quarantined ones are. The report lists each group with its base, job, test, pull requests and evidence, and says “fixed later” when a later run on another base passed the job, else “unresolved”.
Runner image waves
When a floating runner label such as ubuntu-latest moves to a new image, a job can fail on every pull request for days until someone fixes it, whatever each one changed. replay fetch records each failed job’s runner image and version from its log’s “Runner Image” header, and replay run calls a group of failures a wave when all of these hold:
- the same test failed in the same job on an image version that job’s failures hadn’t run on before;
- within 7 days of that version first appearing;
- in three or more pull requests that changed no file in common, none of them touching the test or a non-test file beside it;
- and the test hadn’t failed in that job on an older image, so it isn’t an ordinary regression that happens to share the dates.
Wave failures get the outcome environment: kept out of adjusted recall and counted in raw recall, hits and misses alike, like inherited ones. The report lists each wave with its job, test, old and new image, when the new one appeared, and its pull requests. Only failed jobs’ logs are read, so an image is known only from failures, and datasets fetched before this have none.
Recurring failures
A flaky test fails now and then whatever a pull request changed. Replay calls a failure flaky only when another attempt of the same run passed the job, so a test that fails again on its re-run, or never gets one, would count against a plan that had no reason to select it. Since 0.10.0, replay run sets such failures aside when all of these hold:
- the same test failed in the same job for three or more pull requests that changed no file in common;
- none of them touched the test, or a file beside it that isn’t another test, so none of their changes is a likely cause;
- and the failures weren’t already explained as quarantined, inherited or a runner image wave.
These failures get the outcome recurring. As with inherited and wave failures, hits and misses alike are kept out of adjusted recall and counted in raw recall, so setting them aside can’t be used to raise recall. The report lists each test with its job and pull requests, and a [[replay.quarantine]] entry to add if a person confirms the test is flaky. A real gap in the graph that unrelated changes keep hitting looks the same, so read the list before adding the entries; raw recall still counts them.
A test file is recognised by its name: x.test.ts, x.spec.ts, test_x.py, x_test.py, x_test.go, and XTest or XTests in Java, Kotlin and PHP.
Quarantine
A test that fails in one CI job whatever changes, such as a platform-specific flake, measures that job rather than the planner. A [[replay.quarantine]] entry declares it:
[[replay.quarantine]]
path = "test/typecheck.test.ts" # one test file
job = "^unit, windows$" # the jobs it fails in, as a regex
reason = "Fails only on Windows: 60 failures across 36 pull requests; the job passed 356 times."
until = "2026-12-31" # it stops applying after this day
Every failure is still planned and judged. While an entry applies, its matching hits, misses and unconfirmed failures become quarantined, and the report says what each would have been. It applies only while the dataset bears it out: the test failed in at least three distinct pull requests in the window, and never in a job the entry doesn’t name. Otherwise it’s reported unverified, with the other jobs named. After until it’s expired, and when the test didn’t fail in the window, or only flakily, it’s stale, a sign the entry can go. until is compared with the end of the window, the dataset’s newest run unless --until says otherwise, not with today, so a replay gives the same answer whenever it runs. The report names the jobs each entry absorbed failures in, which shows a regex that reaches further than meant. The report gives recall both ways, with the number of failures each is measured on: raw (quarantined failures counted as what they would have been) and adjusted (left out). Quarantined failures don’t count toward replay.min_failures.
Scheduled runs
Since 0.9.0: a failing scheduled run at commit C is judged job by job. For each failed job, P is the newest earlier scheduled run of the same workflow in the window where that job passed, the same “last green” ci plan --since-green uses. The run is planned from P to C, so the plan covers every push in between, and each failing test is classed against it:
| Outcome | When |
|---|---|
| hit | the plan from P to C selects it: a push’s change reaches it |
| escape | the plan leaves it out, so no push in P..C ran it |
| unconfirmed | a later scheduled run passed the job on the same tree, or P’s tree is C’s (nothing changed, so the change can’t be what failed) |
| unavailable | no earlier scheduled run in the window passed the job, or P’s commit isn’t in the clone |
An escape names where it came in: the push, and the pull request its recorded push run names, when P..C holds exactly one push; otherwise the range and how many pushes it holds. Scheduled runs have their own report line, schedule (full runs), with their misses counted as escapes, and each escape lists an introduced line.
To record them, add --event schedule to replay fetch (beside --event push if the pushes’ runs are wanted too, which also lets an escape name its pull request). The window has to reach back to a green night: a job that has failed every night since the window began is unavailable until one passes.
The report
Per repository: runs replayed, attributed failures against the gate, recall and strict recall (overall and per event, since merge queue runs usually run everything and are the better evidence), hits by how they were selected (by the plan, because the plan selected everything, or as a check), the other outcomes, unwatched jobs by name, the median and 90th-percentile share of test files selected, the share of plans that selected everything, and the first (cold), median and 90th-percentile planning time. Each miss lists its job (since 0.10.0), the changed files and an owner rule that would have caught it, built from the first changed file that isn’t a test or a document (since 0.10.0). When only tests or documents changed, no rule is suggested: another test or a README can’t break the test, so it likelier failed on its own. Push runs have their own line, push (after merge), and scheduled runs theirs, schedule (full runs) (since 0.9.0), with their misses counted as escapes; for a repository whose pull requests run the plan, that line is the recall that matters, and for one whose pushes run the plan too, the scheduled line is. --json prints the same as JSON.
Limits
- CI ran a pull request’s merge commit with its base, while replay plans the head against the merge base. A failure caused by the base alone can show up as a miss, unless it’s recognised as inherited (above): with the base’s push runs recorded, or when enough unrelated pull requests share it.
- A repository whose pull request CI already runs only affected tests records only what it chose to run; its merge queue runs or its default branch’s pushes (
--event push), which usually run everything, are the better evidence, and “passed at a later push” is weaker there. - A push is planned against its first parent, the diff the pull request’s plan saw at merge time, and a failure there is unconfirmed only when a later push of the same tree passed the job (a revert and reland). A failure that appears only because two pull requests, each green alone, conflict shows as an escape on the second one.
- A scheduled run is planned once, from the last green run to its commit, not once per push. That plan covers the same changes as the pushes’ plans together, except a change made and undone inside the range, which can’t be what failed at C. An escape spread over several pushes names the range, not the push: telling which one brought it in would mean running the test at each.
The benchmarks
bench/ holds a config for each public benchmark repository, written from how that repository’s CI runs its tests. The bench workflow runs weekly, or by hand with since (85 days back by default, since GitHub keeps job logs for 90) and limit:
- For each repository in turn, it builds Fairlead from the workflow’s commit, starts from the dataset on the
bench-databranch (ormain), clones the repository with full history and no blobs, records new runs, and replays the window with--fetch-missing. - A last job copies each grown dataset into
bench/data/, writes Benchmarks, and force-pushes both tobench-data, recreated frommaineach time, then opens a pull request from it if none is open. With a GitHub App configured (theBENCH_APP_CLIENT_IDvariable andBENCH_APP_PRIVATE_KEYsecret, the app allowed to write contents and pull requests), it pushes and opens the pull request as that app, so the pull request’s checks start on their own. Without one it uses the workflow’s token, and a maintainer approves the checks. - The workflow token’s API budget is about 1,000 requests an hour for the whole run, so a first backfill takes several runs: each records up to
limitattempts per repository and the next carries on. ABENCH_READ_TOKENsecret, if set, is used instead. Pull requests opened by the workflow token don’t start CI.
Probing a repository
Before a repository joins the benchmark, the replay-probe workflow measures it once: dispatched with a repository, its CI workflow file and a config under bench/probe/, it records up to limit recent run attempts, replays them against that commit’s planner and prints the report in the run’s summary. It publishes nothing and keeps nothing between runs, so it’s the way to try a new language or framework on real CI history.
Readiness score
Since 0.10.0, fairlead doctor --score says how ready a repository is for Fairlead, as points out of 100 on a fixed rubric. Each item asks for evidence that the part works, not only that it’s set up: a done gate that never passed, or hooks that never saw a write, earn nothing. The rubric has a version, and a score is only compared with one on the same version, so a team can record a score before onboarding and again after.
fairlead doctor --score # the items, then the three fixes worth the most
fairlead doctor --score --json # the same, as JSON
fairlead doctor --score --min 70 # exits 1 below 70, for CI
fairlead doctor --score --replay out/report.json
Rubric 1
| Item | Points | Earned when |
|---|---|---|
config | 10 | a project file loads with no problems |
runners | 15 | there’s a runner, and every test file has exactly one |
hooks | 10 | Claude Code’s hooks are installed, and Codex’s or Gemini CLI’s where .codex/ or .gemini/ exists; split between them |
done | 10 | fairlead done passed in the last 30 days |
ci | 10 | 5 for a workflow that runs fairlead ci (or the action), 5 more with [stages] |
replay | 15 | a replay report whose window ends in the last 30 days and met its min_failures gate |
recall | 15 | that report’s recall is 95% or more, and its median plan selected less than everything |
firing | 5 | the write hook checked a write in the last 7 days |
skills | 5 | there’s a routed skill, and every SKILL.md has a route |
lessons | 5 | there’s a lesson, and none is due for review |
The replay items read a fairlead replay run --json-out report: --replay FILE, or .fairlead/replay.json when it’s there. A repository whose replay runs in CI can download that report before scoring. Recall counts only beside a median plan that selects less than everything, so plan.run_all = ["**"] can’t earn it.
The events behind done, firing and hooks are the local event log, so a score reflects the machine it runs on. The tracker and token items in the design come with them, in rubric 2.
Benchmarks
Replay results on public repositories, written by the bench workflow. See Replay for what each measure means; the configs are in bench/.
Planned with Fairlead 0.10.0 at 6770f73ce6f0.
Effect-TS/effect
Window 2026-05-26 to 2026-08-23; dataset bench/data/Effect-TS_effect.jsonl, 2475 run attempts from 2026-06-22 to 2026-08-23. Fetch: fetch stopped: 0 new rows in out/data.jsonl.
| Measure | Value |
|---|---|
| Runs replayed | 498 |
| Attributed failures (gate 30) | 1436 (met) |
| Recall | 99.2% |
| Recall with quarantined tests counted (raw) | 97.7% (n=1578) |
| Recall with them left out (adjusted) | 99.2% (n=1436) |
| Strict recall (unconfirmed as misses) | 99.1% |
| Hits: selected, run everything, checks | 458, 703, 263 |
| Misses | 12 |
| Flaky, unconfirmed, unattributed | 60, 1, 36 |
| Unavailable, errors, ignored | 0, 0, 3 |
| Test files selected: median, p90 | 6.2%, 100.0% |
| Plans that selected everything | 19.9% |
| Plan time: first (cold), median, p90 | 1.14 s, 0.12 s, 0.49 s |
Recall on pull_request runs (strict) | 99.2% (99.1%) |
Quarantined tests (declared in the bench config, applied only while the data bears them out):
packages/sql/mysql2/test/Persistence.test.tsin jobs^Test \(1/2, (Node Deno)\)$: active, 21 failures absorbed (14 would-be hits, 7 would-be misses), 19 pull requests, until 2026-12-31. Times out after 30 s waiting on MySQL, on changes that touch neither: failed in 13 pull requests in July, only in the first test shard. Two of them broke the build instead (a transform error, a missing import).- Absorbed in:
Test (1/2, Deno),Test (1/2, Node)
- Absorbed in:
packages/sql/mysql2/test/KeyValueStore.test.tsin jobs^Test \(2/2, (Node Deno)\)$: active, 9 failures absorbed (8 would-be hits, 1 would-be misses), 9 pull requests, until 2026-12-31. Its setup hook times out after 30 s waiting on MySQL: failed in 8 pull requests in July, only in the second test shard. Two of them broke the build instead (a transform error, a missing import).- Absorbed in:
Test (2/2, Deno),Test (2/2, Node)
- Absorbed in:
packages/sql/d1/test/Resolver.test.tsin jobs^Test \(2/2, Node\)$: active, 7 failures absorbed (7 would-be hits, 0 would-be misses), 8 pull requests, until 2026-12-31. Times out after 5 s against the local D1 engine: failed in 7 pull requests in July, only in the second Node test shard.- Absorbed in:
Test (2/2, Node)
- Absorbed in:
Plans that selected everything, by cause:
unreached packages/**: 23run-all tsconfig.base.json: 18run-all package.json: 15run-all tsconfig.packages.json: 13run-all .github/**: 10run-all packages/**: 5unreached .agents/**: 4run-all pnpm-workspace.yaml: 2run-all vitest.config.ts: 2unreached .vscode/**: 2
Failed jobs no rule watches:
AI Documentation Generation: 3Circular Dependencies: 4Test on Bun: 4
Misses:
- run 29435902211 attempt 1:
packages/sql/pg/test/Client.test.ts, changed.changeset/cli-wizard-mode.md, packages/effect/src/unstable/cli/Command.ts, packages/effect/src/unstable/cli/GlobalFlag.ts, packages/effect/src/unstable/cli/Param.ts, packages/effect/src/unstable/cli/internal/ansi.ts - run 29544337445 attempt 2:
packages/effect/test/schema/toArbitrary.test.ts, changed.changeset/fix-cluster-mssql-for-update.md, packages/effect/src/unstable/cluster/SqlMessageStorage.ts, packages/platform-node/package.json, packages/platform-node/test/cluster/SqlMessageStorage.test.ts, packages/platform-node/test/cluster/SqlMessageStorageMssql.test.ts - run 30312917536 attempt 1:
packages/sql/libsql/test/Client.test.ts, changed.changeset/expose-ai-prompt-part-schemas.md, packages/effect/src/unstable/ai/Prompt.ts, packages/effect/test/unstable/ai/Prompt.test.ts - run 30414439050 attempt 1:
packages/tools/openapi-generator/test/OpenApiGeneratorCli.test.ts, changed.changeset/add-deno-multipart.md, packages/platform-deno/src/DenoMultipart.ts, packages/platform-deno/src/index.ts - run 30414439050 attempt 1:
packages/tools/openapi-generator/test/JsonSchemaGeneratorRepresentation.test.ts, changed.changeset/add-deno-multipart.md, packages/platform-deno/src/DenoMultipart.ts, packages/platform-deno/src/index.ts - run 30690677178 attempt 1:
packages/sql/pg/test/Persistence.integration.test.ts, changed.changeset/mcp-protocol-versions.md, packages/effect/MCP.md, packages/effect/src/unstable/ai/McpProtocol.ts, packages/effect/src/unstable/ai/McpSchema.ts, packages/effect/src/unstable/ai/McpServer.ts - run 30711795071 attempt 1:
packages/sql/pg/test/Client.integration.test.ts, changedpackages/effect/test/SubscriptionRef.test.ts - run 30899876531 attempt 1:
packages/platform-node/test/NodeRedis.integration.test.ts, changedpackages/effect/test/unstable/cli/Help.test.ts - run 30899876531 attempt 1:
packages/sql/libsql/test/Client.integration.test.ts, changedpackages/effect/test/unstable/cli/Help.test.ts - run 30955200961 attempt 1:
packages/platform-deno/test/cluster/SocketRunner.test.ts, changed.changeset/good-cups-reply.md, packages/sql/mssql/src/MssqlClient.ts, packages/sql/mssql/test/Client.test.ts - run 31047937195 attempt 1:
packages/platform-node/test/cluster/SqlMessageStorage.integration.test.ts, changedpackages/ai/openai/test/OpenAiSchema.test.ts - run 31654685741 attempt 1:
packages/sql/mssql/test/Persistence.integration.test.ts, changed.changeset/scope-message-storage-clear-address.md, packages/effect/src/unstable/cluster/MessageStorage.ts, packages/effect/src/unstable/cluster/SqlMessageStorage.ts, packages/effect/test/cluster/MessageStorage.test.ts, packages/platform/node/test/cluster/MessageStorageTest.ts
pnpm/pnpm
Window 2026-05-25 to 2026-08-22; dataset bench/data/pnpm_pnpm.jsonl, 3100 run attempts from 2026-06-25 to 2026-08-22. Fetch: fetch partial: 300 new rows in out/data.jsonl; run again to continue.
| Measure | Value |
|---|---|
| Runs replayed | 444 |
| Attributed failures (gate 30) | 470 (met) |
| Recall | 100.0% |
| Strict recall (unconfirmed as misses) | 100.0% |
| Hits: selected, run everything, checks | 94, 376, 0 |
| Misses | 0 |
| Flaky, unconfirmed, unattributed | 11, 0, 241 |
| Unavailable, errors, ignored | 0, 0, 485 |
| Test files selected: median, p90 | 100.0%, 100.0% |
| Plans that selected everything | 83.2% |
| Plan time: first (cold), median, p90 | 1.50 s, 0.33 s, 0.46 s |
Recall on pull_request runs (strict) | 100.0% (100.0%) |
Plans that selected everything, by cause:
unreached pnpm/**: 146run-all pnpm-lock.yaml: 86unreached Cargo.lock: 31run-all .github/**: 27unreached pacquet/**: 19unreached .gitignore: 14unreached cspell.json: 10run-all package.json: 9run-all pnpm-workspace.yaml: 3unreached .typos.toml: 1
vitest-dev/vitest
Window 2026-07-15 to 2026-10-12; dataset bench/data/vitest-dev_vitest.jsonl, 2029 run attempts from 2026-06-21 to 2026-10-12. Fetch: fetch complete: 127 new rows in out/data.jsonl.
| Measure | Value |
|---|---|
| Runs replayed | 1093 |
| Attributed failures (gate 30) | 693 (met) |
| Recall | 99.3% |
| Recall with quarantined tests counted (raw) | 95.1% (n=1089) |
| Recall with them left out (adjusted) | 99.3% (n=693) |
| Strict recall (unconfirmed as misses) | 99.0% |
| Hits: selected, run everything, checks | 552, 56, 80 |
| Misses | 5 |
| Flaky, unconfirmed, unattributed | 11, 2, 2635 |
| Unavailable, errors, ignored | 0, 0, 1593 |
| Test files selected: median, p90 | 98.7%, 100.0% |
| Plans that selected everything | 29.1% |
| Plan time: first (cold), median, p90 | 7.09 s, 0.18 s, 0.58 s |
Recall on pull_request runs (strict) | 99.3% (99.0%) |
Quarantined tests (declared in the bench config, applied only while the data bears them out):
test/typescript/test/typechecker.test.tsin jobs^Test: unit, node-24, windows-latest$: active, 91 failures absorbed (75 would-be hits, 15 would-be misses), 48 pull requests, until 2026-12-31. Fails only on the Windows unit job (its out-of-memory crash and missing-command cases): 60 failures across 36 pull requests from June to August 2026, while the same job passed 356 times and no other job ever failed it.- Absorbed in:
Test: unit, node-24, windows-latest
- Absorbed in:
Plans that selected everything, by cause:
run-all .github/**: 158run-all package.json: 66run-all pnpm-lock.yaml: 43run-all pnpm-workspace.yaml: 15run-all test/**: 8unreached .dockerignore: 8unreached eslint.config.js: 4unreached knip.jsonc: 2unreached test/**: 2unreached .github/**: 1
Failed jobs no rule watches:
Browser: chromium, macos-latest: 1Browser: chromium, windows-latest: 1lint: 1test (macos-14, 20): 1test (ubuntu-latest, 18): 1test (ubuntu-latest, 20): 1test (ubuntu-latest, 22): 1test (windows-latest, 20): 1test-browser (chrome, chromium): 1test-browser (edge, webkit): 1test-browser (firefox, firefox): 1test-browser-windows (chrome, chromium): 1test-browser-windows (edge, webkit): 1test-ui: 1test-ui-e2e (macos-14): 1test-ui-e2e (ubuntu-latest): 1test-ui-e2e (windows-latest): 1
Misses:
- run 34299387859 attempt 1:
test/e2e/test/open-telemetry.test.ts, changedpackages/ui/client/components/trace/TraceViewPane.vue, packages/ui/client/composables/navigation.ts, packages/ui/client/composables/params.ts, packages/ui/client/pages/index.vue, test/ui/test/trace.spec.ts - run 34458638078 attempt 1:
test/coverage-test/test/reporters.test.ts, changedpackages/ui/client/components/BrowserIframe.vue, packages/ui/client/styles/main.css - run 34956251133 attempt 1:
test/browser/specs/oxc.test.ts, changedpackages/ui/client/pages/index.vue, packages/ui/client/styles/main.css - run 35604155400 attempt 1:
test/unit/test/expect-poll.test.ts, changedtest/unit/test/jest-expect.test.ts - run 37770032024 attempt 1:
test/browser/specs/server-options-inline.test.ts, changedtest/coverage-test/test/uncovered-concurrent-transform.istanbul.test.ts
Tokens and cost
Since 0.10.0, Fairlead records how many tokens each agent session used, and what it cost when the source says. Only numbers are read.
fairlead tokens report # the newest totals of each session and source
fairlead tokens report --session ID --json
fairlead tokens import result.json # a headless run: claude -p --output-format json > result.json
Sources
| Source | How it’s read | Cost |
|---|---|---|
transcript | The Stop hook reads the session’s transcript, from the transcript_path Claude Code passes it. | yes |
headless | fairlead tokens import reads the JSON claude -p --output-format json prints. | yes |
From a transcript, Fairlead reads only the cost-state lines Claude Code writes. These are Claude Code’s own running totals for each process: tokens per model and the cost, counting subagents and its background calls. The last line of each process is summed, so a resumed session adds up.
A transcript without cost-state lines, from an older Claude Code, is counted from the usage on each assistant message instead. Each message is counted once, though a streamed reply repeats its usage on several lines, and the subagent transcripts beside the session’s are included. Those totals are marked estimate: a message’s usage can be written before its reply finishes streaming, so output can be undercounted, and background calls aren’t there at all. No price table ships with Fairlead, so an estimate has no cost.
Each Stop records the session’s totals so far, and a later record of the same session and source replaces the earlier one. Codex and Gemini CLI sessions aren’t counted yet.
What reaches the log
A tokens event holds the session id, the source and the five numbers (input, output, cache read, cache write, cost), and nothing else from the transcript. The transcript’s text is never parsed. Only the cost-state lines are, or the assistant lines’ usage in the fallback. guard.events = "off" stops the records, as it does for the hook’s.
Insights
Since 0.10.0, fairlead insights sums up how the setup has been working, and --suggest lists the config changes that evidence backs.
fairlead insights # the last 30 days of the event log
fairlead insights --since 2026-09-01 --json
fairlead insights --replay out/report.json --results results.json --results results-2.json
fairlead insights --suggest # and the changes the evidence backs
fairlead insights --suggest --write # and apply the one-key change
What it reads
| Section | From |
|---|---|
guard | the write hook’s decisions and denies by rule, the same counts fairlead doctor shows |
done | fairlead done runs passed and failed, and the steps that failed |
skills | skill routing’s hit rate, with the skills offered and unused, and those used without an offer (as fairlead skills report counts them) |
lessons | lessons no brief has offered in the window |
tokens | sessions and their cost, from the newest record of each (Tokens and cost) |
replay | with --replay, a replay run --json-out report’s recall, misses and recurring tests |
escapes | with --results, the escapes in ci run --results files, one per merge |
The event log is local, so the first five sections reflect the machine insights runs on. A replay report and results files usually come from CI.
Suggestions
--suggest lists only changes the evidence backs. Each comes with its counts:
| Suggestion | Evidence | --write |
|---|---|---|
an [[tests.owners]] rule | two or more escapes or replay misses needed the same rule | no: add it, then replay to see what it costs in plan size |
a [[replay.quarantine]] entry | a recurring test in the replay report | no: a person confirms it’s flaky and gives the reason |
| drop or narrow a skill route | offered in ten measured sessions and never used | no |
guard.on_finding = "deny" | twenty warned writes, and no finding reached a commit | yes |
--write applies only the guard change, because it’s the one that changes a single key. It edits that line in fairlead.toml, or adds it under [guard], so the file’s comments and order stay as they were. The rest are printed for a person to judge. Nothing is committed.
Weekly summary
fairlead ci workflow --insights --write writes .github/workflows/fairlead-insights.yml. That workflow runs on Mondays at 05:17 UTC, or by hand, and does three things:
- Replays the last 30 days of the
fairlead.ymlworkflow’s runs. - Runs
fairlead insights --sinceseven days ago, with that replay report and--suggest. - Puts the result in the run’s summary.
It runs no tests. It’s kept apart from fairlead.yml so the schedule never starts a full test run, and it needs only read scopes: contents and actions, plus pull-requests and checks for replay fetch (a run’s pull request and its annotations). It sits on CI’s side, so the event log’s sections are empty there: the replay’s recall, misses and recurring tests carry the summary. Run fairlead insights locally for the event log. As with fairlead.yml, --write replaces only a file this command wrote.
Roadmap
| Milestone | Delivers | Status |
|---|---|---|
| K0 | Workspace, CI, release pipeline, installers, docs | Shipped in 0.1.0 |
| K1 | Config and the test plan (import graph, test classes, CI plan), replay and the benchmarks | Shipped in 0.2.0 |
| K2 | Guard rules, Claude Code hooks, git hooks, event log | Shipped in 0.4.0 |
| K3 | Receipts, workflows, done gates, the change brief (#45), CI report, Codex hooks | Partly shipped in 0.7.0: the brief, the done gate and Stop hook, the receipt, the CI report and escapes (0.6.0), and Codex and Gemini CLI hooks |
| K4 | Memory, search, skills and skill routing (#43), import | Shipped in 0.8.0 |
| K5 | MCP server, trackers, headless runner, token sources | Planned |
| K6 | Blueprints, readiness score, insights, 1.0 | Planned |
Alongside the milestones, any stack, any framework, any layout brings more of what Fairlead reads. Python, Go, PHP, Vue, Svelte and Astro components, external graph providers and coverage maps shipped in 0.5.0, and Java and Kotlin in 0.7.0; framework packs such as Laravel’s and folders of several repositories are next.
Each milestone is an issue on GitHub with its exit criteria.