✨ feat: per-repo custom agents — scope an agent definition to the repos it serves - #6215
Open
Danathar wants to merge 2 commits into
Open
✨ feat: per-repo custom agents — scope an agent definition to the repos it serves#6215Danathar wants to merge 2 commits into
Danathar wants to merge 2 commits into
Conversation
…os it serves
Hive could already vary what an agent KNOWS per repo — pkg/agentsmd injects a
repository's AGENTS.md, skillreg resolves repo-local skills — but not which
agents EXIST per repo. The roster was a function of the hive, so in a hive
holding a Go service next to a Rust CLI next to a Terraform module, a specialist
added for one of them woke on cadence and went hunting for its concern in all
the others: inference spend and issue noise on repos that never wanted it. The
only alternatives were "absent everywhere" or "present everywhere".
`repos:` on an agent closes that. Omit it — the default, and every agent in
every existing config — and the agent serves the whole hive exactly as before.
Name repos and the agent becomes a specialist.
Where the scope lives, and why:
- AGENT-SIDE, not repo-side. It keeps a BYO agent a single self-contained
declaration, which is what skillreg.AgentSpec is for, and it does not need
project.repos to become a list of objects — the change hivecommons#6111 proposes and
which this must neither depend on nor pre-empt.
- The AgentSpec INTERFACE IS UNCHANGED. Scope is a separate optional
interface, RepoScoped, read through skillreg.SpecRepos. Adding a sixth
method would break every existing third-party implementation at compile
time; the package doc calls the contract "intentionally small and stable"
and that is exactly the cost it warns about. A spec that has never heard of
the extension keeps compiling and keeps meaning hive-wide. A `repos:` key
that is present but names nothing IS rejected at parse time — it would read
as scoped while silently widening the agent, the opposite of intent.
- AgentConfig.Repos carries ReposOwner, the same FieldOwner* marker
model/backend/pause already have. No pack ships a repo scope today so
nothing reverts one today; the marker is there so a pack that someday does
cannot widen an operator's specialist back to the whole hive on the next
restart — the hivecommons#5632/hivecommons#5706 clobber family this field would otherwise join,
and the specific failure mode hivecommons#6204 asks to design against.
Enforcement is deterministic, not a prompt instruction — Hive's own rule is that
an answer a human would always give the same way belongs in infrastructure:
- MITM proxy: an out-of-scope agent write is answered 403 with a message
naming the agent and the repo, checked ahead of and outside the ACMM mode
chain so a MERGE-mode agent is refused as firmly as an ADVISORY one.
- hive-open-pr / hive-merge / hive-open-issue relays: all three refuse to
fulfil an out-of-scope request. These gates are NOT redundant with the
proxy — it hard-denies direct POST /pulls and PUT /pulls/{n}/merge for every
mode precisely so they route through the hive, and a request the hive
fulfils never traverses the proxy. Proxy-only enforcement would have left a
specialist opening PRs, merging them and filing issues wherever it liked.
- Kick assembly: the agent's issue list, PR list, hold list, clusters, queue
counts, MERGE_ELIGIBLE and CI_FAILING are filtered to its repos, and
AUTHORIZED REPOS lists only those — with a line saying the agent is scoped,
so a shorter list does not read as scope loss and get filed as a finding.
This is the task filtering hivecommons#6111 names as the cheap cost fix: cadence stays
hive-wide, but a scoped agent wakes to its own repos' work.
- Environment: $HIVE_REPOS is the agent's repos, and $HIVE_REPO its own
primary — handing a specialist a $HIVE_REPO it may not write to would aim
every shipped template example (`gh ... --repo "$HIVE_REPO"`) at a refused
write.
Reads stay open on purpose (including git fetch, a POST that reads): scope says
which repos an agent is FOR, not which it may look at, and it matches the
pre-existing repo filter, which has always gated on writes alone. The hive's own
control plane is exempt. project.primary_repo is untouched.
Operator surface: `repos:` in hive.yaml, a Repos field in the dashboard agent
config dialog (which also shows the hive's repo list so nothing is spelled from
memory), and `PUT /api/config/agent/{name}/general` with `repos` — `[]` clears
the scope. `GET /api/agents` reports `repos` (declared) and `watchedRepos`
(intersected with project.repos) separately, because an entry that is declared
but not watched is exactly the misconfiguration worth seeing. Scoped agents are
named in the boot log, and a scope that matches nothing is a warning, never
fatal: refusing to boot over a stale entry would be worse than ignoring it, and
keeping the entry means re-adding the repo restores the scope.
Known limit, documented rather than half-done: repo attribution reads the
request path, so a GraphQL write whose target repo is only in the body is not
matched — the same blind spot the pre-existing repo-scope filter has. Those
mutations stay gated by ACMM mode.
Closes hivecommons#6204
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQTim25GU8Yk2HrCh39i1u
Signed-off-by: Douglas Baggett <doug.baggett@gmail.com>
Contributor
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
The kick path always passes a repo predicate now, so the bare wrapper had no callers left and golangci-lint's `unused` rung — enabled precisely to catch dead code — flagged it. buildCIFailingList keeps its bare form because the pre-existing TestBuildCIFailingList_* cases call it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQTim25GU8Yk2HrCh39i1u Signed-off-by: Douglas Baggett <doug.baggett@gmail.com>
This was referenced Sep 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An agent can now say which repos it is for:
A scoped agent is handed work only on those repos, its kick lists only those
repos, its
$HIVE_REPO/$HIVE_REPOSfollow, and writes to any other repo arerefused — by the proxy and by all three hive-mediated relays.
Leave
repos:off and the agent serves the whole hive. That is the default andwhat every agent already does, so a hive with no scoped agents behaves exactly
as it did before.
Closes #6204.
Why
Hive can already vary what an agent knows per repo —
AGENTS.mdinjection(#4962), repo-local skills — but not which agents there are. The roster was
a function of the hive, so in a hive holding a Go service next to a Rust CLI next
to a Terraform module, a specialist added for one of them woke on cadence and
went hunting for its concern in all the others. Inference spend and issue noise
on repos that never wanted it, and the only alternatives were "absent everywhere"
or "present everywhere".
This is not what #6111 covers. Per-repo ACMM levels change how much autonomy a
hive-wide agent has on a repo and explicitly keep the roster hive-wide ("The
roster is a function of
hive_level"). An agent that is irrelevant to a repo isnot fixed by lowering its autonomy there — it should not be there.
Design decisions
The issue left three questions open. Here is how each is answered, and why.
1. Scope lives on the agent, not the repo. It keeps a BYO agent a single
self-contained declaration, which is what
AgentSpecis for, and it does notrequire
project.reposto become a list of objects — the change #6111 proposesand which this must neither depend on nor pre-empt. If #6111 lands, a repo-side
view composes on top without moving this.
2.
AgentSpecis extended, not changed. The interface is byte-identical:Adding a sixth method to a published Go interface breaks every existing
third-party implementation at compile time — a spec that compiled last release
would stop compiling. The package doc calls the contract "intentionally small and
stable"; that is exactly the cost it warns about. A spec that has never heard of
the extension keeps working and keeps meaning hive-wide. There is a test that
pins this: a
legacySpecimplementing only the five original accessors compilesand is treated as unscoped.
A
repos:key that is present but names nothing (repos: ["", " "]) isrejected at parse time — it would read as "scoped" while silently widening the
agent, the opposite of what writing the key meant.
3. It survives the pack sweep — the specific failure mode the issue asked to
design against.
AgentConfig.ReposcarriesReposOwner, the sameFieldOwner*marker
ModelOwner/BackendOwner/PauseOwneralready have. No pack ships arepo scope today so nothing reverts one today; the marker is there so a pack that
someday does cannot widen an operator's specialist back to the whole hive on the
next restart (the #5632/#5706 clobber family). Replicas resolve to their base
agent's scope, so
quality-2cannot escape whatqualitywas scoped to.How it is enforced
Deterministically. Hive's own rule is that an answer a human would always give
the same way belongs in infrastructure, not a prompt — and a prompt-level scope
has already been observed to fail when an agent's model changes.
pkg/proxy)403naming the agent and the repo. Checked ahead of and outside the ACMM mode chain, so aISSUES_PRS_MERGEagent is refused as firmly as anADVISORYone.hive-open-prrelayhive-mergerelayhive-open-issuerelayMERGE_ELIGIBLE,CI_FAILINGall filtered to the agent's repos;AUTHORIZED REPOSlists only those.$HIVE_REPOS= the agent's repos;$HIVE_REPO= its own primary.The proxy and relay gates are both required. The proxy hard-denies direct
POST /pullsandPUT /pulls/{n}/mergefor every mode precisely so those routethrough the hive — and a request the hive fulfils never traverses the proxy.
Proxy-only enforcement would have left a specialist opening PRs, merging them and
filing issues wherever it liked.
Cost is fixed by task filtering, not cadence. Cadence stays hive-wide per
agent, as #6111 suggests: a scoped agent still wakes, but it wakes to its own
repos' work instead of a fleet-wide backlog it must read through and discard. An
agent shown a schema-migration issue in a repo with no database will look at it;
the cheapest fix is not to show it.
The
AUTHORIZED REPOSblock also says the agent is scoped. Silently handing anagent a shorter list than last week is how you get a finding filed about scope
loss.
What a scope does not do
look at — a reviewer scoped to the Go service may legitimately read the Rust
CLI to understand a shared protocol, and
git clone/git fetchkeep working(
git-upload-packis a POST that reads). This matchesRepoFilterAllowed,which has always gated the "repo not in hive config" case on writes alone.
project.primary_repois unchanged; only the agent's$HIVE_REPOfollows its scope, and only when the hive primary is outside it.Otherwise every template example (
gh ... --repo "$HIVE_REPO") would aim at arefused write.
Operator surface
repos:on an agent. Bare names orowner/name, matchedorg-qualified and case-insensitively.
which also shows the hive's repo list so nothing is spelled from memory.
PUT /api/config/agent/{name}/generalacceptsrepos;[]clearsthe scope.
GET /api/agentsreportsrepos(declared) andwatchedRepos(intersected with
project.repos) separately — an entry that is declaredbut not watched is exactly the misconfiguration worth seeing, and collapsing
them would hide it. No new routes, so the OpenAPI route-parity guard is
untouched.
"why did nothing happen on that repo" can read the roster composition out of
the log they already have.
Misconfiguration is reported, never fatal: an entry naming an unwatched repo, or
a scope where all entries miss, is a boot warning. Refusing to boot over a
stale entry would be worse than ignoring it, and keeping the entry means
re-adding the repo restores the scope. The dashboard does refuse to save an
entry that cannot name a repository at all (a URL, an
owner/name/extrapath),so it surfaces on the request that caused it.
Known limit
Repo attribution reads the request path, so a GraphQL write whose target repo
is only in the body (
POST /graphql) is not matched — the same blind spot thepre-existing
RepoFilterAllowedhas. Those mutations stay gated by ACMM mode.Documented in
src/docs/per-repo-agents.mdrather than half-fixed; closing itmeans teaching the GraphQL classifier to resolve a target repo, which is its own
change.
Testing
New tests, all passing:
pkg/skillreg— a legacy five-method spec still compiles and reads asunscoped;
repos:parsed/trimmed; arepos:naming nothing is rejected;matching across bare/qualified/case;
SpecDatasatisfies both contracts.pkg/config— unscoped and unknown agents serve everything; everyspelling matches and a same-named repo in another org does not; replicas
inherit;
ReposForAgentintersects withproject.reposwhile the declaredscope keeps the unwatched entry;
PrimaryRepoForAgentacross four cases;persistence +
repos_owner; clear-to-hive-wide; warnings; nil-safety;omitempty(an unused hive writes nothing).pkg/proxy— writes refused / reads allowed (incl. git fetch); fails openfor unscoped, unnamed and unconfigured; end-to-end
proxyHTTPrefusal of aMERGE-mode write (403, right message, violation counted, never reaches
upstream); in-scope write and out-of-scope read both relayed; internal caller
exempt.
pkg/github— all three relays refuse and quarantine with the reason inthe result file; an in-scope PR still opens;
FilterActionableForReposacrossissues/PRs/stale drafts/hold/clusters/
TotalByRepowith counts andSLA violations recomputed, and the shared input proven unmutated.
pkg/scheduler— unscoped section unchanged (andbuildReposSection()still equals the no-agent form, so pre-existing callers are safe); scoped
section lists only its repos and says so; rotation counts the agent's repos
and names the agent's primary; unmatched scope stated; issue/queue/repos-list
filtering per agent with the fleet-wide input proven unmutated;
merge-eligible filtering.
pkg/agent—ReposFor/PrimaryRepoForfall back when unwired; preambleoffers only the agent's repos and is empty when the scope matches nothing.
pkg/dashboard— set/clear round trip with ownership stamped; an omittedfield leaves the scope alone; five kinds of unusable entry rejected; the
agents list reports both fields for scoped agents and neither for unscoped;
the config GET carries the scope and the hive's repo list; the ref validator.
Full-package runs:
pkg/skillreg,pkg/config,pkg/proxy,pkg/scheduler,pkg/github,pkg/dashboard,cmd/hive. Two failures remain and arepre-existing — verified by running them on a clean
upstream/v4worktree —both environmental in this sandbox:
TestGateFailsClosedWithoutIP6Tables(needsroot/
NET_ADMINfor ip6tables) andTestHandleConnectDirectAnthropicReroute(needs outbound network; 502).
pkg/agenthas 38 pre-existing tmux/$HOMEfailures in this environment; none are repo/scope/preamble/env related.
Note for reviewers
This touches
pkg/scheduler/scheduler.go'sbuildReposSectionandsubstituteTemplateWithPolicy, which #6203 (per-repo agent pause) alsotouches. They are independent features — pause is a run-state for a repo, this is
roster composition for an agent — and compose cleanly (a paused repo is quiet for
every agent; a scoped agent is absent from repos it does not serve). Whichever
lands second will need a small textual merge in that file.
Files
src/pkg/skillreg/agentrepos.go,agentspec.go,skillreg.go— the BYOcontract's optional
repos:extensionsrc/pkg/config/agent_repos.go(+config.go) —AgentConfig.Repos,ReposOwner,AgentServesRepo,ReposForAgent,PrimaryRepoForAgent,SetAgentReposAndSave, warningssrc/pkg/proxy/rules.go,github_proxy.go—AgentRepoScopeRefusal,SetAgentRepoScopeFunc, enforcement ahead of the mode chainsrc/pkg/github/agent_repo_scope.go+ the three request watchers —relay gates and
FilterActionableForRepossrc/pkg/scheduler/scheduler.go,src/pkg/agent/manager.go— per-agent kickcontents,
AUTHORIZED REPOS, preamble,$HIVE_REPO/$HIVE_REPOSsrc/pkg/dashboard/api.go,api_agents.go,validation.go,static/index.html— the field, its validation, the listing, the dialogsrc/cmd/hive/main.go— wiring + boot loggingsrc/docs/per-repo-agents.md,README.md,agent-configuration.md,skills.md,api-reference.md,src/hive.yaml.example,changelog.d/added-6204-per-repo-agents.md— hive: backend=claude model=claude-opus-5