Decisions

ADR-096: Warp as fleet PM via one-way GitHub-issue mirror

Tonight there are roughly forty-seven open issues across `gyrum-labs/*` repos that the operator works in directly through the GitHub UI, and roughly forty-seven warp-native items in the warp queue that AI agents claim…

#096

ADR-096: Warp as fleet PM via one-way GitHub-issue mirror

Status: Accepted Date: 2026-04-25 Related: ADR-077 (warp as session-independent agent coordination layer — defines warp's role today as the AI-worker inbox; pending merge as of this writing in feat/adr-077-coordination-layer)

Context

Tonight there are roughly forty-seven open issues across gyrum-labs/* repos that the operator works in directly through the GitHub UI, and roughly forty-seven warp-native items in the warp queue that AI agents claim and work via the warp protocol (per ADR-077). Two queues, one operator, no shared view. The actual question that gets asked between sessions — "what is the fleet currently working on, and what's open?" — has no single answer; it has two answers and a mental merge step. The cost of that merge step is small for one operator on one day and large for any agent that walks in cold and has to spelunk both surfaces before it can pick work.

Warp's role since ADR-077 is the AI-worker inbox: a Postgres-backed HTTP service holding the cross-session queue of work items, with a claim/heartbeat/complete protocol that lets any agent on any machine atomically reserve an item, work it, and report back. That role is doing its job. What it deliberately does not cover is the operator-tracked work that lives in GitHub issues — bugs the operator filed against a project, feature requests from outside the fleet, anything the operator wants to discuss in GitHub's comment thread because that's where the project's contributors and AI reviewers already are. ADR-077 named the cut: warp is for ad-hoc + AI-worker work; GitHub issues remain for issue-tracked work.

The cost of that two-queue split has gone from "noticeable" to "expensive" tonight because the swarm has scaled. With one operator and three open issues across two repos, the merge step is mental and free. With one operator, an N-agent swarm, ~50 open GitHub issues across ~15 repos, and ~50 warp-native items, the merge step is "open ten terminals and run gh issue list in each, then open the warp board, then context-switch between them." That cost compounds per agent, per session, per status-report. The fleet's first reaction has been "well let me grep" — the fleet's second reaction needs to be "warp is the one place I look."

The shape question is whether the answer is bidirectional sync (operator works in either tool, both stay in lockstep), one-way takeover (warp becomes the only PM tool, GitHub issues are deprecated for fleet work), or one-way mirror (operator continues working in GitHub for issue-tracked work, warp gains a read-only view of those issues alongside its native items). The third option is the only one that doesn't punish either an existing workflow (GitHub) or an existing system (warp's native queue), and it's the one decided here.

Decision

Warp gains a one-way pull mirror from GitHub issues into warp items. Issues stay in GitHub — the operator continues to file, comment, label, and close them there. Warp gains one read-only mirror item per open or recently-closed issue, marked as source: "github" in the schema, surfaced on the warp kanban with a visual treatment that distinguishes it from warp-native items, and clickable through to the upstream GitHub URL. Warp-native items (the ones AI agents claim and work via the ADR-077 protocol) keep their existing behaviour exactly: same shape, same claim/heartbeat/complete verbs, same per-agent reservation. The mirror is additive; nothing about the AI-worker inbox changes.

The manifest shape across the boundary is three new fields on warp_items:

external_id   TEXT NULL          -- e.g. "github:gyrum-labs/dark-factory#123"
external_url  TEXT NULL          -- e.g. "https://github.com/gyrum-labs/dark-factory/issues/123"
source        TEXT NOT NULL DEFAULT 'warp'   -- "warp" | "github"

Three rules follow from the cut and are non-negotiable:

  1. GitHub is source of truth for any item with source: "github". Warp never writes back. A mirror item's title, description, status, and tag set are derived from the upstream issue and refreshed on every webhook event. The warp UI does not let the operator edit a mirror item's body, retag it, or change its status — those edits would either silently fail (because warp can't write to GitHub without bidirectional sync, which is explicitly out of scope) or silently win locally and drift away from the GitHub truth on the next webhook. Both shapes are bad. The UI surfaces this honestly: a mirror item's only affordance is "open in GitHub", which opens the issue's URL in a new tab and lets the operator do the work in the place that owns the data. The API enforces it: PATCH /api/v1/items/{id} returns 403 read_only_mirror for any item with source != "warp", and the only path that may mutate a mirror item is the webhook handler at POST /api/v1/items/sync/github.

  2. Mirror items are non-claimable. The whole point of warp's claim verb is reserving an atomic unit of work for an agent that will execute it via the warp protocol — heartbeat, complete, attach a PR URL. None of that fits a GitHub issue, which the operator works through GitHub's comment thread and closes through GitHub's button. POST /api/v1/items/{id}/claim on a mirror item returns 409 mirror_not_claimable with an error body that names the upstream URL ("this item is mirrored from — work it in github"). The web kanban replaces the per-card Claim button with an Open in GitHub link, and the entire card body becomes a click-target that opens external_url in a new tab. Agents see mirror items in their list (so they know what work is open across the fleet), but they cannot accidentally reserve one and break the GitHub workflow.

  3. Bidirectional sync is explicitly out of scope. This is the rule that prevents the slow-creep failure mode every two-system mirror eventually hits: someone notices the mirror is read-only ("frustrating, why can't I close this from warp?"), proposes warp-writes-back-to-GitHub, the implementation is a tax of N edge cases (warp said open + GitHub said closed = which wins? warp added a tag + GitHub removed a label = merge how? warp's API rate-limited GitHub's REST = retry with what backoff?), and the operator ends up debugging sync conflicts rather than working issues. The cost of "the operator clicks through to GitHub to do the work" is one extra click. The cost of "the fleet runs a bidirectional sync engine" is a permanent tax. The trade is correct in the one-click direction; ADR-prose names it so a future PR proposing sync-back gets reviewed against the cut rather than approved on its merits.

Lossy mappings

Three things don't translate cleanly between the two surfaces. Each is acknowledged here so the operator isn't surprised when the mirror disagrees with the upstream issue.

  • Issue comments don't mirror. The mirror item's description carries the issue body's first paragraph (truncated at 500 characters) plus a footer line "→ Open in GitHub: <url>". Comments stay in GitHub, where they're already threaded against the issue and notify the participants the operator has actually subscribed. Pulling comments into warp would either flatten the threading (lossy) or require a comment-tree column (out of proportion for the value), and the link-out solves the actual problem: the operator who sees a mirror item and wants context clicks through and reads the comment thread in its native shape.

  • Labels translate via a small, conservative mapping. GitHub labels and warp tags are nominally the same shape (string tokens) but operationally they're not — GitHub labels are typically a flat namespace per-repo with cross-repo drift (bug in one repo means what defect means in another), and warp tags are a flat fleet-wide namespace where consistency matters because the kanban filter is one input. The mapping table that ships with the handler is small on purpose: bug → kind:bug, enhancement → kind:feature, documentation → kind:docs, question → kind:question, help wanted → help-wanted, good first issue → good-first-issue, plus the universal source:github and repo:<short-name> tags every mirror item gets. Priority labels (priority:critical, priority:high, priority:low) translate to the item's priority column rather than a tag, because the warp UI already sorts and colours by priority and adding a tag would double-render. Other labels pass through verbatim as warp tags. Extending the table is a single follow-up PR per concern.

  • State map is lossy on close-reason. GitHub issues have two close states (completed and not_planned) and one re-open. Warp items have five (ready, in_progress, done, blocked, cancelled) — an interior state machine that's richer than GitHub's. The mirror handler maps open → ready, closed:completed → done, closed:not_planned → cancelled, and re-open events back to ready. Lost information: a mirror item never enters in_progress (because GitHub has no equivalent) and never enters blocked (because GitHub's blocked-on-other-issue chain isn't structured in the issues API). Both losses are acceptable — warp-native items keep the full state machine; mirror items use a degraded subset because the upstream surface is degraded.

Consequences

What becomes easier:

  • One board for fleet status. The operator (and any agent walking in cold) opens the warp kanban and sees every open issue across gyrum-labs/* plus every warp-native item, with a visual marker distinguishing the two. Status reports stop being "open ten terminals and merge by hand"; they become "look at warp, filter by source if you only care about one half."
  • AI agents can see issue-tracked work without picking it up. A mirror item is visible in the agent's list (so the agent knows the fleet has open work even if its own queue is empty) but not claimable (so the agent doesn't accidentally pick up work that belongs to the operator's GitHub workflow). The visibility is the value; the non-claimability is the safety.
  • The gh issue list across N terminals ad-hoc visibility goes away. Operators have been doing variants of for r in $(gh repo list gyrum-labs --json name --jq '.[].name'); do gh issue list --repo gyrum-labs/$r; done for weeks. That script is now exactly one of the things the mirror replaces, and the warp kanban is the canonical view.
  • A future "warp owns all PM" decision has a graceful path. If, twelve months from now, the fleet decides GitHub issues should go away entirely and warp becomes the only PM tool, the mirror is the migration step (it's already pulled the data; the only remaining work is to disable the webhook and let the warp items become writable). Tonight's decision doesn't commit to that future, but it doesn't preclude it either.

What becomes harder:

  • Two surfaces, one source of truth, requires the operator to internalise the cut. "I can edit the mirror item" is the obvious user-error; the API returns 403 and the UI hides the affordance, but the operator may still reach for it. The runbook for first-time use names this explicitly: if you want to change anything about a mirror item (title, status, labels), do it in GitHub and wait for the webhook (typically <2s) to re-mirror.
  • Warp now has an inbound webhook surface. A new endpoint at POST /api/v1/items/sync/github accepts payloads from GitHub, validated by HMAC against a shared secret stored in the fleet vault. That's a new attack surface (signature-validation bug = inbound write-without-auth), and it's mitigated by (a) the standard GitHub HMAC protocol with X-Hub-Signature-256, (b) the secret stored in vault and not in source, (c) the endpoint refusing any payload that doesn't validate. Routine for any GitHub-integrated service; named here so the security review thread doesn't have to discover it.
  • The label-to-tag mapping is now a fleet decision, not a per-repo one. The conservative initial table (six entries) covers the common GitHub labels, but any new label conventions ("priority:p0" instead of "priority:critical", or a project that uses "type:" prefixes) need a mapping update or they pass through verbatim and the kanban filter UI gets cluttered. The cost is one PR per new convention; the constraint is that conventions cross repos and need to be discussed once at the fleet level rather than negotiated per-repo.
  • The webhook setup is per-repo operator toil. Each repo in gyrum-labs/* needs gh repo edit ... --add-webhook run once with the shared secret. That's ~15 repos tonight, ~2 minutes per repo if the operator is paying attention. Documented as a runbook step (dark-factory/docs/runbooks/wire-github-webhook-for-warp-mirror.md); not automated tonight because the trade-off (build a webhook-management script vs. run the command 15 times) lands on the human side at this scale. If the fleet grows to 50+ repos, the script becomes worth writing.

What we have signed up to operate:

  • A schema migration adding external_id, external_url, source to warp_items, with a unique index on external_id (partial, where not null). Additive — existing data and existing endpoints survive unchanged.
  • A webhook handler at POST /api/v1/items/sync/github that validates HMAC, parses GitHub issues and issue_comment events, and upserts mirror items via the source=github API path. State map and label mapping live in this handler.
  • API enforcement: POST /api/v1/items/{id}/claim returns 409 on source != "warp"; PATCH /api/v1/items/{id} returns 403 on source != "warp".
  • A web treatment of mirror items: GitHub icon + repo + issue number on the card, "Open in GitHub" link replacing the Claim button, click-anywhere-on-card opens external_url in a new tab. Plus a kanban filter "source: any | warp-native | github-mirror".
  • A one-time backfill script at dark-factory/scripts/backfill-github-issues-to-warp.sh that pulls every open issue across gyrum-labs/* and POSTs to the source=github API path. Idempotent on external_id.
  • A runbook at dark-factory/docs/runbooks/wire-github-webhook-for-warp-mirror.md for the per-repo webhook setup the operator runs after merge.

What we revisit:

  • If the operator finds themselves regularly editing mirror items in warp and getting 403'd, the affordance is wrong (the UI is presenting an edit path the API rejects) and the UI gets a fix — not the API, which is correctly enforcing the cut.
  • If the conservative label-mapping table is missing labels the fleet uses heavily (signal: operators repeatedly proposing additions in PRs), the table grows and possibly factors into a per-repo override file. Tonight it's six entries hardcoded.
  • If the webhook proves flaky (signal: mirror items drifting from upstream issues without explanation) the backfill script is the recovery path — re-run it and every mirror item re-syncs from upstream truth. The script is intentionally idempotent on external_id so this works.
  • If a real need for bidirectional sync emerges (signal: the operator asks for it more than once with concrete use cases, not "wouldn't it be nice"), that's a new ADR with its own conflict-resolution model. Tonight's ADR explicitly forbids it so a future ADR has to make the case from scratch rather than slip in as an "obvious extension."

Alternatives considered

  • Full bidirectional sync (warp ↔ GitHub). Operator works in either tool, both stay in lockstep. Loses on operational complexity at every scale: every two-system sync runs into the conflict-resolution problem (warp said X, GitHub said Y, ten seconds apart, who wins?), the loop-prevention problem (warp updates GitHub which webhooks back to warp which updates GitHub), and the partial-failure problem (sync succeeded one way, failed the other, state diverges). The implementation is a tax, the operation is a tax, and the value over one-way mirror is small (the operator clicks through to GitHub instead of editing in warp; one click). The trade is wrong.

  • One-way takeover (warp becomes the only PM tool; deprecate GitHub issues for fleet work). Simpler than mirror in some axes — only one source of truth, no sync logic at all. Loses on the migration cost (every existing GitHub issue conversation has to move somewhere) and on the workflow cost (GitHub issues are where the operator already is, where the project's contributors are, where AI code reviewers comment, where the cross-link with PRs lives). Forcing the operator and the fleet to switch tools to gain warp's kanban view is the wrong shape — the kanban is worth a mirror, not a forced migration.

  • No mirror, keep ad-hoc visibility (status quo). Operator continues running gh issue list across N terminals. Loses on every dimension this ADR has named: the merge step is N×2 mental operations per status check, the cost compounds with the fleet's scale, and there's no path for an agent walking in cold to see the full work landscape. The pain is real today and will be worse next month.

  • Mirror per-repo via a GitHub Action that POSTs to warp on issue events (no webhook). Runs in each repo's CI, no warp-side endpoint to validate or operate. Loses on N copies of the sync logic (each repo's .github/workflows/warp-mirror.yml drifts independently), on credential management (each repo needs the warp token in its secrets), and on backfill (existing issues never get mirrored because they don't fire an event). Centralising the handler in warp with one webhook per repo is the right shape for consistency and for the one-time backfill story.

  • Mirror via polling instead of webhook. Warp polls gh issue list --owner gyrum-labs --state all on a cron every N minutes. Loses on latency (mirror state lags real state by up to N minutes; the operator closes an issue in GitHub and the warp board still says open for ten minutes), on rate limits (GitHub's REST API limits compound across the fleet), and on cost (every poll is wasted work when no issues changed). Webhooks are push-based, near-real-time, and event-correlated — the right shape. Polling is the fallback if webhook delivery proves unreliable, not the primary mechanism.


Supersedes: none. Extends warp's role under ADR-077 (pending merge) — adds a second source of items (source: github) alongside the existing warp-native items, without changing how warp-native items behave. Extends the warp_items schema additively (external_id, external_url, source columns; partial unique index on external_id). Out of scope for this ADR: bidirectional sync, comment mirroring, label-mapping extensions beyond the six initial entries. Superseded by: {{leave blank until a later ADR reverses this one}}