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,acceptandapproveappends 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 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).
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 (the operator card) and
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.
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). 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.
--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 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.
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:
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-recordfails a merge request that cites a tracker issue nobody has claimed. Claiming the issue clears it.validate-mr-approval-at-headfails 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 tomerge-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:
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
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.
How to contribute, and who signs their commits off under the DCO:
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.
Metadata
Release files for pairgate 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| pairgate-0.1.0.tar.gz | 454.1 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pairgate-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 931.7 kB
Release files / pairgate-0.1.0.tar.gz
| Download URL | pairgate-0.1.0.tar.gz |
|---|---|
| Size | 454.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
939c0d45513a1ec7718f4dba616b4a65fa828570f9ae7ce7e9fed9b0576e7433
|
|
BLAKE2b-256 checksum How to use checksums |
549b3aefe6ddcf008e80aaa4e31a36d445d54250a3191ed2c818809a93dbf83e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Release files / pairgate-0.1.0-py3-none-any.whl
| Download URL | pairgate-0.1.0-py3-none-any.whl |
|---|---|
| Size | 477.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a076416815cf86a0ea76abcbf64f2a26c7a10e3d457076448fa470fe3abd71cb
|
|
BLAKE2b-256 checksum How to use checksums |
f9395c1b139f3647841bcabb15fa635945c14b664e822136a966fa302a93fc9e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Security for pairgate 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.