Skip to content

✨ feat: per-repo custom agents — scope an agent definition to the repos it serves - #6215

Open
Danathar wants to merge 2 commits into
hivecommons:v4from
Danathar:feat/per-repo-agents
Open

✨ feat: per-repo custom agents — scope an agent definition to the repos it serves#6215
Danathar wants to merge 2 commits into
hivecommons:v4from
Danathar:feat/per-repo-agents

Conversation

@Danathar

@Danathar Danathar commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

An agent can now say which repos it is for:

agents:
  schema-reviewer:
    backend: claude
    model: claude-opus-4-6
    repos: [console]      # ← this agent is for `console` and nothing else

A scoped agent is handed work only on those repos, its kick lists only those
repos, its $HIVE_REPO/$HIVE_REPOS follow, and writes to any other repo are
refused — by the proxy and by all three hive-mediated relays.

Leave repos: off and the agent serves the whole hive. That is the default and
what 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.md injection
(#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 is
not 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 AgentSpec is for, and it does not
require project.repos to become a list of objects — the change #6111 proposes
and which this must neither depend on nor pre-empt. If #6111 lands, a repo-side
view composes on top without moving this.

2. AgentSpec is extended, not changed. The interface is byte-identical:

type RepoScoped interface{ Repos() []string }   // separate, optional
func SpecRepos(spec AgentSpec) []string          // asks any spec, nil if it can't answer

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 legacySpec implementing only the five original accessors compiles
and is treated as unscoped.

A repos: key that is present but names nothing (repos: ["", " "]) is
rejected 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.Repos carries ReposOwner, the same FieldOwner*
marker ModelOwner/BackendOwner/PauseOwner 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 #5632/#5706 clobber family). Replicas resolve to their base
agent's scope, so quality-2 cannot escape what quality was 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.

Layer Behaviour for an out-of-scope repo
MITM proxy (pkg/proxy) Every agent write403 naming the agent and the repo. Checked ahead of and outside the ACMM mode chain, so a ISSUES_PRS_MERGE agent is refused as firmly as an ADVISORY one.
hive-open-pr relay Rejected before any GitHub call, quarantined, reason in the result file.
hive-merge relay Same, before the optional branch update so the repo receives no write at all.
hive-open-issue relay Same. Issue noise on repos that never wanted the agent is the specific cost this is about.
Kick assembly Issue list, PR list, hold list, clusters, queue counts, MERGE_ELIGIBLE, CI_FAILING all filtered to the agent's repos; AUTHORIZED REPOS lists only those.
Agent env $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 /pulls and PUT /pulls/{n}/merge for every mode precisely so those 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.

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 REPOS block also says the agent is scoped. Silently handing an
agent a shorter list than last week is how you get a finding filed about scope
loss.

What a scope does not do

  • Block reads. Scope says which repos an agent is for, not which it may
    look at — a reviewer scoped to the Go service may legitimately read the Rust
    CLI to understand a shared protocol, and git clone/git fetch keep working
    (git-upload-pack is a POST that reads). This matches RepoFilterAllowed,
    which has always gated the "repo not in hive config" case on writes alone.
  • Touch the hive's control plane. Token minting and heartbeats are exempt.
  • Re-point the hive. project.primary_repo is unchanged; only the agent's
    $HIVE_REPO follows its scope, and only when the hive primary is outside it.
    Otherwise every template example (gh ... --repo "$HIVE_REPO") would aim at a
    refused write.

Operator surface

  • Config: repos: on an agent. Bare names or owner/name, matched
    org-qualified and case-insensitively.
  • Dashboard: a Repos field in the agent config dialog's General tab,
    which also shows the hive's repo list so nothing is spelled from memory.
  • API: PUT /api/config/agent/{name}/general accepts repos; [] clears
    the scope. GET /api/agents reports repos (declared) and watchedRepos
    (intersected with project.repos) separately — an entry that is declared
    but 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.
  • Boot log: scoped agents are named individually, so an operator debugging
    "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/extra path),
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 the
pre-existing RepoFilterAllowed has. Those mutations stay gated by ACMM mode.
Documented in src/docs/per-repo-agents.md rather than half-fixed; closing it
means 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 as
    unscoped; repos: parsed/trimmed; a repos: naming nothing is rejected;
    matching across bare/qualified/case; SpecData satisfies both contracts.
  • pkg/config — unscoped and unknown agents serve everything; every
    spelling matches and a same-named repo in another org does not; replicas
    inherit; ReposForAgent intersects with project.repos while the declared
    scope keeps the unwatched entry; PrimaryRepoForAgent across 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 open
    for unscoped, unnamed and unconfigured; end-to-end proxyHTTP refusal of a
    MERGE-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 in
    the result file; an in-scope PR still opens; FilterActionableForRepos across
    issues/PRs/stale drafts/hold/clusters/TotalByRepo with counts and
    SLA violations recomputed, and the shared input proven unmutated.
  • pkg/scheduler — unscoped section unchanged (and buildReposSection()
    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/agentReposFor/PrimaryRepoFor fall back when unwired; preamble
    offers only the agent's repos and is empty when the scope matches nothing.
  • pkg/dashboard — set/clear round trip with ownership stamped; an omitted
    field 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 are
pre-existing — verified by running them on a clean upstream/v4 worktree —
both environmental in this sandbox: TestGateFailsClosedWithoutIP6Tables (needs
root/NET_ADMIN for ip6tables) and TestHandleConnectDirectAnthropicReroute
(needs outbound network; 502). pkg/agent has 38 pre-existing tmux/$HOME
failures in this environment; none are repo/scope/preamble/env related.

Note for reviewers

This touches pkg/scheduler/scheduler.go's buildReposSection and
substituteTemplateWithPolicy, which #6203 (per-repo agent pause) also
touches. 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 BYO
    contract's optional repos: extension
  • src/pkg/config/agent_repos.go (+ config.go) — AgentConfig.Repos,
    ReposOwner, AgentServesRepo, ReposForAgent, PrimaryRepoForAgent,
    SetAgentReposAndSave, warnings
  • src/pkg/proxy/rules.go, github_proxy.goAgentRepoScopeRefusal,
    SetAgentRepoScopeFunc, enforcement ahead of the mode chain
  • src/pkg/github/agent_repo_scope.go + the three request watchers —
    relay gates and FilterActionableForRepos
  • src/pkg/scheduler/scheduler.go, src/pkg/agent/manager.go — per-agent kick
    contents, AUTHORIZED REPOS, preamble, $HIVE_REPO/$HIVE_REPOS
  • src/pkg/dashboard/api.go, api_agents.go, validation.go,
    static/index.html — the field, its validation, the listing, the dialog
  • src/cmd/hive/main.go — wiring + boot logging
  • src/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

…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>
@kubestellar-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign hanthor for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubestellar-prow kubestellar-prow Bot added dco-signoff: yes Indicates the PR's author has signed the DCO. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels Sep 7, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the DCO. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ feature: per-repo custom agents — scope an agent definition to the repos it serves

1 participant