Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 findsIt writes
vitest, jest, ava or mocha in package.json, or bun test or node --test in the test scripts, one npm run step deepthat 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 rootgo test {packages} on **/*_test.go
pyproject.toml, setup.py, setup.cfg, pytest.ini or tox.inipytest on **/test_*.py and **/*_test.py, through uv run or poetry run when their lockfile is there
composer.jsonphp 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:

  1. Built-in defaults, then any framework packs the files below name in extends.
  2. 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 the CI environment variable is set.
  3. The project file: fairlead.toml or fairlead.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.
  4. The environment’s shared file, fairlead.<name>.toml (or .yaml), when an environment is named. See Environments.
  5. The local file, fairlead.local.toml (or .yaml), next to the project file. Keep it out of git. It’s ignored when the CI environment variable is set.
  6. The environment’s local file, fairlead.<name>.local.toml, also out of git and ignored in CI.
  7. Environment variables: FAIRLEAD_ followed by the key path, with sections separated by a double underscore. So FAIRLEAD_TESTS__UNREACHED=all sets tests.unreached.
  8. --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

KeyDefaultMeaning
fairleadnoneThe 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_importstrueCount 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.cachetrueKeep 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_alllockfiles, root manifests, tsconfig, runner and CI configA 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.ignoreroot Markdown, the changesets tool’s folder (.changeset/**), docs/**, READMEs, changelogs, licencesA 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_days90How far back replay looks
replay.min_failures30Failures 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_ms40The 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.sizeofffiles, exclude, file_lines, function_lines, test_hooks, and ratchet (default true). See Guard rules
guard.test_namesofffiles, exclude, file, titles_without, title_calls. See Guard rules
guard.citationsofffiles, exclude, pattern (with a code group), headings_in. See Guard rules
guard.migrationsofffiles, 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.guardtrueWhether 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.nudgetrueWhether fairlead hooks install adds the PostToolUse note after an edit made with no brief
brief.resumetrueWhether 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_days90Days from added to the review_by a new lesson gets
memory.max_lines12The longest body a lesson may have
memory.cap5Lessons 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.imports1Hops along what a changed file imports, for skills and lessons alike. Since 0.8.0
skills.importers0Hops along the files that import a changed file; off by default
skills.cap8Skills 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.reusetrueAt merge, skip the ready stage’s steps when the pull request passed them on the same tree
ci.commentfalseWhether 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.commentsofffiles, 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

BlueprintDetected byAdds
laravel-apiartisanextends = ["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-reactvite.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 of fairlead.toml, and its tables after what init detected.
  • Checked first: the whole config is checked before fairlead.toml is written, so a blueprint whose config doesn’t load leaves nothing behind.
  • Files: a file that’s already there is kept unless --force is 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

  1. 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
    
  2. See what would change. Nothing is written yet:

    fairlead migrate
    
    migrate: 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)
    
  3. Read the review lines. 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, set brief.nudge = false first if you don’t want the nudge added.

  4. Write the changes:

    fairlead migrate --write
    

    If it moved Fairlead’s version in package.json, it says which command updates the lockfile (bun install, pnpm install, yarn install or npm install). Run that command; migrate never edits a lockfile.

  5. 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

WhatWhen it changesHow
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.jsonFairlead’s entries aren’t the ones this release’s hooks install would write for your configFairlead’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.ymlIt runs another command than this release writes, such as fairlead by name where the project has its own copyThe run: line is replaced in place, with its indentation, quotes and comments kept
The fairlead = "X.Y" floor in the project configThe 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.jsondependencies, devDependencies or optionalDependencies pins an older release, as 0.4.2, ^0.4.2, ~0.4.2 or >=0.4.2The 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 releaseBoth move to this release
Since 0.11.0: .github/workflows/fairlead-insights.ymlci 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 needsIt’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 latest alone.

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]] refuses stages = ["write"], because the file isn’t written yet when the write hook runs. Give such a rule commit or ci.
  • A plan’s config digest leaves out guard and hooks, 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 fairlead on the PATH.

For you to decide:

  • plan.ignore applies 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’s docs/**.
  • An owner rule whose match names no test file no longer counts as covering its paths, so they fall to tests.unreached. config check warns about each one; a rule meant to select nothing should be a plan.ignore entry instead.
  • key = [] over a list that already has items changes nothing, because lists append. { replace = [] } clears it, and config check warns where a layer does this.
  • A Go module’s go.mod and go.sum are graph edges instead of plan.run_all defaults, so a dependency bump selects that module and its importers. If your config lists them in plan.run_all itself, 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.ignore and reaches no test is ignored even when other files import it. In 0.5.0 it went to tests.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 install adds since 0.6.0. hooks install itself 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 done hasn’t passed for the tree it leaves (The done gate). Set done.on_stop = "off" before migrate --write to leave it out, or "require" to ask every time.
  • The nudge notes an edit made with no brief (The brief). Set brief.nudge = false to leave it out.

To 0.7.0

Migrate updates:

  • The config’s fairlead floor, 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 migrate refreshes 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 fairlead floor, to 0.7.1, where a runner uses all_command or exclude_arg.

For you to decide:

  • A runner whose tool, given no files, finds more tests than its match claims (a bare bun test that also finds end-to-end specs) can set all_command for the run of everything. A runner holding a test in [[quarantine]] can set exclude_arg so that run stays whole (Configuration).

To 0.8.0

Migrate updates:

  • Claude Code hooks installed before 0.8.0 get Skill in the edit hook’s matcher, so a skill loaded through the Skill tool counts as a use, and a SessionStart hook that runs fairlead resume --hook. Set brief.resume = false before migrate --write to leave that one out (Context and resume).
  • The config’s fairlead floor, to 0.8, where the config uses [memory], [skills], [agents] or brief.resume.

For you to decide:

  • Lessons, skill routes and the AGENTS.md block stay off until a repository adds them. fairlead import lessons turns a lessons document a team already keeps into lesson files, and fairlead import rules turns path-scoped rule files into routed skills (Lessons, Skills).
  • CI stages stay off until the config sets [stages] or a runner’s or check’s from. Then ci plan reads the stage from the GitHub event, a merge skips what its pull request passed on the same tree, and fairlead ci workflow writes a staged workflow or prints the if: 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.toml plans with the new config, and a workflow that only runs by hand or on a schedule selects nothing by itself (The test plan).
  • migrate refreshes Claude Code’s hooks only. Gemini CLI hooks installed by 0.7 don’t match read_file, so reading a SKILL.md there isn’t counted as a use until fairlead hooks uninstall --gemini and hooks install --gemini write this release’s.

To 0.12.0

Migrate updates nothing in your files.

For you to decide:

  • The next pack’s end-to-end rule, every spec that calls page.goto( reaching every route and middleware, is its own pack now. Add "next-e2e" to extends to 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_command keeps the tool’s own discovery (Planning, #210).
  • Under Bun, "trustedDependencies": ["fairlead"] has bun install download the binary instead of the first command (Install).

To 0.11.0

Migrate updates:

  • .github/workflows/fairlead-insights.yml, when ci workflow --insights wrote it: it’s rewritten with the pull-requests and checks read scopes replay fetch needs (#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, insights and tokens report may show more than before.
  • A Next.js app can add extends = ["next"], which fairlead init now 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 recurring and leaves adjusted recall, so a gate can pass that failed before. Confirm the [[replay.quarantine]] entries it lists (Recurring failures).
  • fairlead doctor --score rates the setup out of 100 and names the three fixes worth the most (Readiness score); fairlead ci workflow --insights --write adds the weekly summary (Insights).
  • fairlead mcp serves read-only tools to an agent (MCP server), [tracker] puts a branch’s task in the brief (Trackers), and ci.escapes = "file" opens an issue per escape (CI).

To 0.9.0

Migrate updates:

  • Codex hooks in .codex/hooks.json and Gemini CLI hooks in .gemini/settings.json, which migrate now refreshes as it does Claude Code’s. Gemini CLI hooks from 0.7 get read_file in 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 schedule to its replay fetch, so replay counts what the nightly caught and no push ran (Scheduled runs).
  • fairlead graph why names edge kinds in words (type import, path literal) where it printed typeimport and pathliteral; 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

EdgeFrom
importimport, export … from, import x = require(…)
type importimport type and export type … from; off with graph.type_imports = false
dynamicimport("…") with a plain string
requirerequire("…") and require.resolve("…")
mockvi.mock, jest.mock and their relatives, naming a module
path literalany 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
rulea [[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.

PackWhat it adds
laraveltests/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.
nextSince 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-e2eSince 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 trait use, an attribute or instanceof, and each name it imports with use;
  • 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 include or require names with a literal: 'x.php', __DIR__ . '/x.php' or dirname(__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 .go file in its directory, the package’s own and its other test files, since they’re compiled together;
  • a //go:embed pattern 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.b names, and the package __init__.py files above it, which importing it runs;
  • for from a.b import c, the module a.b.c when there is one, else a.b, whose name c is; from a.b import * names a.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 import names, or the class that holds a nested class or a static member it names (import static a.b.Money.format is a.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, where from depends on to. The shape is a public contract, committed as provider-v1.schema.json.
  • Claims: a file a provider’s files match 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, and graph stats reports 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 a provider-failed warning naming the provider and why, as other uncertainty does.
  • graph stats lists each provider with its files and edges, the built-in scanners as typescript, php, go and python.

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 as coverage-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_days leaves a coverage-stale warning on the plan, naming its date and commit; one that can’t be read leaves coverage-unreadable, and the plan uses the static graph alone. Neither fails the plan. A change to the map itself selects nothing.
  • graph stats reports 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 under rootDir, 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 extends something that can’t be read (a shared config published as a package, before install) is skipped for that file, and counted in graph 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:

RepositoryColdWarm
Effect-TS/effect1.19 s0.07 s
pnpm/pnpm1.02 s0.26 s
vitest-dev/vitest0.41 s0.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

  1. Changed paths. git diff --name-status --find-renames from the base to the working tree, plus untracked files. A rename counts both paths; its old path counts as deleted.
  2. Run everything. A changed path matching plan.run_all selects every test and check. The defaults are lockfiles, root manifests, tsconfig files, test-runner and task-runner config, and CI workflows. With plan.lockfile = "scope" (opt-in for now), a root pnpm-lock.yaml is 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.
  3. Ignored paths. A changed path matching plan.ignore still selects every test it reaches through the files that depend on it. When it reaches none, it’s listed under ignored and selects nothing, where it would otherwise fall to tests.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.
  4. 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.
  5. Package manifests. A changed <package>/package.json counts as every file in that package changing.
  6. 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.
  7. 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-hoist or a public-hoist-pattern in .npmrc or pnpm-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, snapshots and catalogs changed, such as overrides, patchedDependencies or settings;
  • 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 a workspace:, 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 no bun.lock at 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 as vitest.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 a package.json or a lockfile) or the scripts of the root or a changed package.json holds 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 a preset: '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.

ClassSelected when
unitit changed, it depends on a changed file, or an owner rule claims it
ownit changed, or an owner rule claims it
demandit changed (never under run_all)
canaryevery 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.unreachedSelects
module (default)its module’s tests; everything when it’s at the root or its module has no tests
alleverything
warnnothing

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, in cwd with {module} (the module’s root) and {module.id} (its name) filled in, with files relative to that directory.
  • Under run_all or 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 with all_command runs that instead of command, for a tool that, given no files, finds more tests than the runner’s match claims (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, as bun test loads Playwright specs; a runner with all_command still 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 plan lists 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 in quarantined, and the invocation that runs it carries its name in quarantined.
  • 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_arg keeps its whole run (its all_command, or command with 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 that match doesn’t claim and can make a long command line, so the plan warns quarantine-narrowed-everything. A runner whose command has no {files} can’t run one test alone, so the plan warns quarantine-not-separable and the test runs with the rest.
  • fairlead done and fairlead ci run don’t count a failure whose output matches signature. 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 or go test), the run counts. A runner whose output Fairlead can’t read is judged by signature alone.
  • 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 warns quarantine-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, else worktree: and a hash of every file’s path and blob id.
  • head: HEAD’s commit when the working tree is clean, else worktree.
  • base, all, changed, ignored, tests, checks, invocations, unreached and warnings as 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 its target (the test file or check id), kind, what made it hold (here), signature, reason, proved_in and until.

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

FlagDefault
--base SHAthe remote’s default branchThe merge base of this and HEAD is what changes are measured from.
--head SHAnoneRefuses to plan if HEAD is another commit. actions/checkout checks out the merge commit on pull_request unless you pass ref.
--format json|githubjsongithub also writes step outputs.
--out PATHfairlead-plan.json in $RUNNER_TEMP or the system temp directoryNever the working tree, where the file would count as a change. A relative path is from the current directory.
--set KEY=VALUEOverride a config value for this run.
--stage auto|none|draft|ready|merge|fullauto for a config that uses stages, else noneSince 0.8.0: the stage to plan for. none plans as before stages existed.
--since-green JOBnoneSince 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

OutputValue
alltrue when everything is selected
planthe path to the plan JSON
plan_idthe plan’s id
invocationsthe invocations as a JSON array; fromJSON makes it a job matrix
checksthe selected check ids, space-separated
testshow many test files are selected
stageSince 0.8.0: the plan’s stage, empty without one
reusedSince 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:

EventStage
pull_request or pull_request_target, a draftdraft
pull_request or pull_request_target, not a draft (any action, ready_for_review included)ready
a pull request labelled stages.full_labelfull
push to a branch in stages.environments (a name or a glob such as release/*)merge
merge_groupmerge
push to any other branch, a tag, or another eventready
schedule or workflow_dispatchfull

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 run has run every step of the plan and all of them passed, it sets the commit status fairlead/tree on the pull request’s head commit, saying which tree it tested and the config digest it planned with. That needs statuses: write on 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 plan finds 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’s reused names the pull request and the steps, and the run says reuse: #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 --only or --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:

WarningWhen
since-green-unavailableno token, not a GitHub Actions run, or the API failed or was rate-limited
since-green-not-foundnone of the last 100 runs on the branch has the job passing
since-green-not-in-historythe 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), push to stages.environments, and workflow_dispatch.
  • plan: checks out the whole history, installs Fairlead with the action at this release, runs fairlead ci plan --format github with --since-green for each job of its own, and uploads the plan as the fairlead-plan artifact. Its outputs are stage and every run_<id>.
  • checks: runs the plan with --except for 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 from is merge or full, here e2e and desktop-build: it runs only when needs.plan.outputs.run_<id> == 'true', with fairlead ci run --only <id>. Its concurrency group is per workflow, job and branch, with cancel-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: read at the top and in each job; plan adds actions: read for --since-green. With stages.reuse, plan also gets pull-requests: read and statuses: read to find the tree a merge’s pull request passed, and checks gets statuses: write to record it. Every action is pinned to a commit, and no github.event text is written into a run: 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 rule replay report suggests 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, as fairlead receipt --out wrote 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}"]
KeyDefaultWhat 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.namethe folder’s nameWhat --repo and the plan call it. Two repositories with one name is an error.
workspace.repos.basethe repository’s remote default branchThe 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.
  • --base overrides every repository’s base for one run, and --repo NAME plans only that one.
  • --files takes paths from where the command runs; each goes to the repository holding it, and a repository given none is unchanged.
  • --json prints {"repos": [{"name", "path", "plan", "reached"}], "unchanged": [...]}, where each plan is a repository’s plan exactly as fairlead plan --json prints it inside the repository, and path is 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.

StageCommandReadsFails on
writefairlead guard hook, run by Claude Codethe file an edit would leave, before it’s writtena finding the edit adds; see The write hook
checkfairlead guard checkevery tracked file, as it is on diskany zero-tolerance finding, or a ratcheted count above the baseline
commitfairlead guard check --stagedeach staged file, at HEAD and in the indexa 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.

RulePresetFinds
file-lengthguard.sizeA file over file_lines lines. A trailing newline doesn’t start a line.
function-lengthguard.sizeA function over function_lines of its own lines.
block-lengthguard.commentsA comment block longer than its context allows.
densityguard.commentsComment lines over a share of comment and code lines.
historyguard.commentsA date, a name, a narrative phrase, or a measurement beside “measured”.
item-codeguard.commentsA ticket or item reference outside the pointer form.
agent-instructionguard.commentsA comment that addresses its next editor.
block-markerguard.commentsA continuation line of a multi-line /* */ that doesn’t start with *.
test-file-nameguard.test_namesA test file whose name doesn’t match file.
test-titleguard.test_namesA test title that matches titles_without.
citationguard.citationsA pointer in a comment that names no heading in headings_in.
migration-editguard.migrationsA change, move or deletion of a migration that exists at the base.
migration-prefixguard.migrationsTwo migrations with the same leading number.
your idguard.externalWhatever 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, then source.
  • 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 \b and \d are 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:

HookRunsDoes
PreToolUsefairlead guard hookThe write stage below: denies an edit that breaks a rule, or adds a note
PostToolUsefairlead guard nudgeOnce 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)
Stopfairlead guard stopSends the agent back while the tree it leaves hasn’t passed fairlead done (The Stop hook); off with done.on_stop = "off"
SessionStartfairlead resume --hookSince 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 B shows one chain.
  • tests and checks: what the plan for these paths selects, with each test’s first reason. It’s fairlead plan --files run ahead of the change, and fairlead test --explain FILE explains any one of them.
  • rules: the [guard.*] tables whose rules read a named path, such as size, comments or migrations. 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 a warning line. 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 done will run, the same list as fairlead 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
KeyDefaultWhat it does
kindnoneWhere tasks live (below).
id[0-9]+ for github, none otherwiseA regex every task id must match whole before Fairlead uses it. Required for command and agent.
branchnoneA 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.
timeout20How long one tracker call may take, in seconds (1 to 120).

Kinds

  • command: the team’s own command. Fairlead runs get with the id as its last argument and reads one JSON object from its output: id, title, and optionally url, status and done_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 that origin points at (or GITHUB_REPOSITORY), read with GITHUB_TOKEN or GH_TOKEN when 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_when at 1,000;
  • only https:// links are kept;
  • the brief quotes the title and labels done_when as 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, or search: true for a lesson that names no code: fairlead find turns 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, and confirmed_by names 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_lines lines (12). Anything longer is a doc, and the lesson links to it.
  • Review: review_by is added plus memory.review_days (90). Past it, the lesson is still offered, marked due, and fairlead lessons review lists 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:

  1. named: a path the change names;
  2. 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;
  3. always: an always lesson, for every change;
  4. 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 is search: true: fairlead find turns 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 it paths to 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 blame gives the heading’s line, in UTC, or today when the document isn’t committed. An imported lesson has no review_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 with Full 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:

  1. named: the scope covers a path the change names;
  2. used: the scope covers a file a named path imports, within skills.imports hops. The walk stops at a graph.barrier;
  3. importer: only with skills.importers above 0, the scope covers a file that imports a named path;
  4. 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 Skill tool or a Gemini CLI read_file of a SKILL.md, it records a use event in .git/fairlead/events.jsonl. Each brief records an offer event 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 report reads 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:

TargetFileWhat it holds
claude.claude/skills/<name>/SKILL.mdthe SKILL.md, for Claude Code
agents.agents/skills/<name>/SKILL.mdthe SKILL.md, in the open cross-agent location Codex reads
cursor.cursor/rules/<name>.mdca 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 sync writes every target file and prints wrote, unchanged or removed for 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 a conflict, left alone, and the command exits 1.
  • fairlead skills sync --check writes 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 --clean removes 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:

methodimportsimporters
paths00
imports (the default)10
importers01
both11

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 use event 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.

KindNameTextPath
lessonits titleits id and bodythe lesson file
skillits nameits descriptionthe SKILL.md
docthe headingthe first paragraph of its sectionthe file and the heading’s #anchor
symbolthe qualified namethe 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:

DistanceMultiplier
a path the brief names2
11.5
21.33
31.25
further, or no brief1

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 to N hits, 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 of CLAUDE_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, find and learn, and when to open the pull request as a draft and mark it ready, as next says.
  • Lessons for every change: the lessons with always: true, as title (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.md up 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 done run for the tree as it is now, as the receipt reads it.
  • lessons: lesson files under memory.dir added since the base.
  • next: the step fairlead next would 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

ToolRunsReturns
planplan --jsonthe plan, as in the plan’s schema
explaintest --explaintext: why a test or check is in the plan, or why not
briefbrief --jsonthe brief for the paths a change will touch
contextcontext --jsonthe brief, plus what to read around the paths
findfind --jsonhits from lessons, skills, headings and declared names
receiptreceipt --jsonwhat the change did against its brief, and the done gate
nextnexttext: the next step before the change is done
lessonslessons list --jsonthe lessons, with scope and review dates
skills_reportskills report --jsonskill 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 fairlead process, 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 no structuredContent.
  • 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 mcp to the event log, with the tool’s name, the session when given, and ok, refused or error.
  • 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/list and prompts/list are 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:

  1. The test invocations the change’s plan selects, as fairlead ci run would run them.
  2. The [[checks]] the plan selects.
  3. The checks done.always names that the plan didn’t select. Run outside a plan, a check’s {files} stands for the change’s files.
  4. fairlead guard check over 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 stop event 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:

Statenext
Nothing has changednothing due
Changed, no brief for the sessionfairlead brief <paths>
Briefed, the gate hasn’t passed for this treefairlead done, and gh pr create --draft if no pull request is open yet
The gate failedthe failing step’s command, then fairlead done
The gate passed, no receipt for this treefairlead receipt, then gh pr ready
The gate passed and the receipt is writtengh 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 .github exit-code note left out) and the log lines around each FAIL, ● or bun (fail), with the bun file header each failure sits under, each PHPUnit failure or Pest FAILED line with the stack frames after it and each ⨯ line under a FAIL header, under go test -v the lines a failed test logged before its --- FAIL, and Surefire’s <<< ERROR! lines and closing lists, the Gradle task header each FAILED line 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 --clone it’s left for replay run to read. A scheduled run records neither: its base is the last green scheduled run, which replay run finds among the rows.
  • With --clone, each run’s head commit is fetched into the clone, so it’s there when replay run needs it.
  • Rows already in the dataset are skipped before any of their jobs are fetched, so a weekly fetch is incremental. --limit N stops 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-After GitHub 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), or fetch stopped with 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:

OutcomeMeaning
hitthe test file or check is in the plan, or the plan selects everything
missit isn’t, and nothing below explains it; recall is hits over hits and misses
flakyanother attempt of the same run passed the same job; decided before hit, so a flaky failure never raises recall
unconfirmeda 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)
unattributedthe job failed, but no test file or check could be named from its annotations or log
unavailablethe head commit, or the history to its merge base, isn’t in the clone
errorthe planner refused the commit, such as a test file no runner matches
unwatchedno [[replay.failures]] or [[replay.checks]] entry names the job; listed by job name so a gap in the config can’t raise recall
quarantineda [[replay.quarantine]] entry declares the test flaky in this job (below)
environmentthe job failed alike across unrelated pull requests once its runner image changed (below)
inheritedthe 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)
recurringthe test failed in the job across three or more unrelated pull requests on different bases, none of them touching it (below)
ignoredreplay.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 whose Tests run: … <<< FAILURE! -- in com.acme.FooTest line names no test, such as one that failed in @BeforeAll. The closing [ERROR] Failures: and Errors: 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:test header the line sits under, which Gradle’s grouped output prints again whenever another task’s output follows, so --parallel builds 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 whose There were failing tests report follows no FAILED line, as under --quiet, is unattributed. A task that compiles tests, such as compileTestKotlin or compileTestJava, fails on the file that doesn’t compile: its e: file.kt:44:13 or File.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 push whose 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:

OutcomeWhen
hitthe plan from P to C selects it: a push’s change reaches it
escapethe plan leaves it out, so no push in P..C ran it
unconfirmeda 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)
unavailableno 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-data branch (or main), 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 to bench-data, recreated from main each time, then opens a pull request from it if none is open. With a GitHub App configured (the BENCH_APP_CLIENT_ID variable and BENCH_APP_PRIVATE_KEY secret, 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 limit attempts per repository and the next carries on. A BENCH_READ_TOKEN secret, 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

ItemPointsEarned when
config10a project file loads with no problems
runners15there’s a runner, and every test file has exactly one
hooks10Claude Code’s hooks are installed, and Codex’s or Gemini CLI’s where .codex/ or .gemini/ exists; split between them
done10fairlead done passed in the last 30 days
ci105 for a workflow that runs fairlead ci (or the action), 5 more with [stages]
replay15a replay report whose window ends in the last 30 days and met its min_failures gate
recall15that report’s recall is 95% or more, and its median plan selected less than everything
firing5the write hook checked a write in the last 7 days
skills5there’s a routed skill, and every SKILL.md has a route
lessons5there’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.

MeasureValue
Runs replayed498
Attributed failures (gate 30)1436 (met)
Recall99.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, checks458, 703, 263
Misses12
Flaky, unconfirmed, unattributed60, 1, 36
Unavailable, errors, ignored0, 0, 3
Test files selected: median, p906.2%, 100.0%
Plans that selected everything19.9%
Plan time: first (cold), median, p901.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.ts in 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)
  • packages/sql/mysql2/test/KeyValueStore.test.ts in 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)
  • packages/sql/d1/test/Resolver.test.ts in 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)

Plans that selected everything, by cause:

  • unreached packages/**: 23
  • run-all tsconfig.base.json: 18
  • run-all package.json: 15
  • run-all tsconfig.packages.json: 13
  • run-all .github/**: 10
  • run-all packages/**: 5
  • unreached .agents/**: 4
  • run-all pnpm-workspace.yaml: 2
  • run-all vitest.config.ts: 2
  • unreached .vscode/**: 2

Failed jobs no rule watches:

  • AI Documentation Generation: 3
  • Circular Dependencies: 4
  • Test 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, changed packages/effect/test/SubscriptionRef.test.ts
  • run 30899876531 attempt 1: packages/platform-node/test/NodeRedis.integration.test.ts, changed packages/effect/test/unstable/cli/Help.test.ts
  • run 30899876531 attempt 1: packages/sql/libsql/test/Client.integration.test.ts, changed packages/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, changed packages/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.

MeasureValue
Runs replayed444
Attributed failures (gate 30)470 (met)
Recall100.0%
Strict recall (unconfirmed as misses)100.0%
Hits: selected, run everything, checks94, 376, 0
Misses0
Flaky, unconfirmed, unattributed11, 0, 241
Unavailable, errors, ignored0, 0, 485
Test files selected: median, p90100.0%, 100.0%
Plans that selected everything83.2%
Plan time: first (cold), median, p901.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/**: 146
  • run-all pnpm-lock.yaml: 86
  • unreached Cargo.lock: 31
  • run-all .github/**: 27
  • unreached pacquet/**: 19
  • unreached .gitignore: 14
  • unreached cspell.json: 10
  • run-all package.json: 9
  • run-all pnpm-workspace.yaml: 3
  • unreached .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.

MeasureValue
Runs replayed1093
Attributed failures (gate 30)693 (met)
Recall99.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, checks552, 56, 80
Misses5
Flaky, unconfirmed, unattributed11, 2, 2635
Unavailable, errors, ignored0, 0, 1593
Test files selected: median, p9098.7%, 100.0%
Plans that selected everything29.1%
Plan time: first (cold), median, p907.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.ts in 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

Plans that selected everything, by cause:

  • run-all .github/**: 158
  • run-all package.json: 66
  • run-all pnpm-lock.yaml: 43
  • run-all pnpm-workspace.yaml: 15
  • run-all test/**: 8
  • unreached .dockerignore: 8
  • unreached eslint.config.js: 4
  • unreached knip.jsonc: 2
  • unreached test/**: 2
  • unreached .github/**: 1

Failed jobs no rule watches:

  • Browser: chromium, macos-latest: 1
  • Browser: chromium, windows-latest: 1
  • lint: 1
  • test (macos-14, 20): 1
  • test (ubuntu-latest, 18): 1
  • test (ubuntu-latest, 20): 1
  • test (ubuntu-latest, 22): 1
  • test (windows-latest, 20): 1
  • test-browser (chrome, chromium): 1
  • test-browser (edge, webkit): 1
  • test-browser (firefox, firefox): 1
  • test-browser-windows (chrome, chromium): 1
  • test-browser-windows (edge, webkit): 1
  • test-ui: 1
  • test-ui-e2e (macos-14): 1
  • test-ui-e2e (ubuntu-latest): 1
  • test-ui-e2e (windows-latest): 1

Misses:

  • run 34299387859 attempt 1: test/e2e/test/open-telemetry.test.ts, changed packages/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, changed packages/ui/client/components/BrowserIframe.vue, packages/ui/client/styles/main.css
  • run 34956251133 attempt 1: test/browser/specs/oxc.test.ts, changed packages/ui/client/pages/index.vue, packages/ui/client/styles/main.css
  • run 35604155400 attempt 1: test/unit/test/expect-poll.test.ts, changed test/unit/test/jest-expect.test.ts
  • run 37770032024 attempt 1: test/browser/specs/server-options-inline.test.ts, changed test/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

SourceHow it’s readCost
transcriptThe Stop hook reads the session’s transcript, from the transcript_path Claude Code passes it.yes
headlessfairlead 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

SectionFrom
guardthe write hook’s decisions and denies by rule, the same counts fairlead doctor shows
donefairlead done runs passed and failed, and the steps that failed
skillsskill routing’s hit rate, with the skills offered and unused, and those used without an offer (as fairlead skills report counts them)
lessonslessons no brief has offered in the window
tokenssessions and their cost, from the newest record of each (Tokens and cost)
replaywith --replay, a replay run --json-out report’s recall, misses and recurring tests
escapeswith --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:

SuggestionEvidence--write
an [[tests.owners]] ruletwo or more escapes or replay misses needed the same ruleno: add it, then replay to see what it costs in plan size
a [[replay.quarantine]] entrya recurring test in the replay reportno: a person confirms it’s flaky and gives the reason
drop or narrow a skill routeoffered in ten measured sessions and never usedno
guard.on_finding = "deny"twenty warned writes, and no finding reached a commityes

--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:

  1. Replays the last 30 days of the fairlead.yml workflow’s runs.
  2. Runs fairlead insights --since seven days ago, with that replay report and --suggest.
  3. 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

MilestoneDeliversStatus
K0Workspace, CI, release pipeline, installers, docsShipped in 0.1.0
K1Config and the test plan (import graph, test classes, CI plan), replay and the benchmarksShipped in 0.2.0
K2Guard rules, Claude Code hooks, git hooks, event logShipped in 0.4.0
K3Receipts, workflows, done gates, the change brief (#45), CI report, Codex hooksPartly 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
K4Memory, search, skills and skill routing (#43), importShipped in 0.8.0
K5MCP server, trackers, headless runner, token sourcesPlanned
K6Blueprints, readiness score, insights, 1.0Planned

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.