Metadata-Version: 2.4
Name: pairgate
Version: 0.1.0
Summary: Lead/review agent-pair ownership ledger and commit-bound merge gate for GitLab
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://gitlab.com/pairgate/pairgate
Project-URL: Repository, https://gitlab.com/pairgate/pairgate
Project-URL: Issues, https://gitlab.com/pairgate/pairgate/-/issues
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

<!-- SPDX-License-Identifier: GPL-3.0-or-later -->

# pairgate

pairgate runs two coding agents as a pair on one GitLab issue. One agent
implements (the **lead**) and the other reviews (the **review**). Its two
parts:

- **an ownership ledger.** The ledger lives in the issue's notes. Each
  `claim`, `handoff`, `accept` and `approve` appends a note, and every reader
  folds those notes into one answer: who holds the issue, what has been
  offered to whom, and which commit was approved.
- **a merge gate.** The gate refuses a merge request when no approval names
  its current commit, and when its only approval is a self-approval: one
  whose approver offered the work back to itself. It compares agent names
  only, so it cannot see who wrote the commits or which session ran each
  agent.

Development and issues: pairgate is developed in this project, and its own
work is tracked in this project's issues. File a bug or a proposal as an issue
here. A merge request cites the issue it works on as `#NN`, the issue's
number after a `#` (for example `Related to #NN`), the form the
issue-reference check in this project's CI accepts. The work on that issue is recorded in the issue's notes, as for any
deployment: pairgate tracks its own development with itself. Work that started
on the hosting deployment's tracker before this switch stays there until it is
done.

## The protocol

### The issue is the record

Ownership is recorded on the issue, not on the merge request. A note on the
issue carries the agent's identity in a machine-readable HTML comment, which
GitLab does not render. A scoped label (`agent::lead` or `agent::review`)
carries the role. GitLab allows one label per scope, so an issue cannot be in
both roles at once. The notes are authoritative and the label is only an index
for listing issues fast. Notes are append-only and server-timestamped, so two
agents can always agree on their order. A label write is not compare-and-swap,
so two agents can both set it without either seeing a conflict.

[`examples/ledger-trail.md`](examples/ledger-trail.md) shows what a user sees
on the issue for one whole round: claim, offer, accept, approve, accept, then
the merge check, and the same check after one more push.

### A handoff is an offer

`handoff` does not move ownership. It records an offer, and ownership moves
only when the receiver runs `accept`. Until then the sender still holds the
issue and the label still names the sender. With a transfer instead of an
offer, a receiver that never noticed the transfer looks exactly like one that
finished the work. An offer that nobody accepts stays on the record as
pending, so anyone reading the issue can see that nobody picked it up.

An offer is bound to a commit. `handoff --sha` must name the merge request's
head as the repository reports it, and the offer is refused if the merge
request has no commits or its pipeline failed. The one exception is a red
pipeline whose only blocking failure is the `validate-mr-approval-at-head` job
(see [the card's refusal table](docs/agent-handoff.md#refusals-and-what-clears-them)).
`accept` reads the head and the
pipeline again. If the branch moved after the offer, the reviewer is not
handed something other than what was offered: `accept` refuses, and the sender
offers again at the new head.

### Approval is bound to a commit

A review approves one state of the branch. `approve --sha` records the merge
request and the full commit hash the reviewer read. `merge-check` reads the
merge request's head and passes only when it is that commit. Any push after the
approval voids it, a one-word fix to a comment included, and the new head needs
its own review. Without this binding, an approval is a sentence in a note that
cannot say which commit it was about, and an edit made after it is merged
unreviewed.

`approve` also offers the issue back to the lead, who accepts it like any other
offer.

### A self-approval is recorded and refused

One agent may hand an issue to itself and approve its own work (the solo
flow). The note records that it is a self-approval, and every surface says so.
The merge gate does not count it: only an approval by a different agent name
satisfies `merge-check`, and there is no override flag. A solo run therefore
ends with a handoff to a second agent, not with a merge. The gate compares
agent names, not sessions. Two names used by one session still pass, so check
that the reviewer really was a different session.

### One live offer per issue

An issue holds at most one pending offer. A second `handoff` while one is
pending is refused, because both notes would otherwise read as live offers and
nothing would say which binds. To replace an offer on purpose, name it:
`handoff ... --supersede <note id>` (the refusal prints the id). The second
offer then supersedes the first, and the record shows which one it replaced.
`approve` follows the same rule, because it writes an offer too.

### Findings are evidence, and say what they license

A finding that crosses from one agent to the other carries one of three
markers. Only the last one asks for a change before the merge:

```
OBSERVED:  something found and recorded. It licenses no change.
ASKS:      a change wanted, with what and where.
BLOCKS:    this must change before the merge.
```

A relay may not promote a marker: retelling an `OBSERVED:` as "fix this" adds a
default its author never wrote. A figure or a cause that another agent will
act on carries how it was obtained.

An offer to review carries mutation evidence: for each place the change alters
behaviour, the mutant that removes the fix and the test that then fails on an
assertion. The reviewer first re-runs one of those mutants and one of its own.

A third agent, the **finder**, hunts for bugs and files evidence only: an issue
and failing tests, marked as expected failures, with no fix. It never claims an
issue. A lead picks the issue up and fixes it.

The full rules, and the reasons behind them, are in
[`docs/agent-handoff.md`](docs/agent-handoff.md) (the operator card) and
[`docs/agent-handoff-reference.md`](docs/agent-handoff-reference.md).

## Installation

pairgate needs Python 3.11 or later and uses the standard library only. It
reads from GitLab through `glab`, or over HTTPS when `<env_prefix>_TASK_API_TOKEN`
is set, and every write goes through `glab api`. So `glab` must be installed
and authenticated as the account the agents post as.

```bash
pip install pairgate     # the latest tagged release, from pypi.org
pip install .            # or from a checkout of this repository
```

The releases on pypi.org are the tagged versions (`vX.Y.Z`; see
[`RELEASING.md`](RELEASING.md)). Between releases, every commit on `main` is
also published to this project's package registry as
`<release version>+<content hash>`, the form a deployment pins with
`--hash=sha256:`.

`pairgate --help`, `pairgate <command> --help` and `pairgate --version` read no
configuration, so they work in a fresh install. The help spells each configured
value as the key it comes from (`<tracker_project>`, `<env_prefix>_AGENT_ID`).
Every other command reads the configuration first, and with none it prints a
missing-configuration error and exits 2; see [Configuration](#configuration).
`--version` reports the installed distribution's version, from a regular or an
editable (`pip install -e`) install. It refuses when no copy is installed, and
when the copy running is not the installed one (for example, a checkout run
while another copy is installed).

The wheel ships the package, the gate validators (`gate/`), the merge hook
(`hooks/`) and the agent definition templates (`agents/`). It does not ship the
tests, `docs/`, `examples/` or `templates/`.

## Configuration

Every caller reads one JSON file: the file `PAIRGATE_CONFIG` names when that
variable is set, and otherwise `pairgate-config.json` beside `ownership.py`.
The second form is for a deployment that carries the files in its own tree,
because it configures every caller at once. A set variable is used or is an
error; it never falls through to the file beside. There are no built-in
values, so an absent, unreadable or malformed file is an error. The file holds
exactly the keys in `ownership.CONFIG_KEYS`, no more and no fewer.
The configuration the suite runs under, in `src/pairgate/tests/fixtures/`, is a
complete example, and
[`examples/pairgate-config.json`](examples/pairgate-config.json) is a synthetic
one.

The merge hook reads the `pairgate-config.json` installed beside it, not the
session's environment, and passes it on as `PAIRGATE_CONFIG`. The gate
validators read `PAIRGATE_CONFIG` only.

### Configuration keys

#### `tracker_project`

The GitLab project whose issues hold the ownership notes and role labels, as a
full path (`group/project`). It must be a project in `group_path`. Commands
that take `--project` default to it.

#### `group_path`

The GitLab group every repository lives under. The merge hook gates only
projects that the group's own project listing contains, shared-in projects
included.

#### `group_id`

That group's numeric id, as a string of digits. It is used for the group-wide
merge request read.

#### `env_prefix`

The prefix of every environment variable the tool reads: an agent's identity
is `<env_prefix>_AGENT_ID`, the read token is `<env_prefix>_TASK_API_TOKEN`,
and so on. Upper case letters, digits and underscores, starting with a letter.

#### `agent_identity_prefix`

Agent identities are `<agent_identity_prefix>-lead`, `-review`, `-solo` and
`-finder`, optionally with a suffix such as the issue number
(`<prefix>-lead-42`). `deploy-agents` names the rendered agent files after it.
A `--to` name that matches neither this grammar nor a name already on the
issue's trail needs `--new-identity`.

#### `review_bot_token_file`

The default path of the review-bot credential file, which `approve` uses to
approve the merge request on GitLab as well as in the ledger.
`<env_prefix>_REVIEW_BOT_TOKEN`, when it is present, is used instead.

#### `deployed_paths`

The repository paths the deployed copy of the tool is made of, a non-empty
list. The currency check watches them (see "Which checkout the currency check
measures" below).

#### `generic_tracking_issue`

The tracker issue that unattended automation cites when it has no specific
issue. A merge request citing only that issue is not in the merge gate's
scope. `null` for none.

#### `liveness_oracle_file`

The deployment's liveness oracle: the text `deploy-agents` puts between the
finder template's liveness-oracle markers. A path relative to the
configuration file.

#### `cli_command`

The command the deployment's users run this tool as. Every remedy
`ownership.py` prints, in a refusal, a note or a help text, spells its commands
with it (`<cli_command> accept 12`), not with a fixed program name. Lower case
letters, digits, `.`, `_` and `-`, starting with a letter or digit. It names
the command only: the markers the ledger writes into notes
(`<!-- mochi-task:ownership {...} -->` and its siblings) are the record's
persisted format and do not follow it, so a deployment that renames its command
still reads every note already written.

#### `renovate_bot_user_id`

The GitLab user id (an integer) of the account Renovate opens merge requests
as. A merge request carrying the `renovate` label is out of the merge gate's
scope only when this account opened it and its source branch starts with
`renovate_branch_prefix`. The label alone exempts nothing, because anyone who
can edit a merge request can add it. It is the id rather than the username
because an account can be renamed and its old name taken by another account,
while an id is never reused.

#### `renovate_branch_prefix`

The prefix of every source branch Renovate opens a merge request from:
Renovate's `branchPrefix`, which is `renovate/` by default. Include the
trailing separator. The match is a plain prefix, so `renovate` without the
slash would also match a branch named `renovate-anything`.

Protect this prefix in the repositories so that only the Renovate account can
push to it. The gate cannot see who pushed a commit, so otherwise anyone who
can push to an open Renovate merge request's branch can put their own change
behind its exemption.

#### `renovate_exempt_namespaces`

The namespaces (group paths, such as `example-org`) whose projects the Renovate
exemption applies to. A project is covered when its path starts with one of
them followed by `/`, which includes its subgroups. A Renovate merge request in
any other project stays in the merge gate's scope and needs a review like any
other merge request. `[]` exempts nothing.

List a namespace only where `renovate_branch_prefix` is protected so that only
the Renovate account can push and force-push to it. That protection names a
user, and GitLab offers user-specific push rules only on paid tiers. In a
namespace where it cannot be set up, leave the namespace out: Renovate's merge
requests there are then reviewed, and there is no exemption to push past.

The gate reads this key only for a merge request that carries the `renovate`
label. A configuration without the key exempts nothing, so a gate newer than
its configuration fails toward review rather than toward exemption.

#### `cli_install_command`

The command that (re)installs the deployment's copy of this tool. The refusal a
stale copy prints for a writing command ends with it.

#### `hooks_install_command`

The command that (re)installs the deployment's hooks, the merge hook among
them. The merge hook prints it when a file it runs beside is missing. When the
configuration itself is the missing file, the hook cannot read this key and
says what to run in words instead.

#### `agent_text_dir`

The directory, relative to the configuration file, that holds the deployment's
own text for the agent templates: one `<name>.md` for every `{{text:<name>}}` a
template carries (see "Deploying the agent definitions" below). `examples/agent-text/` is the
example deployment's set.

#### `handoff_doc`

Where the deployment's readers find the protocol doc: a path in its repository,
or a URL. The gate validators' refusals end with `Background: <handoff_doc>.`,
the merge hook's refusal too, and the agent definitions link to it
(`{{config:handoff_doc}}`). pairgate's own is `docs/agent-handoff.md`.

## Commands

`pairgate --help` lists every command, and `pairgate <command> --help` its
flags. The ownership verbs act as `--as <agent>` or, without it, as
`<env_prefix>_AGENT_ID`. An unset identity is an error, never an anonymous
write.

```bash
pairgate claim   <issue> --role lead|review                  # take an unowned issue
pairgate handoff <issue> --to <agent> --role lead|review --sha <head> \
                 [--reason TEXT | --reason-file PATH] [--mr <iid>] [--mr-project <p>] \
                 [--supersede <note id>] [--new-identity]    # OFFER it
pairgate accept  <issue>                                     # take an offer made to you
pairgate decline <issue>                                     # return an offer to its sender
pairgate release <issue>                                     # drop it, with no successor
pairgate approve <issue> --mr <iid> --sha <head> --to <lead> \
                 [--mr-project <p>] [--reason TEXT | --reason-file PATH] \
                 [--not-covering ITEM] [--server-approval-only] [--supersede <note id>]
pairgate merge-check --mr <iid> --mr-project <p>             # is the head the approved commit?
pairgate status  <issue>                                     # holder, role, pending offer, MRs
pairgate board   [--json] [--closed]                         # every labelled issue in one pass
pairgate waiting [--agent <agent>] [--json]                  # who owes the next move
pairgate close   <issue>                                     # close it, if nothing is outstanding
pairgate sync    [ISSUE ...] [--apply]                       # close what an independent approval merged
pairgate freeze  <issue> --scope board|issue|issue:<iid>|mr:<iid> --reason TEXT [--minutes N]
pairgate thaw                                                # lift the window you declared
pairgate identity [--json]                                   # does anyone else answer to my name?
pairgate impose  --text TEXT                                 # render a standing constraint's clause
pairgate lift-check --lift-from <agent> [--constraint-file F | --constraint-text T]
pairgate deploy-agents --target <dir> [--check]
pairgate deploy-hooks  --target <dir> [--check] | --list
```

`--to`, `--role` and `--sha` on `handoff`, `--to` and `--sha` on `approve`, and
`--role` on `claim` have no default. `--to` takes the identity the receiver
runs as, never a role name. `merge-check` requires `--mr-project`, because a
merge request iid is not unique across projects. `graph` is registered and
refuses with exit 2; it is not implemented.

## The merge hook

`block-unapproved-merge.sh` is a Claude Code `PreToolUse` hook on Bash
commands. It runs `merge-check` in front of every merge an agent session
issues, either as `glab mr merge <iid>` (or `glab mr accept <iid>`) or as
`glab api -X PUT projects/<p>/merge_requests/<iid>/merge`, and blocks the
command unless the check passes. It reads the command the way bash would, and
on top of that any command whose raw text names a glab merge is blocked unless
the hook read that merge as a real invocation. So a merge command quoted inside
other text blocks too: in a `--reason`, a note body, a commit message or a
heredoc. Put such text in a file and pass it with `--reason-file`. A
merge it cannot attribute to one project and one merge request is blocked
rather than guessed. It does not cover a merge made from the GitLab web page,
from a machine without the hook, or by `curl`. Those are what the CI component
below is for.

A deployment does not copy the hook out of the installed package by path. It
runs:

```bash
pairgate deploy-hooks --target <hooks dir>           # copy the hook and the files it runs
pairgate deploy-hooks --target <hooks dir> --check   # write nothing; exit 1 if one is missing or differs
pairgate deploy-hooks --list                         # <deployed name><TAB><source path>, one per file
```

Four files are copied, byte for byte and mode 0755, into the flat hooks
directory: `block-unapproved-merge.sh`, `ownership.py`,
`validate-mr-carries-ownership-record.py` and `deployed_currency.py`. The hook
also reads three files beside itself that the deployment supplies, because
they are its own: `pairgate-config.json`, `_hooklib.sh` (the preflight library
the deployment's other hooks source too) and `automation_mr_families.py`. The
wheel's own `hooks/_hooklib.sh` is the copy this project's suite runs the hook
beside; it is not deployed. The hook denies a merge when any of the seven is
missing except `deployed_currency.py`: without that one it prints a note that
its own currency was not established, and goes on (its currency check is never
fatal). `--check` compares only the four, byte for byte, and reads no other
file in the directory. It does not compare the file mode, so a deployed file
that lost its execute bit still reads as in line. `--list` is for a
deployment's installer: it keeps these names out of its own manifest and
pruning, and finds the incoming `ownership.py` without knowing the package
layout. A target that is not a directory, or a destination that is not a
regular file, is refused before anything is written; a write that still fails
removes its temporary file and reports the install as PARTIAL. Exit codes:
0 done / in line / listed, 1 drift (`--check`), 2 cannot deploy.

Register the deployed `block-unapproved-merge.sh` as a `PreToolUse` hook with
matcher `Bash` in the Claude Code settings the agent sessions use.

## The CI component

`templates/mr-ownership-gate/` is a GitLab CI/CD Catalog component with two
jobs, run on every merge request pipeline of a repository that includes it:

- `validate-mr-carries-ownership-record` fails a merge request that cites a
  tracker issue nobody has claimed. Claiming the issue clears it.
- `validate-mr-approval-at-head` fails a merge request that has an approval
  when that approval is a self-approval only, or no longer names the head. A
  re-approval at the head, then a re-run of that job, clears it. A merge
  request that was never approved is not failed here: CI cannot tell
  unreviewed agent work from ordinary work, so that case is left to the merge
  hook and to `merge-check`.

With the project setting "Pipelines must succeed", a red job stops the merge
for everyone, including a merge from the web page. For the cases it covers, the
CI pair is the part that holds, and the hook is a fast local copy of the same
verdict. Keep both job
names: `handoff` and `accept` recognise `validate-mr-approval-at-head` by name.

Everything deployment-specific is a component input with no default: the job
image, the runner tags, the tracker project, the environment-variable prefix,
the name of the CI variable holding the read token, and the paths inside the
tracker repository of the reader, its configuration, the automation roster and
the two validators. The job fetches those five files from the tracker's
default branch at one commit, and refuses when the fetched configuration names
another tracker project or prefix than the inputs do.

## Deploying the agent definitions

A deployment does not carry copies of the agent definitions. It renders them
from the installed package:

```bash
pairgate deploy-agents --target <dir>           # write <prefix>-<role>.md for every role
pairgate deploy-agents --target <dir> --check   # write nothing; exit 1 if one is missing or differs
```

Each `agents/<role>.md` template becomes `<agent_identity_prefix>-<role>.md`,
with every `{{agent:<role>}}` in it replaced by that role's name, every
`{{config:<key>}}` by that configuration value (`agent_identity_prefix`,
`cli_command`, `env_prefix`, `group_path`, `handoff_doc` or `tracker_project`,
or `tracker_name` and `tracker_api_path`, the last segment of `tracker_project`
and the whole of it URL-encoded, computed so no text has to repeat it), and every
`{{text:<name>}}` by the deployment's own text: the file `<name>.md` in the
directory `agent_text_dir` names, with one trailing newline removed. The
templates are generic; a deployment's commands, paths and procedures are its
texts. The text directory must hold only `<name>.md` files, each named by some
template; a text is not rendered, so one that carries a placeholder or a
liveness-oracle marker refuses. A text file may be empty. In
`finder.md`, the text between `<!-- pairgate:liveness-oracle:begin -->` and
`<!-- pairgate:liveness-oracle:end -->` is replaced by the deployment's own
liveness oracle: the file the configuration key `liveness_oracle_file` names,
relative to the configuration file. The markers stay. There is no default
oracle: a missing, empty or marker-carrying oracle file refuses, and so does a
template whose markers are not there exactly once each (or appear outside the
finder), an unknown placeholder role, or a front-matter `name:` that is not the
deployed name. Everything is rendered before anything is written, so a refusal
writes nothing. The oracle and templates are carried as bytes (a CRLF stays a
CRLF), and `--check` compares bytes. A target that is not a directory, or a
destination that is not a regular file, is refused before anything is written;
a write that still fails removes its temporary file and reports the install as
PARTIAL, naming what it had already replaced. Exit codes: 0 done / in line,
1 drift (`--check`), 2 cannot render or cannot write (including text that is
not UTF-8). `deploy_agents.py` is the only parser of the markers.

The handoff-vocabulary guard (`gate/validate-handoff-finding-vocabulary.py`)
reads these templates, from the package beside it, as its agent files; only the
protocol document is read under `--root`.

## Which checkout the currency check measures

`main.py` checks, before each command, whether the checkout it serves is
missing origin/main commits to the configured `deployed_paths`, and refuses a
writing command when it is (STALE). By default the checkout measured is the
one `main.py` itself is in. An installed `main.py` is in site-packages, in no
checkout, so a deployment that runs the installed copy names its checkout:
`main(argv, origin=<absolute path inside that checkout>)`. A given origin is
always the one measured: one that cannot be measured is UNVERIFIABLE, and is
never replaced by `main.py`'s own location. The origin is resolved, as
`__file__` is, so one reached through a symlink measures the checkout its
target is in. A blank, relative or non-str-path origin raises.

## Layout

| Path | What it is |
|---|---|
| `src/pairgate/` | the package: `ownership.py` (the ledger reader and every ownership verb, one file, executed standalone by the gate), `main.py` (the CLI), and the command modules beside it |
| `src/pairgate/gate/` | the CI gate validators, run by path |
| `src/pairgate/hooks/` | the agent-session merge hook, run by path; `deploy-hooks` installs it |
| `src/pairgate/agents/` | the agent definition templates, one per role; `deploy-agents` renders them |
| `src/lib/deployed_currency.py` | the "is this deployed copy current" check `main.py` runs |
| `src/pairgate/tests/` | the suite |
| `docs/` | the protocol: the operator card and the reference |
| `examples/` | a ledger trail rendered by the tool itself, and the script that renders it |
| `templates/` | the CI component |

## Development

```bash
python3 -B -m pytest --import-mode=importlib src/pairgate/tests -q -p no:cacheprovider
python3 -m build --wheel && python3 scripts/check-wheel.py dist/*.whl
python3 examples/render_trail.py --check     # the example trail is what the tool writes now
```

`src/pairgate/tests/source-repository-wiring-tests.txt` lists the tests that
are about the source deployment's own wiring; they are deselected here and run
there.

The version is `pyproject.toml`'s `version` on a tagged release, and
`<that version>+<content hash>` (`scripts/content-hash.sh`) on every other
build. How to cut a release: [`RELEASING.md`](RELEASING.md).

How to contribute, and who signs their commits off under the DCO:
[`CONTRIBUTING.md`](CONTRIBUTING.md).

## Licence

Copyright (C) 2026 Will Humphreys

pairgate is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version (`GPL-3.0-or-later`).

pairgate is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details. The
full text is in [`LICENSE`](LICENSE).
