<!-- source: openspec/changes/automate-spec-to-build-loop/design.md -->

---
title: Design — Automate change-first spec→build loop
audience:
  - architect
  - executor
---

# Design — Automate change-first spec→build loop

## 1. Linear write primitives in `core/linear-client.ts`

Rationale: Audit commands already implement `createIssue` / `ensureLabels` / `createRelation` / `findExistingIssueForChange` against the raw Linear GraphQL endpoint. The `linear-as-agent-control-plane` capability constrains `@linear/sdk` to `tools/`; forge core must use raw GraphQL. Lifting these into `core/linear-client.ts` (a) keeps `forge open-issue` and audit consistent, (b) prevents drift between the two write paths, and (c) gives us a single place to thread the `change/<slug>` label claim (B2) without re-implementing GraphQL ergonomics. Audit code is refactored onto the new primitives in the same change — no parallel implementations.

Contract:

```ts
// packages/forge/src/core/linear-client.ts

export interface LinearWriteClient {
  ensureLabels(teamId: string, names: string[]): Promise<Map<string, string>>; // name → labelId
  createIssue(input: CreateIssueInput): Promise<{ id: string; identifier: string; url: string }>;
  createRelation(input: { issueId: string; relatedIssueId: string; type: 'blocks' | 'duplicate' }): Promise<void>;
  findExistingIssueForChange(slug: string): Promise<{ id: string; identifier: string; state: string } | null>;
  // B2 claim: attempt to attach `change/<slug>` label; returns false if another issue already owns it.
  claimChangeSlug(slug: string, issueId: string): Promise<{ claimed: boolean; ownerIssueId?: string }>;
}

export interface CreateIssueInput {
  teamId: string;
  title: string;
  description: string;          // markdown
  labelIds: string[];           // includes `change/<slug>` + any user-supplied
  stateId: string;              // 'Todo' for simple, 'Backlog' for epic parent + sub-issues
  parentId?: string;            // epic sub-issues
  priority?: number;
}
```

Edge cases / failure modes:
- GraphQL error envelopes (`errors[]` with `200 OK`) must throw; do not return success on `data: null`.
- Label name collisions: `ensureLabels` is idempotent — query first, create missing, return merged map.
- `findExistingIssueForChange` queries by `change/<slug>` label, not title substring (avoids the title-matching foot-gun the epic explicitly calls out).
- Rate limits: surface `429` / `RATELIMITED` as a typed error; caller decides retry policy.

## 2. Auto-build marker reader

Rationale: Proposals have no YAML frontmatter (verified D-IT-2 / `linear-as-agent-control-plane`). The marker is a blockquote line in `proposal.md`. We need a permissive reader that tolerates leading whitespace, varying capitalisation, and `**bold**` markup, but is strict enough that documentation prose ("you can set `Auto-build: true`") never accidentally flips the bit. Three fixtures pin the exact accepted/rejected forms.

Contract:

```ts
// packages/forge/src/core/auto-build-marker.ts

export interface AutoBuildMarker {
  present: boolean;
  value: boolean;
  rawLine?: string;            // for diagnostics
}

export function readAutoBuildMarker(proposalMarkdown: string): AutoBuildMarker;
```

Accepted (matches `^\s*>\s*\*\*Auto-build:\*\*\s*(true|false)\s*$` after trimming markdown emphasis variants):
- `> **Auto-build:** true`
- `>   **Auto-build:**   false`
- `> **auto-build:** TRUE` (case-insensitive on key + value)

Rejected (must return `{ present: false }`):
- Marker inside fenced code block.
- Marker in prose without leading `>` (e.g. `Auto-build: true` in a paragraph).
- Marker inside a nested list item without blockquote prefix.

Edge cases / failure modes:
- Multiple markers: first one wins, second logged as a warning to stderr (not failure — keeps deterministic behaviour).
- File missing or unreadable: `{ present: false }`, caller decides whether that's an error.

## 3. `forge open-issue <slug>` command

Rationale: Deterministic, no LLM in the hot path — the change folder is the source of truth. The command is invoked by `forge-on-change-merge.yml` after marker check passes. It must handle both shapes (simple, epic) and emit machine-readable output for the workflow to consume and dispatch the right issue(s).

Contract:

```ts
// packages/forge/src/commands/open-issue/index.ts

export interface OpenIssueArgs {
  slug: string;                 // change folder slug
  changesDir: string;           // default: openspec/changes
  dryRun?: boolean;
  json?: boolean;
  teamId?: string;              // overrides config
}

export interface OpenIssueResult {
  shape: 'simple' | 'epic';
  created: Array<{
    identifier: string;         // RAP-1234
    id: string;                 // Linear uuid
    role: 'simple' | 'epic-parent' | 'epic-phase';
    phase?: number;             // for epic-phase
    wave?: number;              // contiguous wave number for blockedBy chain
    blockedBy?: string;         // identifier of prior wave
    dispatch: 'immediate' | 'gated';
  }>;
  claim: { slug: string; ownerIssueId: string };
  skipped?: { reason: 'no-marker' | 'marker-false' | 'already-claimed'; ownerIssueId?: string };
}
```

Behaviour:
1. Read `openspec/changes/<slug>/proposal.md`; call `readAutoBuildMarker`. If absent or `false`, exit 0 with `skipped`.
2. Parse `tasks.md`:
   - Flat checklist → **simple** shape.
   - Contains `## Phase N` headers → **epic** shape; for each phase run `isBuildablePhase()` (≥1 actionable `- [ ]`, ignore commented-out lines).
3. Compute contiguous wave numbers across **buildable** phases only (skipped phases do not consume a wave index).
4. For simple: create one issue, state=`Todo`, labels=`[auto-build, change/<slug>]`, attempt `claimChangeSlug`. If claim fails, exit 0 with `skipped: { reason: 'already-claimed' }`.
5. For epic: create parent (state=`Backlog`, labels=`[auto-build, change/<slug>, epic]`), claim slug on parent, then create one sub-issue per buildable phase (state=`Backlog`, `parentId`=parent, `blockedBy`=prior wave's sub-issue).
6. Emit `OpenIssueResult` to stdout as JSON when `--json`; otherwise human-readable summary.

Edge cases / failure modes:
- `tasks.md` missing → exit non-zero, no Linear writes.
- All phases unbuildable in an epic → create parent only? **No** — exit non-zero with diagnostic; an epic with zero buildable phases is malformed and Pavel should fix the spec.
- Partial failure mid-epic (parent created, sub-issue 3 fails): do **not** roll back parent; emit `OpenIssueResult` with `created[]` truncated and exit non-zero. Workflow will surface this as a failed run; re-running is safe because the label claim now exists on the parent and `findExistingIssueForChange` will short-circuit.

## 4. `isBuildablePhase()` predicate

Rationale: Title substring matching ("Phase 5 — Tests" → skip) is brittle and was rejected in the adversarial review. Buildability is a structural property of the phase body: does it contain at least one un-checked actionable task?

Contract:

```ts
export interface PhaseBlock {
  index: number;                // 1-based ordinal from heading text "## Phase 3"
  title: string;
  bodyLines: string[];
}

export function isBuildablePhase(phase: PhaseBlock): boolean;
// true iff bodyLines contains ≥1 line matching /^\s*-\s\[\s\]\s+\S/ outside of fenced code blocks
```

Edge cases / failure modes:
- A phase containing only `- [x]` (already done) → not buildable; skipped.
- A phase containing only sub-bullets without top-level `- [ ]` → not buildable (forces spec author to surface at least one concrete deliverable per phase).
- Fenced code blocks: stripped before the regex pass so example markdown inside ``` doesn't count.

## 5. Wave numbering and `blockedBy` chain

Rationale: The chain must reference contiguous waves so a dropped (unbuildable) phase doesn't leave a dangling `blockedBy` pointer. Wave index is assigned after filtering, not from the source phase ordinal.

Contract:

Given phases `[1, 2, 3, 4, 5]` with `isBuildablePhase` returning `[true, false, true, true, false]`:

| Source phase | Buildable | Wave | blockedBy (wave) |
|---|---|---|---|
| 1 | yes | 1 | — |
| 2 | no  | — | — |
| 3 | yes | 2 | wave 1 (= phase 1 sub-issue) |
| 4 | yes | 3 | wave 2 (= phase 3 sub-issue) |
| 5 | no  | — | — |

Sub-issue titles: `[<slug>] Phase <sourceIndex>: <phase title>` (preserve source index in the title for traceability, but use contiguous wave for the dependency graph).

Edge cases / failure modes:
- Exactly one buildable phase → epic parent + one sub-issue, no `blockedBy`. Still parked in `Backlog` per locked decision #2.

## 6. Workflow integration — `forge-on-change-merge.yml`

Rationale: The current workflow has three problems: (a) `head -1` truncates multi-add merges to the first change folder, (b) no concurrency group means two near-simultaneous merges of the same slug race, (c) the no-RAP-id branch exits with a scaffold-stage message instead of invoking `forge open-issue`. All three fixed in one pass.

Contract (workflow shape):

```yaml
concurrency:
  group: forge-change-merge-${{ github.event.pull_request.merge_commit_sha }}
  # per-slug concurrency added inside the matrix step via job-level group
  cancel-in-progress: false

jobs:
  open-issues:
    strategy:
      matrix:
        slug: ${{ fromJSON(needs.detect-changes.outputs.slugs) }}  # array, not head -1
      fail-fast: false
    concurrency:
      group: forge-open-issue-${{ matrix.slug }}
      cancel-in-progress: false
    steps:
      - run: forge open-issue "${{ matrix.slug }}" --json > result.json
      - id: parse
        run: |
          shape=$(jq -r '.shape // "skipped"' result.json)
          echo "shape=$shape" >> $GITHUB_OUTPUT
          if [ "$shape" = "simple" ]; then
            identifier=$(jq -r '.created[0].identifier' result.json)
            echo "identifier=$identifier" >> $GITHUB_OUTPUT
          fi
      - if: steps.parse.outputs.shape == 'simple'
        name: Dispatch forge-build (fail loud)
        run: |
          set -euo pipefail
          gh workflow run forge-build.yml \
            -f rap_id="${{ steps.parse.outputs.identifier }}" \
            --repo "$G
