Skip to content

anvil GitHub Issues sync

Audience: end users configuring bidirectional sync against GitHub Issues. For the SyncProvider Protocol and how to add another backend (Linear, Monday, Jira), see sync-providers.md.

What it is

Bidirectional, polling-based sync between anvil tasks and GitHub issues. Every local Task round-trips to an issue in a configured repo, status labels encode the anvil lifecycle, and divergence is detected per-task via recorded last_synced_at vs the remote updated_at. v0 ships the GitHubIssuesProvider only; the same sync surface accepts Linear, Monday, Jira, and any other contributor-registered backend via the SyncProvider Protocol (see sync-providers.md).


Quick start

# Authenticate. gh CLI is preferred; the provider re-uses your gh session.
gh auth login

# Probe reachability + auth before doing any state mutation.
anvil sync github --health

# One push+pull pass against every local task.
anvil sync github

For environments without gh installed (CI runners, sandboxes), set a PAT instead:

export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxx
export GITHUB_REPOSITORY=owner/repo
anvil sync github

Configuration

The provider needs a target repository AND a way to authenticate. Both have two paths; pick one of each.

Repository selection

Source Example Precedence
Constructor kwarg repo= GitHubIssuesProvider(repo="o/r") 1 (highest)
GITHUB_REPOSITORY env export GITHUB_REPOSITORY=o/r 2

The CLI instantiates with no kwargs, so in CLI usage GITHUB_REPOSITORY is the only option. Format is always <owner>/<repo>; the constructor raises ValueError on missing or malformed values.

Authentication

Source Notes
gh auth login Preferred. Re-uses the user's gh session, no PAT plumbing.
GITHUB_TOKEN Read at request time by the HTTP transport. PAT with repo scope.

Transport selection

anvil sync github                  # transport=auto (default)
anvil sync provider github_issues  # same, generic syntax

The provider's transport kwarg accepts auto, gh_cli, or http. auto probes gh --version and gh auth status once at init: success → gh_cli, either failure → http. The selection is cached for the instance lifetime; construct a new provider to re-probe.

The CLI does not currently expose --transport directly; the provider defaults to auto and that path covers both authenticated gh users and CI runners with GITHUB_TOKEN.

Configured providers (reconciliation)

anvil sync (bare, no subcommand) runs the reconciliation engine, which uses the list of configured providers to decide which tasks count as "done but unmapped" and need a sync.

The optional sync.providers top-level config key narrows that list to an explicit subset (or opts out of sync entirely with an empty list). When the key is absent, the engine defaults to sorted(PROVIDER_REGISTRY) — every registered provider participates.

# .anvil/config.yaml — opt-in subset
sync:
  providers:
    - github_issues
    - linear_issues   # contributor-registered providers also accepted

See sync-providers.md → Per-provider configuration for the full schema, the absent/explicit-list/empty-list semantics, and how the configured list changes reconciliation's missing_sync_mapping discrepancies.


CLI reference

Every subcommand under anvil sync and its flags.

Command Description
anvil sync Run reconciliation only (no provider call). Prints discrepancy report.
anvil sync --fix --yes Apply every suggested fix from reconciliation. --yes required in CI.
anvil sync github Alias for sync provider github_issues. Push + pull every local task.
anvil sync github --push Push only (skip pull). Useful right after apply --approve.
anvil sync github --pull Pull only (skip push). Useful for reconciling remote-side edits.
anvil sync github --task T001 Scope a sync pass to a single task.
anvil sync github --prd P002 Scope a sync pass to one PRD's tasks (multi-PRD; $ANVIL_PRD also works). Ignored when --task is also given.
anvil sync github --health Probe reachability + auth. Exits without touching state.
anvil sync github --fix Force remote_wins on every conflict for this iteration.
anvil sync github --watch Long-running poll loop. Ctrl-C exits.
anvil sync github --watch --interval 30 Override poll cadence (seconds). --interval 0 runs one iteration.
anvil sync provider <id> Generic provider invocation. <id> resolves via PROVIDER_REGISTRY.
anvil sync provider <id> --push --task T001 Single-task push against any registered provider.
anvil sync github --yes Auto-confirm conflict prompts (defaults to local_wins).

Exit codes:

  • 0 — success
  • 1 — generic error (auth missing, provider not registered, etc.)
  • 2 — operator input required (at least one task is parked in manual_merge)

Status label mapping

Every TaskStatus maps to exactly one status:* label, plus a GitHub open/closed state. The mapping is in STATUS_TO_LABEL / LABEL_TO_STATUS / DONE_STATUSES in sync/providers/github_issues.py.

TaskStatus GitHub label Issue state
proposed status:proposed open
drafted status:drafted open
reviewed status:reviewed open
ready status:ready open
claimed status:claimed open
in_progress status:in-progress open
blocked status:blocked open
needs_review status:needs-review open
accepted status:accepted open
done status:done closed
rejected status:rejected open

Only done closes the issue. rejected stays open so a human looking at the repo can see it was actively rejected, not silently archived.

On update, the provider removes every other status:* label it manages before adding the new one. Non-status:* labels (user-added bug, area/*, priority:*, etc.) are preserved across pushes.


Every pushed issue gets a footer appended to its body:

<original task description>

---
_synced from anvil task T001_

The footer is emitted by _compose_body(task_description, task_id) and stripped by _strip_footer(body) on fetch so a round-trip (push → fetch → ExternalTask.body) yields the same text the agent originally wrote. The regex requires the footer to be at the end of the body; intermediate --- separators in the task description are not affected.


Conflict resolution strategies

Each SyncMapping carries a conflict_resolution_strategy enum. When fetch_task returns a remote payload whose last_modified is newer than the local last_synced_at AND the local task's updated_at is also newer, the strategy decides what happens. The emitted sync.conflict_detected event records the choice in resolution.

Strategy Behaviour resolution string
local_wins Record decision; local re-push deferred to the next push pass. local_wins_deferred
remote_wins Record decision; local mutation from remote deferred to the next pull. remote_wins_deferred
prompt Interactive prompt: [local/remote/skip]. Defaults to local on --yes or non-tty. prompt_chose_local, prompt_chose_remote, prompt_skipped, prompt_defaulted_to_local
manual_merge Write .anvil/.sync-conflicts/<task_id>.md; refuse to sync this task. manual_merge_file_written

_deferred is the current contract. Recording a local_wins / remote_wins decision does NOT immediately mutate the other side in this iteration — the mutation rides the next push (for local_wins) or next pull (for remote_wins) pass. A future release (tracked in phase-9-backlog.md) may wire *_applied variants that mutate immediately; until then the deferred contract is the truthful one.

For manual_merge: the markdown file at .anvil/.sync-conflicts/<task_id>.md shows local and remote side-by-side. Resolve the file (edit local or accept remote), delete it, then rerun anvil sync github to continue. The batch exits with code 2 if any task is parked pending manual merge.


Audit honesty

The audit-event stream is the canonical record of what the sync engine actually did vs what was deferred. A deferred conflict-resolution branch never emits sync.pull.completed — only sync.pull.deferred — so the JSONL is safe to grep for "did this task actually update?".

sync.pull.completed vs sync.pull.deferred semantics

Event Meaning
sync.pull.completed The pull was honest: fetch succeeded and the mapping was bumped to a truthful state. Includes (1) clean pull mutated the local Task, (2) tombstone (mapping flipped to external_deleted, audit_note="external_deleted"), (3) no divergence (mapping bumped to in_sync), or (4) local-moved-only — fetch succeeded, no remote movement observed, mapping bumped to local_ahead and a paired sync.push.deferred event fires with resolution="local_moved_no_push".
sync.pull.deferred The pull recorded an intent without mutating local state. Fires on (a) manual_merge (the merge file was written, operator must act — audit_note="manual_merge_pending") and (b) the six deferred conflict-resolution branches (local_wins_deferred, remote_wins_deferred, prompt_defaulted_to_local, prompt_chose_local, prompt_chose_remote, prompt_skipped).

When the local Task has moved ahead of last_synced_at and the remote has not changed, the engine sets sync_state="local_ahead" and emits a sync.push.deferred audit event with resolution="local_moved_no_push" so operators can grep events.jsonl to find tasks awaiting a follow-up --push.

Resolution token vocabulary

Audit-stream-visible resolution strings produced by the sync engine:

Resolution token Emitted on Branch
local_wins_deferred sync.pull.deferred local_wins strategy
remote_wins_deferred sync.pull.deferred remote_wins strategy
prompt_defaulted_to_local sync.pull.deferred prompt strategy on non-tty / --yes
prompt_chose_local sync.pull.deferred prompt strategy, user chose local
prompt_chose_remote sync.pull.deferred prompt strategy, user chose remote
prompt_skipped sync.pull.deferred prompt strategy, user skipped
manual_merge_file_written sync.conflict_detected manual_merge strategy
local_moved_no_push sync.push.deferred local-moved-only pull path

The six local_wins / remote_wins / prompt_* tokens also appear on the paired sync.conflict_detected event (one per conflict) so a forensic query of "show me every deferral and its conflict context" is a single jq over the JSONL.

Querying the audit log

# Every deferred pull this week
jq 'select(.action == "sync.pull.deferred")' .anvil/events.jsonl

# Every task with a local_moved_no_push hint awaiting --push
jq 'select(.payload_json.resolution == "local_moved_no_push") | .target_id' \
   .anvil/events.jsonl | sort -u

# Conflict resolution histogram
jq -r 'select(.action == "sync.conflict_detected") | .payload_json.resolution' \
   .anvil/events.jsonl | sort | uniq -c

Reconciliation

Bare anvil sync runs the ReconciliationEngine only — no provider call, no network. It cross-checks SQLite state vs filesystem (packets/) vs git (branches/worktrees) and prints a discrepancy report.

Discrepancy kinds:

Kind Severity Auto-fix?
orphan_branch warning yes
orphan_packet info yes
orphan_worktree warning yes
stale_claim error yes
missing_sync_mapping warning no (prints CLI hint)
drift_sync_state warning no (prints CLI hint)

anvil sync --fix --yes applies the auto-fixable kinds via the backend or a bounded git subprocess. The two stub-fix kinds (missing_sync_mapping, drift_sync_state) print the operator-facing anvil sync provider <id> --pull --task <id> command in suggested_fix but require manual execution — pushing or pulling requires the provider credentials and conflict-resolution flow, which the reconciliation engine does not own.


Audit events

Every sync mutation emits an event into events.jsonl AND the events table in state.db (replay-from-empty reconstructs the SyncMapping rows from these events).

Action Emitted by
sync.batch.started start of a _run_sync_once pass
sync.batch.completed end of a _run_sync_once pass
sync.push.started per task, before provider.push_task
sync.push.completed per task, on success
sync.push.failed per task, on SyncProviderError
sync.push.deferred per task, on the local-moved-only pull path (resolution="local_moved_no_push") — hints that a follow-up --push is needed
sync.pull.started per task, before provider.fetch_task
sync.pull.completed per task, when the pull was honest (see Audit honesty)
sync.pull.failed per task, on SyncProviderError
sync.pull.deferred per task, when manual_merge or any of the six deferred conflict-resolution branches recorded an intent without mutating local state
sync.conflict_detected per conflict, every strategy
sync_mapping.upserted per successful push (after persist)
sync_mapping.deleted per explicit mapping removal

Filter the JSONL for forensic queries:

jq 'select(.action | startswith("sync."))' .anvil/events.jsonl

Audit emission failures are non-fatal — a sync that succeeded but whose audit row failed to write logs to stderr rather than aborting the sync.


Failure modes

Trigger Surface
GITHUB_TOKEN missing, no gh auth --health reports auth_configured=False with a hint; sync ops exit 1.
gh uninstalled mid---watch Transport flips to http on the next iteration's new provider instance (currently per-watch single instance — re-probe happens on restart). Each iteration prints the error and continues.
Rate-limited RateLimitExceeded → wrapped as SyncProviderError → batch loop continues; that single task gets sync.push.failed / sync.pull.failed.
Issue deleted on remote fetch_task returns None; sync logs external_deleted on stderr; SyncMapping's sync_state flips to external_deleted so anvil sync (reconciliation) surfaces a drift_sync_state discrepancy with payload.reason='external_deleted'.
Provider raises arbitrary exception Caught by the best-effort wrapping loop in _push_one_task / _pull_one_task; surfaced on stderr with exception_type recorded in the audit event; loop continues with the next task.
--watch iteration raises Outer except Exception in _run_watch_loop surfaces the error and keeps polling; the daemon never dies on a single bad pass.

Migration

The sync_mappings table was introduced by a schema bump (additive: new external_url column, new provider_metadata_json column, new UNIQUE(external_system, external_id), FK flipped to ON DELETE CASCADE). Every schema step since then has stayed additive, so a database at any older version auto-upgrades straight through to the current schema on first open — no operator action required. See migrations.md for the full version history, diffs, and rollback notes.


See also

  • sync-providers.mdSyncProvider Protocol + how to add Linear, Monday, Jira.
  • mcp.md — MCP server (does not currently expose sync tools; agents call the CLI directly).
  • migrations.md — schema version history.
  • specs/2026-05-24-anvil-v0.md — historical v0 design record that includes the original Phase 8 sync target; this page is the current sync reference.