# Architecture — the Linear ⇄ GitHub Engine

A small Go server ("the engine") that turns Linear into the control panel for
Claude Code working in this repo. Linear status changes create GitHub issues;
Claude's activity on GitHub drives Linear status back. It replaces the old n8n
bridge entirely — **this engine is the single source of truth for status
movement**. Do not reintroduce any other automation that moves Linear status.

```
Linear  ──webhook──▶  /linear   ┐
GitHub  ──webhook──▶  /github    ├── engine.exe (Go, port 3000, via ngrok)
Netlify ──webhook──▶  /netlify  ┘
        ◀──REST──── GitHub API (create issue, merge PR, close)
        ◀──GraphQL── Linear API (set state, create/update comment)
```

Netlify builds & deploys `main` on every merge; its `/netlify` deploy
notification round-trips the result back onto the issue.

## Where the code lives

The Go module lives in `bridge/` (module name `bridge`):

| File | Responsibility |
| -- | -- |
| `main.go` | HTTP server on `:3000`; routes `/linear`, `/github`, and `/netlify`, plus the local apiframe image proxy (`/generate`, `/job`, `/apiframe` — see "Asset generation vs. the apiframe proxy" below). Passes the `X-GitHub-Event` header into `handleGithub` and the request headers into `handleNetlify`. |
| `apiframe.go` | The apiframe.ai image proxy for the browser tools: `generateImage` (submit a job with `afk_token`), `jobStatus` (poll), and `handleApiframe` (the completion webhook). **Local-only** — not part of the Linear⇄GitHub automation, and not what the CI art workers use. |
| `config.go` | Loads `config.json` at startup (tokens, repo, team key, Claude login, Netlify build hook + optional webhook secret, optional `slite_api_key`). |
| `store.go` | The pairing memory (`store.json`) — Linear↔GitHub maps, Linear identifiers, status-comment IDs, a completed-issues set, seen-comment IDs (revision loop guard), the Slite doc-mirror pairings (`slite_notes`: repo doc path → Slite note ID), and the BR-02 dispatch state (`issue_paths`, `queued_dispatch`, `resolve_attempts`). All access is mutex-guarded; every write persists. |
| `linear.go` | Linear webhook handling. Issue `outstanding` → create GitHub issue (idempotently per Linear id, through the BR-02 ownership gate; records the human identifier); Comment `create` on a `revision`-labelled issue → reopen the GitHub twin and re-tag `@claude`. |
| `linear_api.go` | Linear GraphQL: resolve state IDs, set state, create/update comments, fetch an issue's labels. `addEngineComment` wraps comment creation to record the engine's own comment IDs (revision loop guard). |
| `github.go` | GitHub REST + webhook handling: issue creation, comment/label events, and the completion flow (merge PR, close issue, move Linear to complete). |
| `dispatch.go` | Conflict-proof dispatch (BR-02): the file-ownership concurrency gate that holds a worker queued when its declared paths overlap an in-flight issue, the auto-resolve loop that re-invokes a worker to fix merge conflicts, and the idempotent-twin reservation. Ownership is read fresh from `manifest.json` on every dispatch. |
| `netlify.go` | Handles the `/netlify` deploy notification (optional JWS verify): resolves the issue from the deploy title (the merge commit message) and posts the "latest build" link to Linear. |
| `slite.go` | The Slite doc mirror (BR-01, Path B): on merge, mirrors the PR's changed docs into Slite's browser knowledge base via the Slite Notes write REST API. Self-contained — `syncDocsToSlite` is called from `onCompletion` after the merge; reuses `githubRequest` to read the PR's files + content. **Not** part of the Linear⇄GitHub status automation. |
| `log.go` | One-line aligned logger (`logf(src, level, format, …)`) shared by every handler. Each webhook prints exactly one line: `time · source · glyph · message`. Levels: `→` action (green), `·` skipped no-op (dim), `!` problem (yellow). Colour is emitted only when stdout is a terminal, so piped/CI logs stay plain ASCII. |

`config.json` and `store.json` are gitignored and live only on Dave's machine.
**Never commit secrets.**

## Asset generation vs. the apiframe proxy

There are **two separate worlds** that both touch external image/asset APIs;
they don't share keys, and only one of them runs through this engine.

1. **The engine's apiframe proxy (local).** `main.go`'s `/generate`, `/job` and
   `/apiframe` routes (`apiframe.go`) proxy apiframe.ai using `afk_token` from
   `config.json`. This exists purely so the **browser tools** served over the
   ngrok tunnel (e.g. `assets/prompt_builder.html`, `assets/scene_builder.html`,
   `tools/the-book-of-ruin.html`) can generate images key-side without exposing
   the key in the page. It is the *only* asset API the engine knows about —
   `config.go` carries `afk_token` and nothing for Meshy or Ludo.

2. **Claude worker asset generation (CI).** When a Claude specialist generates
   art/audio/3D for an issue, it runs on a **GitHub-hosted CI runner**
   (`.github/workflows/ClaudeCode.yml`) and calls each API **directly**, never
   through this engine:

   | API | Key (CI secret → env) | How the worker reaches it |
   | -- | -- | -- |
   | Ludo (2D, audio, video) | `LUDO_API_KEY` | injected into the Ludo MCP server header; used via `mcp__ludo__*` tools |
   | Meshy (3D, textures, anim) | `MESHY_API_KEY` | `meshy-3d-generation` skill, direct HTTP |
   | apiframe.ai (Midjourney) | `AFK_API_KEY` | `generating-assets` skill, direct `curl` |

   These keys are **GitHub Actions secrets**, not `config.json` — CI runners
   physically cannot read the local `config.json`, which is why the apiframe key
   is duplicated (as `afk_token` for the proxy and `AFK_API_KEY` for the worker).
   Routing CI asset generation through the engine would require the local machine
   and tunnel to be up for the duration of every art job; the direct-from-CI
   model deliberately avoids that single point of failure.

### Pairing key rule

**Linear `data.id` (UUID) ⇄ GitHub `issue.number`** (the small per-repo number,
e.g. 217 — *not* the global `issue.id`). All GitHub REST write calls take the
number.

## Conventions (exact strings — case matters)

- Linear statuses: `planned`, `outstanding`, `in progress`, `under review`,
  `blocked`, `complete` (all lowercase).
- The `claude` label is the GitHub Action's trigger. The engine **auto-adds it**
  to every dispatched twin, so moving a Linear issue to `outstanding` is enough to
  wake a worker — nobody has to remember the label. It is withheld only for an
  issue queued behind a file-ownership conflict.
- The Linear `revision` label turns a comment into a GitHub revision: a comment
  on a `revision`-labelled issue is forwarded to the paired GitHub issue,
  reopening it and re-tagging `@claude`.
- Completion summaries sit between `<!--linear-summary-->` and
  `<!--/linear-summary-->` in Claude's final GitHub comment.
- Every PR body must include `Closes #<github-issue-number>` so the completion
  flow can find and merge it.

## Flows

| Flow | Trigger | Behaviour |
| -- | -- | -- |
| **Issue creation** | Linear issue moves to `outstanding` | Creates a GitHub issue (title, description + Linear back-link, mirrored labels), stores the pairing. Twin creation is **idempotent per Linear id** (a reservation, so concurrent webhooks make exactly one issue). The **file-ownership gate** holds the worker queued (the `claude` trigger label is withheld) when its declared paths overlap an in-flight issue. See "Conflict-proof dispatch". |
| **Working signal** | `claude[bot]` posts a comment (`created`) on a paired issue | Linear → `in progress`; creates one "Status Update" comment ("Claude is working on this.") and stores its ID. |
| **Progress checklist** | `claude[bot]` *edits* a comment containing markdown checkboxes | Mirrors Claude's task list as a bullet list — each item marked ✅ complete (`- [x]`/`- [X]`), 🔄 in progress (`- [~]`/`- [-]`) or ⬜ remaining (`- [ ]`), order preserved — and edits the same status comment in place — no new comments. |
| **Completion** | Claude's comment (`created` or `edited`) contains the summary markers | Posts the summary, finds the PR, merges it if clean, closes the issue, Linear → `complete`. A **conflicted** PR is auto-resolved (re-invoke the worker) rather than left for a human; on merge, workers queued behind it are dispatched. See below. |
| **Blocked** | GitHub `issues` `labeled` with `blocked` on a paired issue | Linear → `blocked`, and the issue's file-ownership lock is freed so queued work isn't stranded behind it. Removing the label (`unlabeled`) → back to `in progress`. A closed or deleted issue frees its lock the same way. |
| **advise-only** | Completion on an issue carrying the `advise-only` label | Skips all PR work; posts the brief; Linear → `under review`. |
| **Deploy result** | Netlify auto-builds `main` on merge and POSTs a deploy notification to `/netlify` | Resolves the issue from the deploy title (the merge commit message — `issue-<n>` then `DF-<n>`) and posts one "latest build" comment with a link to the build: `Latest Build: [<build>](<link>)` (state `ready`), `❌ Latest build failed` (state `error`), or `🔨 Latest build started` (in-flight). |
| **Slite doc mirror** | A PR is merged in the completion flow | Mirrors the PR's changed markdown docs (`docs/**`, `*/docs/**`, `underworld/style-manual/`) into Slite via the Notes REST API — upsert on add/modify, delete on remove, re-key on rename. Pairing in `slite_notes`. No-op without `slite_api_key`. See below. |
| **Revision** | A Linear comment is `create`d on a `revision`-labelled paired issue | Reopens the GitHub twin (`PATCH state:open`), posts the comment body prefixed with `@claude` (re-triggers Claude Code), and clears the issue's completion guard so the next summary completes again. See below. |

### Revision flow (`onLinearComment`, `linear.go`)

Lets a designer reopen finished work from Linear: comment on the issue with the
`revision` label and the engine hands the comment to Claude on GitHub.

1. **Loop/retry guard** — skip any comment ID already in the store's
   `seen_comments`. This is the critical guard: the engine records *its own*
   Linear comments (status updates, summaries, deploy results) there via
   `addEngineComment`, so they never bounce back as revision tasks; forwarded
   human comments are recorded too, so a webhook retry can't double-post.
2. **Resolve the pairing** — map the comment's issue (`issueId`, or nested
   `issue.id`) to the GitHub number; no pairing → ignore.
3. **Gate on the label** — fetch the issue's labels from the Linear API
   (`issueLabelNames`; the comment payload doesn't carry them reliably) and only
   proceed if `revision` is present.
4. **Forward** — `reopenGithubIssue` (idempotent), then post `@claude\n\n<body>`
   as a GitHub comment. The engine's GitHub token is **not** `claude_github_login`,
   so this comment doesn't re-enter the working/completion flow, but it does fire
   the GitHub Action's `@claude` trigger.
5. **Reset completion** — `clearCompleted` so onCompletion's idempotency guard
   doesn't ignore the revision's new summary; mark the comment seen.

### Completion flow (`onCompletion`, `github.go`)

Triggered by the summary markers on a `claude[bot]` comment (`created` *or*
`edited` — Claude sometimes edits a checklist comment rather than posting fresh):

1. **Idempotency guard** — if this issue is already recorded as completed in the
   store, ignore the repeat (handles created+edited and event replay). The issue
   is only marked completed once it genuinely finishes, so an error path (no PR,
   conflicts) can still be retried by a later edit.
2. **advise-only** — if the issue has the `advise-only` label: update the status
   comment, post the summary, set Linear → `under review`, mark completed, stop.
   No PR is found or merged.
3. **Find the PR**:
   - Primary: an open PR whose body has a closing keyword + `#<issue-number>`
     (`Closes`/`Fixes`/`Resolves`, with a word boundary so `#2` ≠ `#23`).
   - Fallback: an open PR whose head branch contains `issue-<n>`
     (branch convention `claude/issue-<n>-<timestamp>`).
   - No match → comment on the GitHub issue, leave Linear at `in progress`.
4. **Merge** — re-fetch the PR until GitHub has computed `mergeable` (it is
   `null` on first read; up to 3 tries ~2s apart). If `merged` already, skip.
   If mergeable, `PUT …/merge` (method `merge`). If **`mergeable == false`** (a
   real conflict), **auto-resolve** instead of leaving it for a human (see
   "Conflict-proof dispatch"). If mergeability is still uncomputed after the
   retries, comment and leave it.
5. **Close the issue** — `Closes #n` closes it automatically on merge; verify and
   `PATCH state:closed` if still open (idempotent).
6. **Finish** — update the status comment to `✅ Done — summary below.`, post the
   summary as its own Linear comment, Linear → `complete`, mark completed, reset
   the resolve-attempt counter, and **dispatch any workers queued behind this
   issue** now its files are free (`releaseQueued`).

On a successful merge the completion flow also calls `syncDocsToSlite` to mirror
the PR's changed docs into Slite — see the Slite doc mirror section below.

### Conflict-proof dispatch (`dispatch.go`, BR-02)

**The rule: one worker per file at a time.** Two workers editing the same file
at once produce merge conflicts the engine used to drop on a human. The engine
now prevents most of them and cures the rest, so disjoint work still runs fully
in parallel while work that touches the same files is serialised.

**Ownership comes from an explicit `Touches:` line in the brief** (e.g.
`Touches: games/SL-sludge/sim/sim.js`), read on every dispatch. Disciplines were
removed, so the `manifest.json` `disciplines` map is empty and label-based
ownership yields nothing — the `Touches:` hint is the only signal.

If an issue declares no paths, ownership is *unknown* and the gate treats it as
**non-conflicting**: it runs rather than serialising. Only a provable same-file
overlap (two briefs whose `Touches:` sets collide) holds a worker back; genuine
collisions that slip through are caught by the auto-resolve cure.

**1. Prevention — the concurrency gate (`onOutstanding`).** Before a worker is
dispatched, `conflictingInFlight` compares its path-set against every *in-flight*
issue (paired, dispatched, not yet completed). Overlap is decided by
`ownershipConflict`: each glob is reduced to a path prefix (trailing `/`, `/*`,
`/**` stripped) and two prefixes overlap when one is a component-wise prefix of
the other — so `sludge/sim/` blocks `sludge/sim/sim.js` but not `sludge/render/`,
and `engine/engine.js` ≠ `engine/engine.test.js`. On overlap the twin is created
**without the `claude` trigger label** (so the GitHub Action doesn't start) and
recorded **queued**; with no overlap it's created with all labels and recorded
in-flight. Queued issues hold no files, so they don't block each other.

**2. Cure — auto-resolve (`autoResolveConflict`).** When a completed PR comes
back `mergeable == false`, instead of leaving it the engine **re-invokes the same
worker** — it has the change's context, and these conflicts are typically
mechanical. It reopens the twin and posts an `@claude` task ("merge the latest
`main` into your branch, resolve the conflicts, re-run checks, push to the same
branch — don't open a new PR"), clearing the completion guard so the worker's
fresh summary re-enters `onCompletion` and the merge is re-attempted. After
`maxResolveAttempts` (3) it falls back to the old behaviour: comment and leave it
for a human. The attempt counter is reset on a successful merge. This reuses the
revision flow's machinery (reopen + `@claude` comment + `clearCompleted`).

**3. Release (`releaseQueued`).** A merge frees an issue's files, so the
completion flow calls `releaseQueued`, which dispatches every queued worker whose
conflicts have now cleared by **adding the `claude` label** (firing the Action).
It dispatches one at a time, re-checking after each, so two queued issues that
overlap *each other* don't both start.

**Idempotent twin creation (`reserveTwin`).** `onOutstanding` first atomically
*reserves* creation for the Linear id: it returns false if a twin already exists
**or** another in-flight webhook is mid-creation. This closes the SL-13
double-fire, where a description edit and the state→`outstanding` change arriving
together raced the old check-then-create and made two GitHub issues for one
Linear id. The reservation is in-memory (`creating`, not persisted); the durable
guard remains the `LinearToGithub` pairing.

**Dispatch state in the store** (`store.json`): `issue_paths` (GitHub # → owned
path prefixes), `queued_dispatch` (GitHub # → dispatch withheld), and
`resolve_attempts` (GitHub # → auto-resolve count). All mutex-guarded like the
other pairings.

> **First-run caveat (BR-01 house rule):** the gate and auto-resolve loop were
> built and verified by reading + `go build ./... && go vet ./...`; the live
> webhook loop can't run in CI without secrets. Confirm the queue/release and the
> first real auto-resolve on a live conflict.

### Slite doc mirror (`slite.go`, BR-01 Path B)

Slite (Super) is the team's browser-based knowledge base. **The repo stays the
single source of truth** — instances keep writing docs as markdown in the repo;
Slite mirrors them so the team reads always-current docs without opening `.md`
files. Two mechanisms keep it current, only one of which is engine code:

- **Path A (Slite-side, no repo code):** Slite's GitHub Agent watches the repo
  (`docs/**`, per-product `*/docs/**`, `underworld/style-manual/`) and drafts doc
  updates for a human to approve. Configured in Slite, not here.
- **Path B (this engine):** `syncDocsToSlite(cfg, store, prNum)` runs from
  `onCompletion` immediately after a PR merges. It lists the merged PR's changed
  files (`GET /pulls/{n}/files`), keeps the markdown docs (`isDocPath`: `.md`
  under `docs/`, any `*/docs/`, or `underworld/style-manual/`), reads each from
  the default branch (contents API, raw media type), and **upserts** the matching
  Slite note. Removed docs delete their note; renamed docs carry the note across
  by re-keying the mapping. The note title is the doc's first H1 (else its
  filename).

Why hook on merge rather than a `push` webhook: the engine's GitHub webhook only
subscribes to Issues/Comments/PRs, and the engine merges doc PRs itself — so the
merge in `onCompletion` is the natural trigger, and no new webhook subscription
is needed. Docs reach Slite exactly when they reach the default branch.

**Pairing:** `store.json`'s `slite_notes` maps repo doc path → Slite note ID,
the same way issue/PR pairings are stored — so an edit updates the same note
instead of creating duplicates.

**Auth:** `slite_api_key` in `config.json`, sent as the `x-slite-api-key` header.
`SLITE_API_KEY` is also a repo-level GitHub Actions secret; like `afk_token` ↔
`AFK_API_KEY`, the engine reads the **local config** value (the mirror runs in
the always-on Bridge, not CI). If `slite_api_key` is empty the mirror is a
logged no-op, so the engine is safe to run before the key is provisioned. Never
commit the key.

**The Super MCP is not used.** `https://api.super.work/mcp` is read-only (one
search tool, `query-super-sources`) and OAuth-based — it is not a write path and
does not fit a headless writer, so it is **not** added to `ClaudeCode.yml`.
Writes go through the Slite Notes REST API above. As an optional nicety, a team
member may add the Super MCP as a custom connector in their **own** Claude/desktop
settings (OAuth) to *search* Slite — nothing to build or deploy.

> The Slite Notes payload sends the doc's markdown as the `markdown` **string**
> field. The first real merge proved the original `markdown: true` + `content`
> shape wrong (the API 422'd — `body.markdown` "invalid string value"); `markdown`
> *is* the content.

### Netlify deploy flow (`netlify.go`)

Netlify **builds and deploys the production branch (`main`) on every push**
(`netlify.toml` → `[build] ignore` builds when `$BRANCH` is `main`, skips other
branches). So every merged issue goes live on its own — the engine does **not**
trigger builds; it only reports the resulting deploy back onto the issue.

- **Result** (`handleNetlify`, route `/netlify`) — Dave configures outgoing
  Deploy notifications (succeeded + failed, and optionally "deploy started")
  POSTing here. Netlify titles a git-built deploy with the **merge commit
  message** (e.g. `Merge pull request #N from owner/claude/issue-<n>-… [DF-218]
  …`), so `linearForDeployTitle` reads the paired issue straight out of it: the
  branch's `issue-<n>` first (the central GitHub pairing), then a `DF-`-style
  identifier as a fallback. It then posts one **"latest build" comment with a
  link to the build** (`admin_url + /deploys/<id>`, falling back to the live URL),
  labelled with a short build number (`commit_ref`): `Latest Build:
  [<build>](<link>)` for state `ready`, `❌ Latest build failed: …` for state
  `error`, and `🔨 Latest build started: …` for any in-flight state
  (new/enqueued/building/…). Titles with no resolvable issue are ignored (this is
  a public route), which also blocks comment-injection on arbitrary issues.
- **Signature** — if `netlify_webhook_secret` is set, the `X-Webhook-Signature`
  header (a compact HS256 JWS whose payload carries the body's hex SHA-256 in the
  `sha256` claim) is verified before acting; bad/missing signatures are dropped.
  With no secret configured, verification is skipped.

The build link in the "latest build" comment comes from the deploy
notification's payload (`admin_url`/`id`/`commit_ref`), so it always points at the
build the notification is about.

`netlify_webhook_secret` is a trigger secret — it lives in the gitignored
`config.json`, never committed. `netlify_build_hook` is retained in the config for
any manual/ad-hoc deploy trigger but is no longer used by the completion flow.

## API gotchas (respect these)

- **Linear GraphQL** (`https://api.linear.app/graphql`): the `Authorization`
  header is the API key **as-is — no `Bearer` prefix**. State changes need the
  state's internal ID; `linearStateID()` resolves it by name within team `DF`.
- **GitHub REST**: `Authorization: Bearer <token>`, plus
  `Accept: application/vnd.github+json`, `X-GitHub-Api-Version: 2022-11-28`, and
  a `User-Agent`. All built by `githubRequest()`.
- GitHub PR `mergeable` is computed lazily — `null` on first fetch. Re-fetch
  before deciding (`fetchMergeablePR`).
- The repo webhook must be content type `application/json` and subscribe to
  **Issue comments, Issues, Pull requests**. The engine routes on the
  `X-GitHub-Event` header and logs+ignores event types it doesn't handle.
- The **Linear webhook** must include **Comments** (alongside Issues) for the
  revision flow to fire — `handleLinear` switches on `data` shape by `type`
  (`Issue` vs `Comment`), so the `data` field is decoded as `json.RawMessage`
  and unmarshalled per type.
- Config loads at startup only. Dave rebuilds and runs locally:
  `go build -o engine.exe . && .\engine.exe` (stable exe name avoids Windows
  Firewall re-prompts).

## Code style

Plain structs; free functions taking `Config`/`Store` explicitly; no methods or
interfaces; standard library only (no new dependencies). One `fmt.Printf` per
arriving event before filtering, one per action taken, one per deliberate ignore
with the reason.

## Out of scope (backlogged)

Posting to Linear as a distinct "Claude (Discipline Specialist)" actor (OAuth app
actors). Do not attempt.
