NEW Ownership, but make it YAML

Who owns this code?

Finally answerable. By a person, by a review bot, or by the agent that opened fourteen pull requests while you were at lunch.

$ uvx owners-yaml who src/that/file.py
Read the spec (it has RFC 2119 keywords)
No signup. No dashboard. No AI at runtime, it just reads YAML. Plenty of AI in the commit history, and we are not going to pretend otherwise.
zsh, the obligatory hero terminal
# billing/owners.yaml
version: 1
owners: [team-billing, '@alice']

$ owners who billing/api/invoices.py
path:    billing/api/invoices.py
owners:  team-billing, @alice
status:  active
slack:   #team-billing
source:  billing/owners.yaml
Trusted by companies we made up for this section
Blamr YAMLworks Mergely Flakebase Monolith & Sons PostHog (real, about 30 teams)

Everything you need. Nothing on a blockchain.

Six features in a three-column grid, as the law of landing pages requires.

Blazingly nearest
Between files, the nearest one wins, field by field. A child that only sets owners keeps its parent's status. inherit: false cuts the cord. Inside one file, the last matching rule wins.
Rules that stay in their lane
A rule only reaches paths below its own file. A pattern added at the bottom of one giant file can no longer adopt half the repo.
Unowned, on purpose
owners: null says nobody owns this, and we meant it. Everything else without an owner shows up in owners unowned, in front of everyone.
A status field for robots
generated, vendored, deprecated. Your review bot skips the lockfile without keeping its own secret ignore list.
Knows where your team lives
One registry maps a team to its Slack channels, with a separate channel for automation and an opt-out per bot.
One resolver, three doors
A Python library, a CLI, and a JSON entrypoint that needs only PyYAML. Nobody reimplements the resolution steps in a bash script again.
Get started in 6 easy steps

You only need the first two

No “book a demo”. Run it, put a file next to the code, ask. The other four steps cover settings, bots, CI, and your own tools.

1
Run it. Installing is optional.
uvx fetches the package from PyPI, so a laptop or a CI runner needs only uv.
# run once, nothing installed
$ uvx owners-yaml --help
# careful: "uvx owners" is a different PyPI package.
# always spell it uvx owners-yaml.

# or keep it around
$ uv tool install owners-yaml
$ pipx install owners-yaml   # also fine

# two identical commands
$ owners --version
$ owners-yaml --version
2
Put a file next to the code.
The smallest file sets a version and the owners. Teams are bare slugs, people are @handles.
# billing/owners.yaml
version: 1
owners: [team-billing, '@alice']

# rules change paths below this file only.
# inside this file, the last matching rule wins.
rules:
  - match: 'generated/**'
    status: generated
  - match: 'vendor/'
    owners: null      # nobody, on purpose
# then ask who owns a path
$ owners who billing/api/invoices.py
owners:  team-billing, @alice
Show the other 4 stepsHide the other 4 steps
3
Tell the root file about your repo.
Settings live only in the root owners.yaml. None of them is required. A team that is not listed still works, and its channel is derived from its slug.
# owners.yaml (the root file)
# format version. always 1.
version: 1
# repo-wide default owner. empty = none.
owners: []
# where lint --live looks up teams and @handles
github_org: acme
# bot names that teams may mention below
producers: [review-bot]
# other file names that count as ownership files.
# defaults to [product.yaml]. [] turns that off.
alias_files: []
# optional. only needed for channels.
teams:
  team-billing:
    slack: '#billing'               # people
    notifications: '#billing-bots'  # bots
4
Ask, do not read.
Resolution has more steps than a CODEOWNERS lookup, so ask the resolver.
$ owners who billing/api/invoices.py
owners:  team-billing, @alice
source:  billing/owners.yaml

# many paths, as JSON, args or stdin
$ git diff --name-only main \
    | owners resolve --json

# the JSON shape a bot gets
{
  "billing/api/invoices.py": {
    "owners": ["team-billing", "@alice"],
    "slack": "#billing",
    "source": "billing/owners.yaml",
    "status": "active"
  }
}

# the channel where bots should post
$ owners resolve --purpose notifications \
    billing/api/invoices.py
billing/api/invoices.py  team-billing, @alice  active  #billing-bots

# what nobody owns, and nobody decided that
$ owners unowned
5
Lint it in CI.
Pin the version. A new release can change how a path resolves.
# .github/workflows/owners.yml
on: [pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v10.2.0
      - run: uvx owners-yaml==0.2.0 lint
# also check slugs and handles against GitHub
$ owners lint --live
# plain lint does not know which teams exist.
# --live asks GitHub through the gh CLI, which must be
# signed in and allowed to read the org's teams.

# see where files could merge or split. never writes.
$ owners fmt

# run lint before you trust who: a file that does not
# parse is skipped, and the parent answers instead.
6
Call it from your own tools.
A Python library, and a JSON entrypoint for every other language.
from owners_yaml import OwnersResolver

resolution = OwnersResolver().resolve(
    "billing/api/invoices.py"
)
resolution.owners  # ['team-billing', '@alice']
# any language: paths in, one JSON object out
$ echo web/app.ts | \
    uvx --from owners-yaml==0.2.0 \
    python -m owners_yaml --repo-root .
Bonus level
Resolve without a checkout
A server that routes alerts has no worktree. Give the resolver anything with a read(path) method: a repository API, an unpacked archive, a dict in a test. read returns None for a missing file. Add read_all(paths) and map() fetches the whole batch before it reads anything.
Some tools read only CODEOWNERS. For test analytics, owners codeowners -o CODEOWNERS.generated writes the owner of every test file in that syntax. owners census counts test files per owning team.
from owners_yaml import OwnersResolver


class ApiSource:
    def read(self, path: str) -> str | None:
        # your repository API
        return fetch_file_or_none(path)

    def read_all(self, paths: list[str]) -> None:
        # optional: one batch, not N requests
        prefetch(paths)


resolver = OwnersResolver(source=ApiSource())
resolver.map(["billing/api/invoices.py", "web/app.ts"])
For the robots in your repo

We are contractually required to say “agentic” once.

CODEOWNERS answers one question for one consumer: who must approve this pull request on GitHub. The bots around your repo have other questions, and they ask them at three in the morning.

The comparison table where we win every row

One CODEOWNERS file
owners.yaml files
Who wins a conflict
The last line. Good luck.
The nearest file
Merge conflicts
A team sport
Your file, your problem
Lint
Vibes
Dead globs, broken files, coverage. Real teams with --live
Anything besides owners
A comment, if you are lucky
Status, Slack channels, bot opt-outs
Required approvals on GitHub
Yes, and it is good at it
Keep CODEOWNERS for that. We do not mind.
The last row is us being fair. It felt strange.

Loved by things that cannot love

“For years people asked me who owns a file. I only know who touched it last. It was always the person who ran the formatter.”
git blame, reluctant oracle
“I used to request review from eleven people and hope. Now I request it from the right two. They still ignore me, but accurately.”
A review bot
“Nobody has read me since the reorg. I matched everything below me and I regret nothing.”
A wildcard near the bottom of CODEOWNERS
“I was asked whether everything was correct. I said yes. Then I checked. I would like to retract the first answer and keep the second.”
The agent that wrote this package, now humbler

Questions a sensible person asks

How do I migrate my CODEOWNERS file?
By hand. There is no importer. Start with one owners.yaml per top-level directory, run owners unowned, and work down from there. It took us an afternoon per team, mostly arguing.
What happens to required reviews on GitHub?
Nothing. GitHub still reads .github/CODEOWNERS, and this tool never writes that file. Keep it for approvals. owners codeowners only exports test-file owners, for test analytics.
Where do team names come from?
A team is the bare slug of a GitHub team in github_org. A person is an @handle. lint --live checks that both exist. The teams: map is optional and only adds channels.
What does version: 1 mean?
The version of the file format. Every file carries it, and 1 is the only version there is. It lets a future format change fail loudly and not quietly.
What is producers for?
It lists the bots that teams may name in their settings. lint rejects a bot name that is not on the list, so a typo does not silently route alerts nowhere.
Can each bot get its own channel?
Yes, notifications also takes a map of bot name to channel, and false mutes one bot. Today only the Python API reads that form: team_channel(slug, teams, "notifications", producer="review-bot"). The CLI reads the plain string.
Full disclosure

Agent-engineered. Supervised by seniors who know what they are doing.

An AI typed most of this package. A person with opinions read it, argued with it, and sent it back. Several times. Both of them would like you to know that.

A spec, so the agent cannot improvise
The format is written down with RFC 2119 keywords. When the agent gets creative, the spec says MUST NOT and the argument is over.
About 100 conformance cases
We do not trust it either. Every resolution rule has a case that fails loudly, in any language, before a human has to notice.
Reviewed by bots, then by people
Several review bots read every diff. They are also AI. It is AI most of the way down, and then there is a tired human with merge rights.
Do not like that? Do not use it.
It is a small MIT-licensed package that reads YAML files. You can read all of it before your coffee gets cold. You will be fine.
how the code review actually went
agent> Done. Everything is correct.
human> So you think all of this is right,
       and you verified nothing?
agent> ...
agent> I will now verify things.
fix: three things that were not correct
human> Why is this hardcoded?
agent> It is a sensible default.
human> It is my company name.
fix: move PostHog out of the general tool
# repeat until the spec and the human agree

Simple, transparent pricing

Hobby
$0
It reads YAML files. MIT license. You already have the whole product.
Enterprise
$0
Same package, but we say “governance” while you install it. SSO not included, because there is nothing to log in to.