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

---
title: Tasks — Automate spec→build loop
audience:
  - executor
---

# Tasks — Automate spec→build loop

## Phase 1 — Linear write primitives (single PR)

**Goal:** Expose `createIssue`, `ensureLabels`, `createRelation`, and `findExistingIssueForChange` as typed functions in `core/linear-client.ts` and refactor `audit/*` callers onto them.

- [ ] Add `createIssue(input: CreateIssueInput): Promise<{ id: string; identifier: string }>` to `packages/forge/src/core/linear-client.ts` via raw GraphQL
  Done when: `grep -q 'createIssue' packages/forge/src/core/linear-client.ts`

- [ ] Add `ensureLabels(teamId: string, names: string[]): Promise<Record<string, string>>` to `core/linear-client.ts`
  Done when: `grep -q 'ensureLabels' packages/forge/src/core/linear-client.ts`

- [ ] Add `createRelation(issueId: string, relatedId: string, type: 'blocks' | 'duplicate'): Promise<void>` to `core/linear-client.ts`
  Done when: `grep -q 'createRelation' packages/forge/src/core/linear-client.ts`

- [ ] Add `findExistingIssueForChange(slug: string): Promise<{ id: string; identifier: string } | null>` to `core/linear-client.ts` (queries by `change/<slug>` label)
  Done when: `grep -q 'findExistingIssueForChange' packages/forge/src/core/linear-client.ts`

- [ ] Refactor `packages/forge/src/commands/audit/*` to import the four primitives above; delete any inline GraphQL duplicates
  Done when: `grep -rq 'linear-client' packages/forge/src/commands/audit/ && pnpm --filter @rapoport/forge build` exits 0

- [ ] Unit-test each primitive with a recorded HTTP fixture (no live API calls)
  Done when: `pnpm --filter @rapoport/forge test --testPathPattern=linear-client` passes

**Checkpoint:** `pnpm --filter @rapoport/forge build && pnpm --filter @rapoport/forge test` both pass; no raw GraphQL strings remain outside `core/linear-client.ts`.

---

## Phase 2 — `forge open-issue` command (single PR)

**Goal:** Implement a deterministic CLI command that reads a change folder and creates the correct Linear issue shape (simple → one issue; epic → parent + per-phase sub-issues with `blockedBy` chain).

- [ ] Implement `isBuildablePhase(section: string): boolean` — returns true iff section contains ≥1 `- [ ]` item; no title substring matching
  Done when: `pnpm --filter @rapoport/forge test --testPathPattern=isBuildablePhase` passes with ≥3 fixtures (all-done, mixed, no-tasks)

- [ ] Implement `parseChangeShape(markdown: string): SimpleChange | EpicChange` — detects `## Phase N` headers and extracts buildable phases with contiguous wave numbers
  Done when: `pnpm --filter @rapoport/forge test --testPathPattern=parseChangeShape` passes

- [ ] Implement `forge open-issue <slug>` command skeleton with `--dry-run` and `--json` flags; reads `changes/<slug>/proposal.md`
  Done when: `pnpm forge open-issue --help` lists `--dry-run` and `--json`

- [ ] Wire simple-change path: create one issue in `Todo`, apply `auto-build` label, return `{ identifier, id }`
  Done when: `pnpm forge open-issue <fixture-slug> --dry-run --json` outputs `{ "shape": "simple", "issues": [{ "title": "..." }] }` without hitting Linear API

- [ ] Wire epic-change path: create parent epic in `Backlog`, create one sub-issue per buildable phase, set `blockedBy` chain across waves
  Done when: `pnpm forge open-issue <epic-fixture-slug> --dry-run --json` outputs parent + ordered sub-issues with `blockedBy` references

- [ ] Apply `change/<slug>` label on every created issue (dedup layer B2)
  Done when: dry-run JSON output includes `"labels": ["change/<slug>"]` for all issues

- [ ] Guard against duplicate runs: if `findExistingIssueForChange(slug)` returns non-null, exit 0 with `already-exists` JSON and skip creation
  Done when: running `open-issue` twice against the same slug in integration test produces exactly one issue

**Checkpoint:** `pnpm --filter @rapoport/forge test` passes; `forge open-issue <slug> --dry-run --json` produces valid JSON for both simple and epic fixtures without a live Linear token.

---

## Phase 3 — `readAutoBuildMarker` and change-folder reader (single PR)

**Goal:** Implement a robust, tested marker reader that detects `> **Auto-build:** true` in proposal markdown and tolerates absent/varying metadata blocks.

- [ ] Implement `readAutoBuildMarker(markdown: string): boolean` — parses blockquote line `> **Auto-build:** true`; case-insensitive value, tolerates leading/trailing whitespace
  Done when: `pnpm --filter @rapoport/forge test --testPathPattern=readAutoBuildMarker` passes with fixtures: (a) marker present, (b) marker absent, (c) marker in YAML-like metadata block

- [ ] Add fixture file `changes/__fixtures__/auto-build-present/proposal.md` with the marker
  Done when: `[ -f packages/forge/src/changes/__fixtures__/auto-build-present/proposal.md ]`

- [ ] Add fixture file `changes/__fixtures__/auto-build-absent/proposal.md` without the marker
  Done when: `[ -f packages/forge/src/changes/__fixtures__/auto-build-absent/proposal.md ]`

- [ ] Add fixture file `changes/__fixtures__/auto-build-metadata/proposal.md` with marker inside a metadata block
  Done when: `[ -f packages/forge/src/changes/__fixtures__/auto-build-metadata/proposal.md ]`

- [ ] Integrate `readAutoBuildMarker` into `forge open-issue`: exit 0 with `{ "skipped": true, "reason": "no-auto-build-marker" }` when marker absent
  Done when: `forge open-issue <absent-fixture> --dry-run --json` outputs `skipped: true`

**Checkpoint:** All three fixture tests pass; `forge open-issue` on an unmarked change exits 0 and emits `skipped`.

---

## Phase 4 — Wire into `forge-on-change-merge.yml` (single PR)

**Goal:** Replace the manual `scaffold-stage PR — no impl issue yet` exit with a loop that calls `forge open-issue` for every changed slug, dispatches immediately for simple issues, and fails loud on dispatch error.

- [ ] Drop the `head -1` slug extraction; replace with a loop over all changed `changes/*/` paths in the merge commit
  Done when: workflow YAML contains no `head -1` and iterates changed slugs

- [ ] Add slug-keyed `concurrency.group: forge-open-issue-${{ matrix.slug }}` with `cancel-in-progress: false`
  Done when: `grep -q 'concurrency' .github/workflows/forge-on-change-merge.yml`

- [ ] In the no-RAP-id branch: call `forge open-issue <slug> --json`; capture output; skip if `skipped: true`
  Done when: workflow step echoes captured JSON and continues loop on `skipped`

- [ ] For simple issues: run `gh workflow run forge-build.yml` with the returned `identifier`; exit non-zero and post PR comment on dispatch failure (fail-loud)
  Done when: workflow step has `|| (gh pr comment ... && exit 1)` guard

- [ ] For epic issues: skip dispatch (issues land in `Backlog`); post informational PR comment listing created sub-issues
  Done when: workflow step posts comment containing sub-issue identifiers when shape is `epic`

- [ ] Smoke-test the workflow with `act` or a dry-run matrix against fixture slugs
  Done when: `act push --dry-run` exits 0 for both simple and epic fixture inputs

**Checkpoint:** A merged PR adding a simple change with `> **Auto-build:** true` triggers `forge-build.yml` automatically; an epic change creates Backlog issues and posts a comment; an unmarked change produces no Linear writes.

---

## Phase 5 — Studio substrate write-back (single PR)

**Goal:** Record the created Linear issue identifier(s) against the change slug in the studio substrate (B2 label claim is already live; this phase adds any DB-side bookkeeping deferred from Phase 2).

- [ ] Decide and document B1 vs B2 upgrade path in `design.md` (no migration required for B2-only)
  Done when: `grep -q 'B2' changes/automate-spec-to-build-loop/design.md`

- [ ] If B2 only: verify `change/<slug>` label is queryable via `findExistingIssueForChange` in production; add integration smoke test
  Done when: `pnpm --filter @rapoport/forge test --testPathPattern=findExistingIssueForChange` passes against a seeded Linear sandbox

- [ ] If B1 upgrade chosen: add `auto_build_claims(change_slug PK, linear_issue_id, created_at)` migration; wire upsert in `forge open-issue`
  Done when: `supabase db diff` shows migration and `forge open-issue` upserts on success

**Checkpoint:** Re-running `forge open-issue <slug>` against an already-claimed slug exits 0 with `already-exists`; no duplicate issues exist in Linear.

---

## Phase 6 — Tests, spec amendment, archive (single PR)

**Goal:** Full test coverage, capability-spec updated with D-IFL lineage, plan archived.

- [ ] Achieve ≥90% line coverage on `core/linear-client.ts`, `open-issue` command, `isBuildablePhase`, `parseChangeShape`, `readAutoBuildMarker`
  Done when: `pnpm --filter @rapoport/forge test --coverage` reports ≥90% for listed files

- [ ] Amend `capability-specs/D-IFL.md` (or create if absent) to document the `forge open-issue` command, marker contract, and dedup model
  Done when: `[ -f capability-specs/D-IFL.md ] && grep -q 'open-issue' capability-specs/D-IFL.md`

- [ ] Add RAP-1266 cross-reference to `capability-specs/linear-as-agent-control-plane.md`
  Done when: `grep -q 'RAP-1266' capability-specs/linear-as-agent-control-plane.md`

- [ ] Archive `~/.claude/plans/golden-munching-cat.md` to `changes/automate-spec-to-build-loop/plan-archive.md`
  Done when: `[ -f changes/automate-spec-to-build-loop/plan-archive.md ]`

- [ ] Close RAP-1266 in Linear with status `Done`
  Done when: Linear issue RAP-1266 shows state `Done`

**Checkpoint:** CI green on main; spec docs updated; plan archived; RAP-1266 closed.
