<!-- source: openspec/changes/linear-as-agent-control-plane-openspec/design.md -->

---
title: Design — Linear ↔ OpenSpec sync (Phase 1)
audience:
  - architect
  - executor
---

# Design — Linear ↔ OpenSpec sync (Phase 1)

## 1. Canonical guidance source

Rationale: Linear's Agent Guidance field is a mirror, not a source — editing in two places drifts within a sprint. Repo doc stays canonical so it diffs with code, reviews via PR, and survives Linear workspace migrations. CI is the only writer to the Linear field; humans editing it directly is explicitly out of contract (CI overwrite is the enforcement).

Contract:

```
docs/linear-agent-guidance.md
  ├── front-matter: { version: <semver>, last_synced: <ISO> }  # version bumped manually
  └── body: plain markdown, ≤ 8 KB (Linear field limit ~10 KB; leave headroom)
```

Sections required in body (assertion-tested):
- `## Naming` — RAP-id format, branch convention
- `## OpenSpec` — change-folder path, proposal/design/tasks/verification roles
- `## Acceptance` — falsifier-required rule, verification.md as DoD
- `## Workflow` — how agents should claim, branch, and PR

Edge cases:
- File > 8 KB ⇒ CI fails before push to Linear (loud, not silent truncate).
- Front-matter `version` unchanged but body diffs ⇒ warn but still sync; principal owns version discipline.

## 2. Sync tool layout

Rationale: Single Node package, no bundling, callable both from CI and from a local `pnpm` script for dry-runs. Handlebars over string templating because the block has conditional sections (spec deltas may be empty, tasks may not exist yet). Kept under `tools/` not `packages/` because it has no runtime consumers in apps.

Contract:

```
tools/openspec-to-linear-sync/
  ├── package.json              # name: @rapoport/openspec-linear-sync, private
  ├── src/
  │   ├── cli.ts                # entry: `sync-guidance` | `sync-change <pr-number>`
  │   ├── linear.ts             # GraphQL client, typed mutations
  │   ├── openspec.ts           # parse change-folder, extract deltas/tasks/criteria
  │   ├── render.ts             # Handlebars render
  │   ├── markers.ts            # block injection / replacement
  │   └── rap-id.ts             # extract RAP-id from PR title or branch
  ├── templates/
  │   └── change-block.md.hbs
  └── test/
      ├── fixtures/             # sample change-folders + expected blocks
      └── *.spec.ts
```

CLI surface:

```ts
// sync-guidance: idempotent push of docs/linear-agent-guidance.md to workspace
sync-guidance --workspace <id> [--dry-run]

// sync-change: render OpenSpec block into the Linear issue body for one PR
sync-change --pr <number> --repo <owner/repo> [--dry-run]
```

Exit codes: `0` success, `1` user error (missing RAP-id, no linked change), `2` Linear API error, `3` parse error in change-folder.

## 3. Marker block contract

Rationale: HTML comments survive Linear's markdown rendering and are invisible to humans. Single start/end pair (not nested) because we only own one block per issue. Re-running must be byte-identical for unchanged inputs to avoid Linear churn / notification spam.

Contract:

```
<!-- openspec:start change-id=<kebab-id> -->
…rendered content…
<!-- openspec:end -->
```

Replacement algorithm:
1. Fetch `issue.description` via Linear GraphQL.
2. Regex `/<!-- openspec:start[^>]*-->[\s\S]*?<!-- openspec:end -->/`.
3. If match: replace. If no match: append with `\n\n` separator.
4. If rendered block === existing block (string equality): skip mutation entirely.

Edge cases:
- Multiple matches (corrupt state) ⇒ replace the first, delete the rest, log warning.
- `change-id` in start marker differs from current change-id ⇒ still replace (PRs can rename folders); log info.
- Issue description is `null` ⇒ treat as empty string.

## 4. Rendered block template

Rationale: Mirrors the issue body's example exactly; agents have been trained on that shape. Links resolve to `github.com/<repo>/blob/<sha>/...` pinned to the PR head SHA so the block is reproducible even after merge. Task progress is `N / M complete` not a checklist — checklists in a synced block would confuse Linear's task tracker.

Contract (`change-block.md.hbs`):

```handlebars
## OpenSpec Change · {{changeId}}

**Proposal:** [proposal.md]({{links.proposal}})
**Design:** [design.md]({{links.design}}) · **Tasks:** {{tasks.done}} / {{tasks.total}} complete
**Verification:** [verification.md]({{links.verification}})

### Spec deltas
{{#each deltas}}
- {{op}}: {{count}} requirement{{plural count}} in `{{capability}}`
{{else}}
- _none_
{{/each}}

### Acceptance criteria (from verification.md)
{{#each criteria}}
- [ ] {{this}}
{{/each}}
```

Input shape:

```ts
interface BlockContext {
  changeId: string;                          // kebab-case folder name
  links: {
    proposal: string;
    design: string;
    tasks: string;
    verification: string;
  };
  tasks: { done: number; total: number };
  deltas: Array<{
    op: 'ADDED' | 'MODIFIED' | 'REMOVED' | 'RENAMED';
    count: number;
    capability: string;                      // capability spec name
  }>;
  criteria: string[];                        // extracted falsifiers + DoD lines
}
```

Edge cases:
- `verification.md` missing ⇒ `criteria` empty, link omitted, CI emits warning (not failure — verification can be added in a follow-up PR within the same change).
- `tasks.md` missing ⇒ tasks line reads `Tasks: not yet authored`.

## 5. OpenSpec parser

Rationale: Reuse no upstream OpenSpec tooling — it's pre-1.0 and parsing rules drift. Local parser is ~80 LOC and pinned to the conventions we actually emit. Operates on markdown AST (`remark-parse`) not regex, because proposal sections nest.

Contract:

```ts
interface ChangeFolder {
  id: string;                                // folder name
  path: string;                              // absolute path
  proposal: ProposalDoc;
  design?: DesignDoc;
  tasks?: TasksDoc;
  verification?: VerificationDoc;
  specs: SpecDelta[];                        // from specs/ subfolder
}

interface TasksDoc {
  total: number;                             // count of `- [ ]` or `- [x]` items
  done: number;                              // count of `- [x]` items
}

interface VerificationDoc {
  scenarios: string[];                       // bullets under ## Scenarios or ## Acceptance
  falsifiers: string[];                      // bullets under ## Falsifier
}

interface SpecDelta {
  capability: string;                        // e.g. 'auth-session'
  added: number;                             // count of ADDED requirements
  modified: number;
  removed: number;
}
```

Parser rules:
- Tasks: top-level checklist items only (no nested).
- Verification criteria: union of `scenarios` (rendered as "All N scenarios in `<cap>` pass" if scenarios cluster by capability) + `falsifiers` verbatim.
- Spec deltas: read `specs/<capability>/spec.md`, count `### Requirement` blocks under `## ADDED` / `## MODIFIED` / `## REMOVED` headers.

Edge cases:
- Malformed proposal (missing required H2) ⇒ exit 3 with line number.
- Specs folder absent ⇒ `deltas: []`, not a failure.

## 6. RAP-id resolution

Rationale: One source — PR title or branch — not both, to avoid disagreement. Title takes precedence (humans edit titles when correcting). Branch convention is the fallback for agents that don't author titles. Fail loudly on missing — silent skip turns the feature into a coin flip.

Contract:

```ts
// Precedence: title > branch
function extractRapId(pr: { title: string; headRef: string }): string {
  const TITLE_RE = /\bRAP-(\d+)\b/;
  const BRANCH_RE = /^pavel\/rap-(\d+)-/;
  return (
    pr.title.match(TITLE_RE)?.[0]
      ?? (pr.headRef.match(BRANCH_RE) && `RAP-${pr.headRef.match(BRANCH_RE)![1]}`)
      ?? throwLoud('No RAP-id in PR title or branch')
  );
}
```

Linear issue lookup: `issueVcsBranchSearch(branchName: $headRef)` GraphQL query, falling back to `issue(id: "RAP-<n>")` by identifier.

Edge cases:
- Multiple RAP-ids in title (e.g. `RAP-1116 closes RAP-900`) ⇒ first match wins, others logged.
- RAP-id present but issue doesn't exist / archived ⇒ exit 1, do not create.
- PR linked to multiple issues via Linear's auto-link ⇒ sync only the one matching the extracted RAP-id; ignore others (multi-issue PR is explicitly out of scope).

## 7. Linear GraphQL surface

Rationale: Use `@linear/sdk` rather than raw GraphQL — typed, rate-limit-aware, handles pagination. Token is a workspace-scoped API key in GitHub Actions secrets (`LINEAR_API_KEY`); rotating it doesn't require code changes.

Contract:

```ts
// Mutations used
issueUpdate(id: ID!, input: { description: String }): IssuePayload
organizationUpdate(input: { ... }): OrganizationPayload   // for Agent Guidance field

// Queries used
issue(id: "RAP-1116"): Issue
issueVcsBranchSearch(branchName: String!): IssueConnection
organization: Organization                                // read current guidance for diff
```

Agent Guidance field name: TBD by Linear API surface — confirmed via `organization` query before first sync; tool fails with a clear error if the field is unavailable (Linear hasn't exposed it via API yet, in which case Layer 1 falls back to a manual paste with CI emitting a notification to the principal that the doc changed).

Edge cases:
- Rate limit (429) ⇒ `@linear/sdk` retries with backoff; cap at 3 retries then exit 2.
- API schema drift ⇒ pin `@linear/sdk` version; renovate PRs surface upgrades.

## 8. CI workflow

Rationale: Two distinct triggers, two distinct jobs, same tool. Separating them keeps the failure surface narrow — a broken guidance doc doesn't block PR sync, and vice versa.

Contract (`.github/workflows/openspec-linear-sync.yml`):

```yaml
on:
  pull_request:
    types: [opened, synchronize, reopened, edited]
    paths: ['openspec/changes/**']
  push:
    branches: [main]
    paths: ['docs/linear-agent-guidance.md']

jobs:
  sync-change-block:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 2
    steps:
      - checkout
      - setup-node + pnpm install --filter @rapoport/openspec-linear
