Skip to content

GitHub watcher

The github-watcher child keeps GitHub issues and Lithos tasks aligned in both directions, and — for PRs that story-develop delivers — watches them to merge, keeps them landable, and ingests the reviews people leave on them. It runs on one host only.

Enabling it

Two gates, one per host and one per project.

Per host — [github_watcher] present with enabled = true. The watcher resolves a bearer token from gh auth token at startup, so gh must be on PATH and logged in. There is no election: pick one host and leave the block out (or false) everywhere else.

[github_watcher]
enabled                     = true
poll_interval_seconds       = 60      # incremental issue polls
coord_doc_path              = "projects/_lithos-loom-internal/github-watcher-state.md"  # per-repo cursors, in Lithos
resolved_replay_days        = 7       # replay window for Lithos→GH mirroring at bootstrap; 0 disables
reconcile_interval_minutes  = 60      # the periodic sweep (below); 0 disables
pr_merge_poll_enabled       = true    # resolve `pr` gates on merge
external_reviews_enabled    = true    # ingest PR reviews/comments as [ExternalReview]
external_remediation_budget = 2       # autonomous `converge --from-github` rounds per PR; 0 disables
merge_gate_enabled          = true    # trial-merge re-gate when the base moves
conflict_resolve_enabled    = true    # `converge --resolve-conflicts` on a named conflict
trusted_bots                = ["copilot-pull-request-reviewer[bot]"]

Per project — metadata on the project-context doc, managed by the CLI (see project GitHub subcommands):

lithos-loom project add-github-repo my-project agent-lore/my-repo   # repeatable; a project may map several
lithos-loom project enable-github  my-project                        # sets github_watch_enabled = true
lithos-loom project disable-github my-project                        # keeps the repo list
lithos-loom project migrate-github-tags [--dry-run]                  # one-shot: legacy github-* tags → metadata
Metadata key Meaning
github_watch_enabled true enrols the project; takes effect mid-run without a restart
github_repos owner/name list, each polled independently
github_exclude_labels / github_exclude_authors Drop an issue at import time only (e.g. dependabot[bot]); already-linked tasks are never stranded by a later exclude

GitHub → Lithos

Each poll lists every open issue on the first pass (no cursor) and, thereafter, everything updated since the per-repo updated_at cursor — so closes on known issues surface alongside new opens. Pull requests are filtered out at parse time. The link between an issue and its task is a <!-- lithos:<task_id> --> marker written into the issue body:

Situation Action
No marker, no task carries this URL, issue open lithos_task_create(title, description=body, tags=labels + ["github-issue"], metadata={project, github_issue_url, github_issue_number, github_labels, github_state_snapshot}), then write the marker into the issue body (re-fetched immediately before the PATCH so a concurrent edit survives)
No marker, no task, issue closed Skipped — historic closures are not backfilled
No marker, but a task carries metadata.github_issue_url for it Re-write the marker; no duplicate task
Marker → open task Drift sync: title, body (marker stripped), and labels (diffed against the github_labels snapshot, so operator-added Lithos tags survive) — one task_update per changed poll, zero round-trips when nothing changed
Marker → open task, issue closed as completed / not_planned Drift sync + lithos_task_complete / lithos_task_cancel
Marker → terminal task, issue reopened Post a one-shot [ReopenRequested] finding (de-duped on github_state_snapshot). Loom does not reopen the task itself.
Marker → missing task (force-deleted) Create a fresh task and overwrite the stale marker

Cursors and a per-repo set of issues whose dispatch failed persist in the coord doc (CAS-protected), so a restart between a partial reconciliation and its retry loses nothing. A 404 on a repo drops it from the in-memory watch list with a [Friction] log line until the metadata is fixed; a rate-limit response sleeps until X-RateLimit-Reset.


Lithos → GitHub

The auto-wired github-issue-push handler consumes lithos.task.* events for tasks carrying metadata.github_issue_url (everything else is filtered at entry):

Lithos event GitHub action
task.completed PATCH state=closed, state_reason=completed (unless already so)
task.cancelled PATCH state=closed, state_reason=not_planned
task.updated (and bootstrap-replayed task.created) PATCH title when it differs

Pushes re-fetch the issue before every PATCH, so they are idempotent. Transient errors retry with exponential backoff (up to 8 attempts, ~3 minutes); auth and repo-404 errors are logged as [Friction] and dropped. Outages longer than the retry budget are caught by the reconciliation sweep: every reconcile_interval_minutes the child re-dispatches every open task plus those resolved within resolved_replay_days through the push handler. A linked issue that was deleted posts [LinkedIssueGone] once and writes a github_issue_gone_url marker that silences further pushes until the task is re-linked.


PR-gate resolution

The same sweep is the home of every PR-maintenance behaviour on the pr gates the route-runner (or develop deliver) raises. For each still-open gate it reads repo / pr_number, finds the gated story through the waits_on_gate edge, fetches the PR and acts:

PR state Action
Merged Complete the story first, then the gate; post [GateResolved]; nudge every task the story's completion unblocked so the route-runner picks it up
Closed unmerged / deleted Convert the stranding into a loom human gate on the story (pr_closed_unmerged / pr_gone), post [DeliveredPRClosed] and [NeedsHuman] with the three choices (work landed elsewhere → complete the story; re-develop → complete the gate; abandon → cancel the story), then complete the pr gate — never cancel it
Story already terminal (completed by hand or via the issue mirror) Complete the gate; the PR is the operator's now
Still open Run the dispatchers below, then write reconciliation_state / reconciliation_detail / reconciliation_since on the gate — the one place Lens, lithos-loom gates and you read "where is this PR right now"

The dispatchers, each gated by a host dial and a per-project develop_* opt-out on the context doc, each spawning a Loom CLI command as a crash-isolated subprocess and recording a URL-scoped marker on the gate so nothing runs twice on the same inputs:

Behaviour Trigger Host dial / project key Outcome
Landability every sweep — GitHub reports a conflict → one-shot [PRConflicted] per (head, base)
External-review ingestion new reviews, inline comments or Conversation-tab comments external_reviews_enabled [ExternalReview] on the story; pure approvals are listed but never remediated; high-water marks on the gate de-dup
Autonomous remediation a batch with at least one trusted item (allowlisted bot, or a human with repo write/admin) external_remediation_budget, trusted_bots / develop_external_review_converge develop converge <pr> --from-github --story <id>; one in flight globally; the per-PR round counter never resets on Loom's own pushes and resets on a human push; exhaustion raises a remediation_exhausted human gate
Base-move re-gate (head, base, settings) changed merge_gate_enabled / develop_merge_gate develop merge-gate <pr> --story <id>: green and behind → merge commit pushed (append-only); red → [MergeGateFailed]; conflict → paths added to [PRConflicted]
Conflict resolver the re-gate named a conflict conflict_resolve_enabled / develop_conflict_resolve develop converge <pr> --resolve-conflicts once per sha pair; pushed → [ConflictResolved]; unresolved → conflict_unresolved human gate

All of them hold each other per PR (any one may push to the branch), are refused before any subprocess when the mapped checkout's origin does not match the gate's repo, and fail closed on an unreadable project doc. lithos-loom drain lets a sweep in flight finish and refuses new dispatch; a round the daemon itself kills at shutdown is refunded.

Where the state lives

Findings are history. The current state of a delivered PR is the set of flat, metadata_match-queryable keys the sweep alone writes on its pr gate (reconciliation_state, pr_landability, merge_gate, conflict_resolve, external_remediation, external_review_seen, …). The route-runner, Lens and the CLI read them; a webhook could too.