Skills
A skill is a SKILL.md in the open format agents already read: a name, a description and the instructions. Fairlead doesn’t change the file. It routes it: [[skills.routes]] in fairlead.toml says which code each skill applies to, and the brief lists the skills a change reaches, the way the plan lists its tests. Since 0.8.0.
[skills]
imports = 1 # hops along what a changed file imports
importers = 0 # hops along the files that import it; off by default
cap = 8 # skills a brief lists before "N more"
[[skills.routes]]
skill = ".claude/skills/forms/SKILL.md"
paths = ["src/forms/**"]
[[skills.routes]]
skill = ".claude/skills/house-style/SKILL.md"
always = true
A route needs a scope: paths (globs), modules (module names) or always = true. Each skill is routed once. Fairlead reads only the skill’s name and description from its front matter, falling back to its directory’s name.
How a change picks its skills
Skills are picked by the same router as lessons:
- named: the scope covers a path the change names;
- used: the scope covers a file a named path imports, within
skills.importshops. The walk stops at agraph.barrier; - importer: only with
skills.importersabove 0, the scope covers a file that imports a named path; - always, then the fallback when the plan runs everything.
Imports are the default because a file needs the contracts it uses, and nothing imports a test, so a test’s skills are only reachable forward. On one project’s history, routing along imports caught slightly more of the rules a change needed than routing along importers did, and the two together caught no more than imports alone. Importers stay one setting away, for a repository whose history says otherwise.
The brief’s skills row lists skills.cap (8) skills, each with its description and why it was picked. A route whose SKILL.md can’t be read is named in a warning [bad-skill] line.
While the agent works
The PostToolUse hook fairlead hooks install adds does two more things when routes exist:
- After an edit, it names the skill whose scope covers the edited file, with its description, once per skill per session.
- When a skill is loaded, through Claude Code’s
Skilltool or a Gemini CLIread_fileof a SKILL.md, it records auseevent in.git/fairlead/events.jsonl. Each brief records anofferevent with what it listed. Together they give routing a hit rate: what was offered, what was used, and what was used without being offered;fairlead skills reportreads it.
Codex has no hook that sees a file being read, so a skill it loads isn’t seen; its use is unmeasured rather than counted as a miss.
Writing skills for each agent: skills sync
Agents look for skills in different places. fairlead skills sync writes each routed skill where each agent in skills.targets loads it, so a team keeps one SKILL.md per skill:
| Target | File | What it holds |
|---|---|---|
claude | .claude/skills/<name>/SKILL.md | the SKILL.md, for Claude Code |
agents | .agents/skills/<name>/SKILL.md | the SKILL.md, in the open cross-agent location Codex reads |
cursor | .cursor/rules/<name>.mdc | a Cursor rule: the skill’s description, the route’s globs and the SKILL.md’s body |
[skills]
targets = ["claude", "agents", "cursor"] # the default
<name> is the skill’s name. A skill that already lives in a target’s place is the copy that agent loads, so a SKILL.md under .claude/skills/ gets no second Claude Code copy. A copy keeps the SKILL.md byte for byte, front matter included, and adds one line after the opening ---:
---
# Generated by fairlead skills sync from skills/forms/SKILL.md; edit that file, not this one.
name: forms
description: How forms validate.
---
That header is a YAML comment, so the front matter still parses, and it’s how Fairlead knows the file is its own. A Cursor rule carries the same header. Its globs are the route’s paths, then <root>/** for each module in modules. An always route becomes alwaysApply: true. A route with only modules that don’t resolve gets no globs, so Cursor attaches it by its description.
fairlead skills syncwrites every target file and printswrote,unchangedorremovedfor each. A generated file whose route or target is gone is removed. A file at a target path without the header is someone’s own: it’s reported as aconflict, left alone, and the command exits 1.fairlead skills sync --checkwrites nothing and exits 1 when a file is missing, stale (hand-edited, or its SKILL.md changed) or orphaned, naming each, for CI.fairlead skills sync --cleanremoves every file with the header and nothing else.
Only SKILL.md is copied. Files beside it, such as scripts/ or references/, stay where they are, and sync lists each as not copied. A route whose SKILL.md can’t be read, a name that can’t be a file name, or two skills with the same name stop the sync before anything is written. fairlead doctor adds a skills sync line when the files have drifted.
Commit the generated files, so every agent finds them on a fresh clone, and run fairlead skills sync --check in CI.
Starting from rule files: import rules
A team that already scopes rules by path, in Claude Code’s .claude/rules/*.md (front matter paths) or Cursor’s .cursor/rules/*.mdc (globs, alwaysApply), can start from them:
fairlead import rules .claude/rules # dry run: prints each skill and the routes
fairlead import rules .claude/rules --write # writes them
Each rule file becomes .claude/skills/<name>/SKILL.md, named after the file (a file in a subdirectory gets the directory in its name), with name and description front matter and the rule’s body unchanged. The description is the rule’s own, or the opening of its body: sentences, without bold or italic markers, until they reach 40 characters, on one line of at most 200. Its scope becomes a [[skills.routes]] block: paths from paths or globs, given as a list or a comma-separated string, or always = true for alwaysApply: true. A Cursor glob without a slash, such as *.tsx, matches at any depth, so it’s written as **/*.tsx. A Claude Code rule with no paths is one Claude Code loads in every session, so it becomes always = true. A Cursor rule with no globs and no alwaysApply is one Cursor applies only on request, so it’s skipped with “no scope”.
Without --write nothing is written. With it, the skills are written and the route blocks are appended to the end of fairlead.toml under a comment naming the command; the existing text isn’t touched. A rule whose skill is already routed is left alone, so running it again changes nothing. A different file already at a skill’s path stops the run before anything is written. The rule files stay where they are, and their agents still load them: remove them once the skills replace them.
Measuring routing: skills eval
fairlead skills eval # the last 500 first-parent commits
fairlead skills eval --since 2026-01-01 --json
skills eval scores the router on the repository’s history. A commit that changes code and modifies a routed SKILL.md needed that skill; a SKILL.md the commit adds isn’t counted, since there was nothing to offer yet. For each such commit, the router runs on the commit’s other changed files four ways:
| method | imports | importers |
|---|---|---|
paths | 0 | 0 |
imports (the default) | 1 | 0 |
importers | 0 | 1 |
both | 1 | 1 |
Always routes count as offered. Each method reports the commits scored, recall (needed skills offered over needed), the commits with every needed skill offered, skills offered per change, and precision (needed skills offered over offered). Recall at caps 5, 6, 8 and 10 cuts the default method’s list where a brief with that skills.cap would, in the brief’s order. --json prints the same numbers.
The import graph is built once, from the working tree, so each commit is routed along today’s imports rather than its own; a file that has since moved or gone is still matched by path. Commits come first-parent from HEAD, newest first: --limit (500) of them, or every one since --since.
Measuring routing: skills report
fairlead skills report sets the offers against the uses, per session, so a team sees whether routing offers what agents load:
$ fairlead skills report --since 2026-10-01
skills report (since 2026-10-01): 4 sessions
skills: offered in 3 sessions, an offered skill used in 1 (hit rate 33%); 1 miss (used before any offer)
unmeasured: codex, whose hooks don't see a skill load; 1 session not counted as unused
1 offer with no session, not counted
skill offered used missed unused
forms 3 1 0 1
money 2 1 1 1
lessons: offered in 3 sessions; use of lessons isn't measured: there's no event for it
lesson offered
settle 2
tone 1
Each column counts sessions:
- offered: a brief or an edit nudge offered the skill;
- used: a
useevent named it; - missed: it was used before any offer of it in that session. An offer later in the session doesn’t turn a miss into a hit;
- unused: it was offered and never used.
The hit rate is the share of sessions with a skill offered in which an offered skill was used after its offer. A brief made with no session is counted apart, since no use can be set against it.
Lessons reach the agent in the brief’s text, and nothing records an agent reading one, so the report gives lessons’ offers only. Codex’s hooks see edits but not reads: a session whose events carry its apply_patch tool is listed as unmeasured, its offers count, and its skills are never counted as unused. With .codex/hooks.json installed, the report names Codex as unmeasured.
--since YYYY-MM-DD (a UTC day) and --session ID narrow the window, --all lists every row instead of the first ten, and --json prints the same numbers. With no events yet, the report says how they get recorded: briefs record offers, and the PostToolUse hook records uses.