<!-- source: openspec/current/forge/entities.md -->

---
title: Forge — Entities
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Data structures for Forge — Builder mode identity model, module registry, persistence, convention adapters, reports, and UI entities.
---
# Forge — entities

> Part of openspec/current/forge/ — split from forge.md on 2026-05-12

---

## Builder mode

Builder is the autonomous-execution surface introduced under RAP-48: Forge takes a Linear issue from Backlog through merged PR with explicit checkpoints in between. Hybrid intelligence model — **Forge orchestrates, Anthropic Agent SDK executes**. `forge build` does not call the Anthropic Messages API directly to write code; it spawns a `query()` session against `@anthropic-ai/claude-agent-sdk` with a Builder-mode system prompt, a `canUseTool` gate for checkpoints, and an `AbortController` for cancel. Code-writing intelligence stays in the SDK; Forge owns git operations, plan generation, state, GitHub PR creation, and Linear comment posting.

Substrate for the four shipped commands (`inbox`, `inspect`, `plan`, `build`) is in place; `status` and `cancel` ship under `openspec/archive/2026-05-09-sync-forge-builder-spec/`. Original design lives in `openspec/archive/2026-05-05-add-forge-builder-mode/{proposal,design,tasks}.md` and the SDK call-shape spike note at `openspec/archive/2026-05-05-add-forge-builder-mode/spikes/2026-05-05-claude-agent-sdk.md`.

### Identity model

Forge runs under three dedicated bot identities in production, distinct from Pavel's personal credentials. Local development falls back to Pavel's PATs with a console warning. Production builds (`NODE_ENV=production` or equivalent) fail loudly when bot tokens are absent.

| Identity | Token | Source | Notes |
|---|---|---|---|
| GitHub `rapoport-studio-bot` | `GITHUB_BOT_TOKEN` | Infisical | Fine-grained PAT, 90-day expiry, scoped to `Contents` / `Pull requests` / `Issues` read-write. **No `Administration` permission.** Per RAP-47. |
| Linear `hello@rapoport.studio` | `LINEAR_BOT_API_KEY` | Infisical | Workspace member (not Admin) in `rapoport-studio-slr`. |
| Supabase service role | `SUPABASE_SERVICE_ROLE_KEY` | Infisical | Used for migration applies during `forge build`. |

`loadForgeIdentity()` (`packages/forge/src/core/auth.ts`) reads env, prefers bot tokens, falls back to Pavel's PATs (`GITHUB_TOKEN`, existing Linear API key) with `console.warn`. The `LinearClient` and `GitHubClient` constructors accept an explicit `apiKey` / `token` parameter so consumers (`audit`, `spec`, `review`, `estimate`, `inbox`, `inspect`, `plan`, `build`) all route through the loader without per-command env wiring.

See also `platform.md § Bot identities` for the cross-capability identity table.

### Programmatic API

The `Forge` handle returned from `initForge()` exposes Builder-mode methods alongside the legacy command surface. Closing what was Known gap #4 in earlier revisions. External consumers (Cloudflare Worker, GitHub Action, future control plane) can drive Forge programmatically without spawning the CLI as a subprocess.

```ts
class Forge {
  // Existing
  resolveProject(slug?: string): ProjectConfig
  // Builder mode (shipped)
  inbox(opts?: InboxOptions, projectSlug?: string): Promise<InboxResult>
  inspect(issueKey: string, opts?: InspectOptions, repoRoot?: string, projectSlug?: string): Promise<InspectResult>
  plan(issueKey: string, opts?: PlanOptions, repoRoot?: string, projectSlug?: string): Promise<PlanResult>
  build(issueKey: string, opts?: BuildOptions, repoRoot?: string, projectSlug?: string): Promise<BuildResult>
  // Pending (RAP-48 Wave 4 — sync-forge-builder-spec change)
  // status(issueKey?: string): Promise<StatusResult>
  // cancel(issueKey: string, opts: CancelOptions): Promise<CancelResult>
}
```

Live call site reference: `packages/forge/src/index.ts:155-238`. Result type definitions: `commands/{inbox,inspect,plan,build}/types.ts`.

CLI dispatchers (`packages/forge/src/cli.ts`) call into the same orchestrators the handle exposes, so the CLI and programmatic surfaces share one source of truth — no parallel implementations.

### Post-subprocess steps

Between the SDK subprocess returning and the push checkpoint, the orchestrator runs deterministic post-steps that fix up generated artefacts the agent typically forgets. These run in the worktree, commit any resulting changes onto the build branch, and surface failures as build aborts (not silent drift).

| Step | Trigger | Action | Source |
|---|---|---|---|
| **openspec bundle regen** (RAP-198) | `git diff --name-only origin/main -- openspec/{current,archive,_methodology,mirror}/` is non-empty | Run `node tools/build-openspec-bundle.mjs` from the worktree, then `git add` + `git commit -m "chore(openspec): regenerate bundle [forge post-step]"` if `apps/studio/src/generated/openspec-bundle.ts` actually changed | `packages/forge/src/post-steps/openspec-bundle.ts`; wired in `commands/build/orchestrator.ts` before `checkpoint('push', …)` |
| **finalize commit** (RAP-392 A3, refined by RAP-418) | Always, after the bundle regen post-step on the `!dryRun` path | `git add -A`, then discriminate between three outcomes using `git diff --cached --quiet` AND `git rev-list main..HEAD --count`: (a) staged empty + ahead 0 → truly empty, record `BuildOutcome 'aborted-no-changes'` and skip push/PR; (b) staged empty + ahead > 0 → Builder self-committed via its Bash tool, log a bypass message and fall through to `pushBranch` + `createPullRequest` (both idempotent — `git push` is a no-op on an up-to-date remote, and `createPullRequest` short-circuits to `getPullRequestForBranch` when a PR exists); (c) staged changes → `git commit -m "forge(<rap-key>): <issue.title>"`. | `packages/forge/src/git/finalize.ts § commitWorktreeChanges`; wired in `commands/build/orchestrator.ts` between bundle regen and `checkpoint('push', …)` |
| **push branch** (RAP-392 A1) | After the `push` checkpoint passes on the `!dryRun` path | `git push -u origin <BuildState.branchName>` in the worktree. Non-zero exit → `BuildOutcome 'failed-push'` and the orchestrator throws (no fall-through to PR creation). | `packages/forge/src/git/finalize.ts § pushBranch`; wired in `commands/build/orchestrator.ts` between `checkpoint('push', …)` and PR-body assembly |
| **open PR** (RAP-392 A2) | After the `pr-creation` checkpoint passes on the `!dryRun` path | `GitHubClient.createPullRequest({ branch, title, body })` with title `"<RAP-NN>: <issue.title>"` and body from `generatePrBody`. Under `--auto-pr` a failure surfaces as `BuildOutcome 'failed-pr'` and re-throws; without `--auto-pr` the orchestrator preserves the human-prompted path and falls back to a `console.warn`. | `packages/forge/src/core/github-client.ts § createPullRequest`; pre-flight at `commands/build/pr-precheck.ts § preflightPrCheck` |

Post-steps execute only on the live (`!options.dryRun`) path so dry-runs do not pollute the worktree with regen / commit / push side effects. Each post-step is a no-op when its precondition does not match (missing tool, no drift, empty diff) — third-party Forge consumers without `tools/build-openspec-bundle.mjs` see a one-line skip log and proceed. A non-zero exit from any post-step throws into the orchestrator's `catch` block and follows the standard stop-write-comment-exit failure path (`verifier.md § Failure modes`).

### Lessons-learned mechanism

After every run (success or failure), Forge appends a structured entry to `~/.forge/lessons-learned.md`. The same file is **read by `forge plan`** on every subsequent run as additional system-prompt context. This is the compounding-learning loop: plans benefit from prior runs' surprises, drift discoveries, and recovery patterns.

```markdown
## YYYY-MM-DD RAP-NN — short-title
**Outcome:** success | failure | cancelled
**Build environment:** Node X.Y, OS, SDK version, repo HEAD sha
**What worked:** …
**What failed:** …
**Drift discovered:** …
**Notes for future plans:** …
```

`packages/forge/src/core/lessons.ts` exposes `readLessons()` and `appendLesson(issueKey, outcome, content)`; the file lives at `~/.forge/lessons-learned.md` (cross-worktree, not in any repo). Appends are **parallel-safe**: header init uses `writeFile(HEADER, { flag: 'ax' })` (append + exclusive create, EEXIST-tolerant — NOT `wx`, which carries `O_TRUNC` and races with sibling O_APPEND writes), body append uses `fs.open(path, 'a')` + `fh.write(block)`. POSIX guarantees `write(2)` to an O_APPEND fd is atomic for payloads ≤ PIPE_BUF (4096B); lessons sections are 200–800B, well under the limit. Two simultaneous build completions both land their sections — no read-modify-write data loss.

v1 is intentionally simple append-only Markdown. Periodic manual consolidation into OpenSpec is Pavel's job. Smarter mechanisms (embedding-based retrieval, automatic spec-amendment proposals from lessons) are deferred to v2.

### Cancel runtime

`forge cancel <RAP-NN>` reaches the running build through two complementary mechanisms:

1. **In-process registry** — `packages/forge/src/core/build-registry.ts` exports a `Map<string, AbortController>` with `registerBuild(key, ac)`, `abortBuild(key)`, `unregisterBuild(key)`. The build orchestrator registers at start and unregisters in `finally`; in-process cancel calls `.abort()` on the controller, the SDK rejects, the orchestrator catches and transitions state to `cancelled`. Covers callers that hold a `Forge` instance and orchestrate `Forge.build()` and `Forge.cancel()` in parallel async paths within the same Node process.

2. **Cross-process sentinel file** — `forge cancel` from a separate terminal cannot reach an `AbortController` in another process (not serializable). Instead it writes an empty sentinel at `~/.forge/state/<RAP-NN>.cancel-requested` (path via `cancelSentinelPath(issueKey)`, also in `build-registry.ts`). The build orchestrator polls for the sentinel at every checkpoint gate (`enterCheckpoint` is the polling site); when present, it `unlink`s the sentinel and throws `CancelledByExternalSignal`. The catch block routes that error through the same `aborted` branch as the in-process AbortController, producing `state.currentCheckpoint = 'cancelled'` (not `failed`). Stale sentinels from previously-cancelled-then-restarted builds are cleared at `runBuild` start. Empty file is sufficient — existence is the signal.

Branch and PR are preserved by default (`--keep-branch` is the default; `--delete-branch` opt-in calls `GitHubClient.deleteBranch`). PR titles get a `[cancelled]` prefix — never auto-closed.

### Per-issue worktree isolation

`forge build` runs each issue in a **dedicated git worktree** at `~/.forge/worktrees/<RAP-NN>/`. This decouples concurrent builds from each other and from the developer's primary checkout: the operator can `pnpm forge build RAP-A` and `pnpm forge build RAP-B` in two terminals while continuing to work in `~/Documents/GitHub/rapoport.studio` on a third branch — none of the three sees the others' working-tree state.

`packages/forge/src/core/worktree.ts` exposes `setupWorktree({ issueKey, repoRoot, branchName })` which implements a three-branch algorithm:

1. **Clean creation** — no existing worktree, no existing branch: `git worktree add -b <branch> <path> origin/main`.
2. **Branch re-attach** — branch exists (e.g., from a prior cancelled run) but no worktree on it: `git worktree add <path> <branch>`.
3. **Adoption** — worktree exists at `<path>` and is on `<branch>` with a clean tree: reused as-is. A worktree on the wrong branch or with a dirty tree throws `WorktreeAdoptionError({ reason: 'wrong-branch' | 'dirty' })` rather than silently mutating user state.

The orchestrator passes `worktree.path` as `cwd` to `runBuilderSubprocess` and to `createNodeProjectFileSystem`, and persists it as `BuildState.worktreePath`. `repoRoot` is retained for `setupWorktree` itself (which must run from the primary checkout to invoke `git -C`) and is reserved for Phase B per-worktree `pnpm install`. macOS quirk: `git worktree list --porcelain` canonicalises `/var/...` tmpdirs to `/private/var/...`; the registry comparison uses `realpathSync` to handle this.

Phase A (RAP-153) shipped the worktree primitive plus parallel-safe lessons appends and the cross-process sentinel cancel above. Phase B (`add-forge-worktree-pnpm-install`) ships dependency installation inside each worktree so subprocesses can run `pnpm` commands. Phase C will plumb worktree teardown into the cancel/cleanup paths.

#### Phase B — dependency installation (RAP-168)

After `setupWorktree()` returns and before the Builder subprocess is spawned,
`installWorktreeDeps(worktreePath)` (`packages/forge/src/core/worktree.ts`) runs
`pnpm install --frozen-lockfile` inside the worktree. This unblocks all
verification gates (`pnpm typecheck`, `pnpm lint`, `pnpm test`) which require a
populated `node_modules/`.

**Default strategy:** `pnpm install --frozen-lockfile`. The flag is
non-negotiable — it ensures the install exactly mirrors `pnpm-lock.yaml` and
surfaces lockfile/manifest divergence as a loud error rather than silently
installing different versions.

**Lockfile-hash skip optimisation:** On repeated `forge build` invocations
(e.g., resuming from a cancelled run, running Wave 3 a second time), skipping
a redundant install saves ~40 s. The algorithm:

1. SHA-256 `pnpm-lock.yaml` in the worktree.
2. Read sentinel `node_modules/.lockfile-hash`.
3. If sentinel == hash → return (fast-path, no pnpm call).
4. Else → `pnpm install --frozen-lockfile`, write sentinel.

**Sentinel file convention:** `<worktreePath>/node_modules/.lockfile-hash`
(a plain text file containing the hex digest). Writing inside `node_modules/`
avoids polluting the worktree root with a Forge artefact; the directory is
gitignored so the sentinel is never committed. Existence of the sentinel also
implicitly confirms `node_modules/` is populated from a previous successful install.

The orchestrator logs total latency (`[forge:build] worktree deps ready in Nms`)
after `installWorktreeDeps` returns so future dogfood runs can track install cost.

#### Per-repo bare-clone option (RAP-395)

The default isolation strategy (worktree added directly to the operator's primary repo) is correct for one-at-a-time dispatch, but races when two `forge build` invocations call `git worktree add` simultaneously — both processes contend on `.git/config.lock` in the parent repo:

```
error: could not lock config file /Users/.../.git/config: File exists
error: unable to write upstream branch configuration
```

Memory `forge_parallel_dispatch_collisions` documented the workaround (strict serial dispatch via `~/.forge/locks/build.lock`); RAP-395 lifts the limitation.

The opt-in shape introduces an alternative worktree layout:

| Layout | Worktree base | Concurrency |
| -- | -- | -- |
| **Primary-repo** (default) | `git worktree add <path>` runs against the operator's primary repo `.git/` | Serial only (via build.lock) |
| **Per-repo bare clone** (RAP-395) | `git worktree add` runs from `~/.forge/repos/<repo-hash>/.git` (a separate bare clone Forge maintains) | Parallel-safe; no `.git/config.lock` contention with operator's primary repo |

Bare-clone management:

- `~/.forge/repos/<repo-hash>/.git` is created on first use via `git clone --bare <remote>`.
- Refreshed via `git fetch origin` before each `worktree add`.
- Repo-hash derived from the project's `repo` (`owner/name`) field — one bare clone per `ProjectConfig`.
- Disk cost: ~50 MB per repo. Trade-off: parallel `forge build` finally possible at the cost of the bare clone footprint.

Either layout is acceptable; the choice is driven by whether the operator dispatches in parallel. Today's epic-conductor flow (`forge epic --close`) serialises children via the global build-lock either way, so the primary-repo layout remains the path of least configuration for solo operators. The bare-clone layout becomes necessary when (a) the operator runs `forge build` in two terminals concurrently, OR (b) a Forge subprocess in one repo needs to run git operations against another repo simultaneously.

### Builder prompt

The Builder-mode system prompt is owned by `packages/muse` and consumed by `forge build`:

- `packages/muse/src/builder-prompt.ts` exports `buildBuilderModePrompt(plan, lessons, context)` which composes plan + lessons + repo context into a single system-prompt string.
- `forge build` invokes the SDK with `query({ ...options })` and the prompt is supplied as `systemPrompt: { type: 'preset', preset: 'claude_code', append: builderPrompt }`. This **preserves Claude Code's Read-before-Edit discipline** while layering Builder-mode rules (spec-driven discipline, checkpoint awareness, lessons-learned reference) on top — see the SDK spike at `openspec/archive/2026-05-05-add-forge-builder-mode/spikes/2026-05-05-claude-agent-sdk.md`.
- Tool gating (per-checkpoint pause): `canUseTool(name, input, ctx)` callback intercepts SDK tool-use requests and routes them through `commands/build/checkpoints.ts`.
- Cancel: `abortController.signal` from the build-registry, propagated as `query({ abortController: ac.signal, ... })`.

The prompt template is the **only** Muse v1.2 surface that Forge consumes. The rest of `packages/muse` (modes, runners, mirror loader) remains independent — see `muse.md § Relationship to Forge`.

---

## Module registry

The `audit` and `spec` commands operate per-module. A module is a named slice of the codebase (e.g., `worker-app`, `marketing`, `auth`) with:

- A glob set (which files this module includes)
- Per-agent context (which prompts run, which specs they read)
- Optional spec paths (markdown files describing the module's intended behavior)

The registry is **consumer-supplied** via `ForgeConfig.modules` (typed as `Record<string, ModuleConfig>`). `initForge()` seeds it once via `initModuleRegistry(config.modules)`; `getModule` and `listModules` are synchronous reads. Default is `{}` — without a registry, `audit` and `spec` exit with `module not found` errors.

The audit-agent prompt corpus is similarly consumer-overridable via `ForgeConfig.auditPromptsDir` (in-package prompts at `commands/audit/prompts/` are the fallback). The `forge spec --fix` system prompt is consumer-overridable via `ForgeConfig.fixSystemPromptPath` (in-package fallback at `commands/spec/prompts/fix-proposal.md`, with `{{MODULE_NAME}}` substitution at render time).

In this monorepo, the registry is wired in `forge.config.mjs` at the repo root. The companion `FORGE.md` provides project context for the audit agents. See `spec.md § Extension › Adding a new project as Forge consumer` for the consumer authoring shape.

---

## Persistence

The `--persist` flag on `audit`, `spec`, and `estimate` is honored only when a `PersistenceAdapter` is wired via `ForgeConfig.persistence`. The adapter extends a **seven-method** contract — three report sinks (for `--persist` on audit/spec/estimate) and four **optional** Builder-mode hooks (omitted adapters no-op via shims in `core/persist.ts`):

```ts
export interface PersistenceAdapter {
  persistAudit(report: AuditReport, projectSlug: string): Promise<{ id: string } | null>;
  persistSpec(report: SpecReport, projectSlug: string): Promise<{ id: string } | null>;
  persistEstimate(
    result: EstimateResult,
    projectSlug: string,
  ): Promise<{ ids: string[] } | null>;

  // Optional — Builder mode (`forge build`); see below.
  persistBuildStart?(input: BuildStartInput, projectSlug: string): Promise<{ runId: string } | null>;
  persistBuildCheckpoint?(input: BuildCheckpointInput, projectSlug: string): Promise<void>;
  persistBuildOutput?(input: BuildOutputInput, projectSlug: string): Promise<void>;
  persistBuildCompletion?(input: BuildCompletionInput, projectSlug: string): Promise<void>;
}
```

Input types (`BuildStartInput`, `BuildCheckpointInput`, `BuildOutputInput`, `BuildCompletionInput`, `ToolCallSummary`) are exported from `@rapoport-studio/forge/core` alongside the interface.

**Builder-mode lifecycle.** `forge build` is CLI-initiated; it does not run inside the studio worker. After each gated persistence point (start, checkpoint, PR output, terminal success/failure), the orchestrator calls the optional hooks **fire-and-forget** — failures log a warning and never change the build outcome (`packages/forge/src/core/persist.ts`). The studio ships `createStudioPersistenceAdapter()` (`apps/studio/src/lib/forge-persistence-adapter.mts`), wired from `forge.config.mjs` so local builds upsert `forge_runs` / `forge_steps` / `forge_outputs` via the service-role client. That data lights Phase 2 (`<LiveBuilderPanel>`) over SSR plus the SSE stream (`tasks/[key]/stream/route.ts`).

The second positional argument on every method is the **resolved project slug** (RAP-85 Phase 4) — engine threads it from `forge.resolveProject(--project).slug` through `core/persist.ts` (`persistAuditResult` / `persistSpecResult`, the `persistBuild*Safe` shims) and `commands/estimate/persist-estimate.ts` (`persistEstimateResults`) to the adapter. An adapter serving multiple engagements keys the row by it; an adapter that only knows one project may surface a clear error when an unfamiliar slug arrives.

`initForge()` seeds the adapter once via `initPersistenceAdapter(config.persistence)`; the helper functions in `core/persist.ts` and `commands/estimate/persist-estimate.ts` are thin shims that delegate to `getPersistenceAdapter()` with the threaded slug. When `--persist` is set but no adapter is configured, the shim logs `[persist] No persistence adapter configured — set ForgeConfig.persistence to enable --persist` to stderr and returns `null`. The run completes; the report still lands on disk.

In this monorepo the studio adapter implements Builder hooks; other consumers may supply audit/spec/estimate-only sinks or full seven-method shapes.

---

## Convention adapters

Forge runs against multiple OpenSpec conventions in the same process. The studio's house style is **prose-style minimal** — three files per change (`proposal.md` / `design.md` / `tasks.md`), no frontmatter, edits applied to `current/` alongside the change folder. Other engagements layer extensions on top — Unbind, the first external project, adds YAML frontmatter on every capability, four cross-cutting registries, capability-prefixed decision IDs, and **diff-at-archive** (describe `current/` edits in `tasks.md`, apply at archive time).

The seam is `OpenSpecConventionAdapter` — one TS file per engagement, resolved by slug from `ProjectConfig.conventionAdapter`. Forge has zero `project.slug === 'unbind'` conditionals outside the adapter boundary. See `openspec/archive/2026-05-08-add-forge-convention-adapter/design.md § 1` for the full interface and rationale.

### Interface (sketch)

```ts
interface OpenSpecConventionAdapter {
  /** Stable slug — must match ProjectConfig.conventionAdapter. */
  slug: string;
  name: string;

  /** YAML frontmatter for new capability specs. Returns null when none. */
  capabilityFrontmatter?(capability: string, ctx: AdapterContext): string | null;

  /** Whether the build agent may write directly to `openspec/current/` from a change folder. */
  allowsDirectCurrentEdits(): boolean;

  /** Append rows to the four cross-cutting registries (decisions / dependencies / maturity / open-questions). */
  mirrorDecision?(input: DecisionInput, ctx: AdapterContext): Promise<void>;
  mirrorDependency?(input: DependencyInput, ctx: AdapterContext): Promise<void>;
  mirrorMaturity?(input: MaturityInput, ctx: AdapterContext): Promise<void>;
  mirrorOpenQuestion?(input: OpenQuestionInput, ctx: AdapterContext): Promise<void>;

  /** Next-available decision ID per capability. Null when adapter doesn't assign IDs. */
  nextDecisionId?(capability: string, ctx: AdapterContext): Promise<string | null>;

  /** Pre-commit lint. Error-severity violations abort the build. */
  validate?(changePath: string, ctx: AdapterContext): Promise<AdapterViolation[]>;

  /** Archive-time hook. Applies diff-at-archive ops + renames the change folder to archive/<date>-<slug>/. */
  archive?(changePath: string, ctx: AdapterContext): Promise<void>;

  /** One-paragraph convention reminder appended to the build agent's system prompt. */
  promptAugmentation(): string;
}
```

`AdapterContext` carries the resolved project, a path-scoped `ProjectFileSystem` (refuses any read or write outside `worktreeRoot`), and an optional logger. Adapters are stateless — one instance per process — and pure: they touch the filesystem only through `ctx.fs`, so unit tests run against an in-memory stub.

### Built-in registry

`packages/forge/src/conventions/index.ts` exports `resolveAdapter(slug)`. Three built-ins ship:

| Slug | Convention | What it does |
|---|---|---|
| `studio` | Prose-style minimal (default) | `capabilityFrontmatter` returns null, `allowsDirectCurrentEdits` is `true`, no registries, no `archive()` hook. Studio behavior preserved bit-for-bit. |
| `unbind` | Prose + frontmatter + 4 registries + diff-at-archive | YAML frontmatter on every capability spec, `mirrorDecision/Dependency/Maturity/OpenQuestion` append rows to the four corpus-root registries, decision IDs prefixed by capability (e.g. `D-SE-3`), `validate` enforces frontmatter and rejects `current/` subdirectories inside change folders, `archive` parses `tasks.md` diffs and applies them. |
| `dentour` | Statement-style formal-spec + commitlint + Changesets | No frontmatter, `allowsDirectCurrentEdits` is `true`, no registries, `validate` shells out to `pnpm openspec:validate --strict` (local-only gate; treat all errors as build-blockers). Commit assembler guarantees scope is in Dentour's 30-value commitlint `scope-enum` (severity 2 — hard error); falls back to `workspace`. Spec-only PRs emit no changeset; code-bearing PRs scaffold `.changeset/<slug>-<hash>.md`. Branch shape: `studio/<change-slug>` in Hybrid mode; PR target is `develop` (NOT `main`, which triggers Dentour's Changesets release flow). `apps/auth-portal/` is blacklisted (vestigial, no `package.json`). Registry mirror methods are no-ops. |
| `vivod` | REQ-numbered formal-delta + Fumadocs frontmatter + filename-bridge validate | Capability files at `openspec/specs/<capability>/index.md`. `allowsDirectCurrentEdits` is `true`. No registries. `validate` shells out to `pnpm openspec validate` (deferred Phase 3). Commit assembler (deferred Phase 4). Branch shape: `studio/<change-slug>`, PR target `main`. Registry mirror tools are no-ops. |

Custom adapters are deferred to v2 — see `add-forge-convention-adapter/design.md § 9 Q1`. New engagements ship a TS file under `packages/forge/src/conventions/<slug>.ts` and register it in the built-in map. A worked example lives at `_methodology/templates/convention-adapter-example.md`.

**Dentour adapter detail.** The Dentour adapter (`packages/forge/src/conventions/dentour.ts`) ships with a companion spec parser (`dentour-spec-parser.ts`) and exported helpers: `assembleDentourCommit`, `scopeFromPaths`, `requiresChangeset`, `buildChangesetContent`, `dentourBranchShape`, `validatePrTarget`, `dentourSpecDeltaPath`. Design source-of-truth: `openspec/_methodology/research/dentour-convention-adapter-design.md` (15 `REQ-DCA-NNN` requirements). E2E smoke tests (P7.1–P7.2) are operator-blocked, requiring live Dentour worktree access.

### Integration points

| Surface | Adapter call | Phase |
|---|---|---|
| `forge build` system prompt | `adapter.promptAugmentation()` appended (additive — never replaces the base prompt) | RAP-86 Phase 3A |
| `forge build` pre-migrations | `adapter.validate(changePath, ctx)` — error-severity violations throw `BuildBlockedError` and post a comment to Linear | RAP-86 Phase 3B |
| `forge build` archive flow | `adapter.archive(changePath, ctx)` — parses diff-at-archive ops + renames change folder | RAP-86 Phase 3C |
| `forge spec --fix` proposal generation | `adapter.promptAugmentation()` lands in the system prompt; `adapter.capabilityFrontmatter()` prepends a YAML block to the generated `proposal.md` | RAP-86 Phase 4 |

Agent-side tool-use hooks (`mirrorDecision` / `mirrorDependency` / `mirrorMaturity` / `mirrorOpenQuestion` / `nextDecisionId`) called during the build subprocess as the agent finalizes registry entries are deferred to **Phase 3D** — the prompt augmentation alone is sufficient to steer the agent for v1.

### Diff-at-archive grammar

Unbind's `archive()` parses a `### Diffs to apply at archive` section inside `tasks.md` and applies each operation against `current/` + registries via `ctx.fs`. The grammar is deliberately small (v1):

```markdown
### Diffs to apply at archive

#### `<relative-file-path>`

- Replace `<old>` with `<new>`.
- Append to `§ <heading>`:
  ```
  <content>
  ```
- Append to file:
  ```
  <content>
  ```
- Insert before `§ <heading>`:
  ```
  <content>
  ```
- Insert after `§ <heading>`:
  ```
  <content>
  ```
```

Anything more complex (table-cell edits, conditional inserts) is human-authored at archive time. Bad operations fail loud (`DiffGrammarError`) rather than silently mangling `current/`. Implementation lives at `packages/forge/src/conventions/diff-grammar.ts`.

---

## Reports

Reports land under the consumer repo root in `.forge/`:

```
.forge/
├── audits/<module>-<date>.{json,md}
├── specs/<module>-<date>.{json,md}
└── reviews/<sha>-<date>.{json,md}
```

Nothing in the package gitignores these files. Consumers should add `.forge/` to their root `.gitignore` if they don't want artifacts committed.

---

## Cost tracking

Every Claude API call records:

- Input tokens, output tokens
- Model used (`opus`, `sonnet`, or override)
- Per-call cost (USD)
- Aggregated into report's `costBreakdown` per agent

Reports include total cost, per-agent breakdown, and (when persisting) write to `forge_<table>.cost_usd`.

Pre-flight `--estimate` mode prints projected cost for an `audit`, `spec`, `review`, or `estimate` run without making any Claude calls.

Pricing table `MODEL_PRICING` at `src/core/anthropic.ts` knows the three model IDs in the `MODELS` map at `src/core/anthropic.ts:60-64` (`MODELS.opus = "claude-opus-4-8"`, `MODELS.sonnet = "claude-sonnet-4-6"`, `MODELS.haiku = "claude-haiku-4-5-20251001"` — refreshed RAP-1253; historical aliases `claude-opus-4-6`, `claude-opus-4-7`, and `claude-sonnet-4-20250514` are retained in the pricing table so prior transcripts and sandbox dogfood runs still cost-aggregate correctly). Unknown model IDs fall back to Sonnet pricing. If you override `models.opus` to a third-party Claude model that isn't in the pricing table, cost numbers will be wrong.

---

## Anthropic API integration

Direct `fetch('https://api.anthropic.com/v1/messages')` — no SDK. Manual retry on 5xx and 429 with rate-limit-header awareness (respects `retry-after` and `anthropic-ratelimit-*` headers). Single retry on JSON-parse failure for outputs that should be structured (e.g., agent verdicts).

Three model IDs hard-coded as defaults:

| Constant | Default |
|---|---|
| `MODELS.opus` | `claude-opus-4-8` |
| `MODELS.sonnet` | `claude-sonnet-4-6` |
| `MODELS.haiku` | `claude-haiku-4-5-20251001` |

All three declared in the `MODELS` const at `src/core/anthropic.ts:60-64` (refreshed RAP-1253). Override via `models` field in `ForgeConfig`.

Output-token cap is the `maxTokens` argument to `callClaude(...)` at `src/core/anthropic.ts:63` (default `4096`). Each command pipeline passes its own value. There is no separate input-token cap exported by this module — input length is bounded by the model's context window and the API's request limits, not by package code. Per-call tuning is therefore a code-edit at the call site, not a forge-config knob; expose as a runtime override when a consumer first needs it.

`AnthropicResponse` exposes `stop_reason: string` (the messages-API field of the same name). Consumers branch on `stop_reason === 'max_tokens'` to detect output truncation and surface a clear error rather than a misleading downstream JSON-parse failure. `commands/render/orchestrator.ts` is the first consumer to use this branch; existing audit/review/spec/estimate paths ignore the field.

---

## GitHub API integration

Direct Octokit (`@octokit/rest`) wrapper, no `gh` CLI dependency. Single access point:

- `core/github-client.ts` — `GitHubClient` class shipped by RAP-72 (Track B of `complete-canvas-file-lifecycle`). Constructor takes `{ token, repoFullName, baseBranch? }`; reads `GITHUB_TOKEN` from the existing config pattern (env-var passthrough). Methods cover the canvas-conversion + per-file-save + PR-open flows the studio runtime needs.

| Method | Effect | Failure surface |
|---|---|---|
| `getFileContent(ref, path)` | Reads file content + blob SHA at a given ref. Returns `null` on 404 (file not found at ref). | Network errors throw raw Octokit error; surfaced to callers as `network_error`. |
| `branchExists(branchName)` | Boolean — used by `convertToProjectAction` to fail fast when `canvas/<slug>` already exists on the target repo. | Network errors throw. |
| `getRefSha(ref)` | Reads the commit SHA at a ref (typically `main`); the base for new branch creation. | 404 on unknown ref. |
| `createBranch(branchName, fromSha)` | Creates a new branch pointing at `fromSha`. 422 "Reference already exists" → `branch_already_exists`. | `branch_already_exists`, network. |
| `putFile(branch, path, content, { message, sha? })` | Single-file commit on a branch. `sha` is the optimistic-concurrency anchor — 409 → `ConflictError` carrying `path`, `expectedSha`, `actualSha`. | `ConflictError`, network. |
| `createTreeCommit(branch, files, message)` | Multi-file tree commit. Used by `convertToProjectAction` (initial canvas tree) and `saveAllPendingAction` (≥ 2 pending files). | Network. |
| `createPullRequest({ branch, title, body, base? })` | Opens a PR. Idempotent: calls `getPullRequestForBranch(opts.branch)` first; if a PR is already open for the branch, returns it without invoking `pulls.create`. (RAP-418 — replaces the prior 422-catch with a deterministic fast-path lookup.) | Network. |
| `getPullRequestForBranch(branch)` | Returns `{ url, number }` for the open PR on `<owner>:<branch>`, or `null` when none exists. Used by `createPullRequest` and reusable by `forge status` / epic poll loops. (RAP-418.) | Network. |
| `getRepoDetails()` | Reads `GET /repos/{owner}/{repo}` and projects to `RepoDetails` (`description` / `defaultBranch` / `language` / `pushedAt` / `isPrivate` / `isFork`). 404 → `null`. Other HTTP errors propagate so callers can map status codes. | Network. |

The class is exported from `@rapoport-studio/forge/core` for use by `apps/studio` (the boundary table allows this single carve-out — see `AGENTS.md` § Hard rules § Package boundaries). Tests in `packages/forge/src/core/__tests__/github-client.test.ts` cover happy + failure paths for each method with `@octokit/rest` mocked at module scope; `ConflictError` carries the optimistic-concurrency triple for the studio's "Reload from GitHub" CTA.

**D-PC-13 transport boundary** (locked 2026-05-06 by `add-project-connections`). `GitHubClient` is the **single** path to GitHub HTTP across the workspace. `packages/connections` (RAP-134 epic) imports `GitHubClient` for its `github` kind handler's `verify` / `loadMetadata` / `clientFactory` — it never imports `@octokit/rest` directly. The `getRepoDetails()` method was added to this client in RAP-136 specifically to give `packages/connections` a single round trip for both verify and metadata-load without leaking Octokit's response shape across the package boundary. Future Muse write-tools that need GitHub access (RAP-130 territory) follow the same path.

`audit --create-issues` and the audit Linear integration do **not** touch GitHub — those still go through the `gh` CLI as a separate concern. `GitHubClient` is exclusively the canvas-conversion / per-file-save / PR-open / `packages/connections` path.

---

## Linear API integration

Direct `fetch('https://api.linear.app/graphql')` — no SDK. Two access points:

- `core/linear-client.ts` — generic `LinearClient` with `fetchIssue`, `addComment`, `createIssue`, `searchIssues`, plus `getTeamDetails(teamId)` / `getTeamByKey(key)` (added by RAP-137 for `packages/connections`'s `linear` kind verify + key-resolution paths), plus `fetchIssuesBatch(keys: string[]): Promise<LinearIssueLite[]>` (added 2026-05-11 by `redesign-tasks-surface` W1 — one GraphQL query per team prefix, joined via `Promise.all`; consumed by `apps/studio` `/tasks` loader to hydrate title / priority / assignee / labels for ~50 rows in parallel with the Supabase `forge_runs` query). Errors throw as `LinearHttpError` (status-carrying) so consumers can map 401 → `auth_failed`, 429 → `rate_limited`, etc. without parsing message strings. Used by `estimate`, `audit --create-issues`, the connections kind handler, and the studio `/tasks` discovery surface.
- `commands/audit/linear-integration.ts` — audit-specific helpers for opening findings as Linear issues.

**D-PC-13 transport boundary** (locked 2026-05-06 by `add-project-connections`). `LinearClient` is the **single** path to `api.linear.app` across the workspace. `packages/connections` imports it for the `linear` kind handler — it never calls `fetch` against `api.linear.app` directly and never imports `@linear/sdk`. `getTeamDetails` / `getTeamByKey` were added to the client in RAP-137 specifically to give `packages/connections` typed projections instead of leaking the raw GraphQL response shape across the boundary.

`audit --create-issues` deduplicates by **stable hash identifier** embedded in the issue title (RAP-165, closed 2026-05-09). Each finding generates a 6-character suffix `[#<hash>]` derived from `sha256(file:startLine:endLine:agent)` (`commands/audit/finding-hash.ts`). `findExistingHashedIssues` queries Linear with **no state filter** so duplicates in any state — `In Progress`, `Done`, `Cancelled` — are detected and skipped before any new-issue creation call.

Skipped duplicate-detection events emit a structured `dedup_hit` JSON event to stderr (matched issue id, identifier, current state) for downstream log aggregation. Operators can pipe `forge audit --create-issues --json 2>dedup-hits.log` to capture them per run.

Cross-state dedup is observable in metrics: the audit run's `costBreakdown` shows no Linear-API cost incurred for findings that hit the dedup path — only the Anthropic-agent cost remains.

---

## openspec/ relationship

Forge consumes specs by **file path**, not by package import. Each `ModuleConfig.agents[name].specs` is a list of relative paths read via `readFile(resolve(repoRoot, specPath))` (`commands/audit/context-builder.ts:78`, `commands/spec/context-builder.ts:67`).

In this monorepo, OpenSpec lives **only** at the root `openspec/` directory. Specs that Forge consumes are paths under `openspec/current/`. There is no separate `packages/openspec/` package — content lives at repo root, tools live in packages.

`forge spec --fix` writes new proposal scaffolds to a path derived from the consumer's config. The path convention is `<outputDir>/<slug>-spec-gaps-<date>/proposal.md`; consumers can override `outputDir` if they don't want files under `.forge/`.

---

## UI surface (applied 2026-05-07 from change `add-forge-ui-task-detail`)

Forge gains a UI surface inside `apps/studio`. The CLI engine stays unchanged — this section describes the studio-side layer that consumes it.

### Task as projection

A **Task** is a UI-projection over three existing entities — it has no DB table. Resolved at request time via `loadTask(linearKey)`:

```
Task = (linear_issue) ⊕ (canvas_session?) ⊕ (forge_run?)
```

Linear issue is required (Tasks always have a Linear key). Canvas and Forge run are optional. The Task surface **embeds** the existing Canvas (via `/spec/[id]`) and **adds** a Forge surface alongside it. Routes addressable at `/tasks/[linear-key]` (uppercase-normalized server-side); see `studio-workspace/spec.md § Task page` for the route shell.

### 4-phase pipeline

Forge work is structured as four sequential phases per Run, fixed order:

| Phase | Wraps | Status in v1 |
|---|---|---|
| **1. Plan** | `buildPlanGeneratorSystem` / `buildPlanUserPrompt` library helpers | functional |
| **2. Implement** | Builder mode (shipped) | UI lit via `PersistenceAdapter` (`<LiveBuilderPanel>` — see `openspec/archive/2026-05-09-add-forge-builder-orchestrator-ui/`) |
| **3. Review** | `forge review --orchestrate` (this CLI) | functional |
| **4. PR** | Octokit `createPullRequest` | functional |

Phase 2 is **read-only observational** in the studio (CLI runs `pnpm forge build <key>`; the UI reflects DB state). All four phases share one accordion primitive, status taxonomy, and action matrix.

### Run lifecycle states

`forge_runs.status` takes one of: `queued`, `running`, `awaiting_input`, `done`, `failed`, `cancelled`. Auto-advance is the default — phases transition without human input unless a checkpoint is required (Phase 1 always; Phase 3 if any `critical` finding; Phase 4 always — Pavel reviews PR before merge).

### Server runtime

Lives at `apps/studio/src/app/(auth-walled)/(forge)/tasks/[key]/forge-actions.ts`. 15 server actions, each `auth + platform_owner` gated:

- Run lifecycle — `startRunAction`, `cancelRunAction`, `retryRunAction`.
- Phase 1 — `approvePhaseAction`, `iteratePhaseAction`, `improvePhaseAction`.
- Phase 3 — `addressFindingsAction`, `acceptReviewAction`, `acknowledgeFindingAction`, `suppressFindingAction`.
- Phase 4 — `createPRAction`, `mergePRAction`, `requestChangesAction`, `editPRBodyAction`, `toggleLinearAutoCommentAction`.

Phase 1/3/4 wrappers live at `apps/studio/src/lib/forge/{run-plan,run-review,run-pr}.ts`. Phase 2 has no server-action wrapper — the Builder orchestrator persists lifecycle events through `ForgeConfig.persistence` (`apps/studio/src/lib/forge-persistence-adapter.mts` in this monorepo); the Task page reads `forge_runs` / `forge_steps` / `forge_outputs` and subscribes to SSE for live deltas (see `studio-workspace/spec.md § Task page`).

### SSE streaming

Route handler at `apps/studio/src/app/(auth-walled)/(forge)/tasks/[key]/stream/route.ts`. **v1 deviation:** production runs on Cloudflare Workers — no long-lived `LISTEN` / `pg_notify`; the handler polls Supabase every 1500ms and emits an SSE frame when the snapshot digest changes (run status, cost, worktree, head branch, implement-step tool-call tail, outputs, findings). Wire shape matches what a notify payload would carry so the producer can swap later. `<Phase2ImplementBody>` consumes snapshots via `useForgeRunStream` (`EventSource`). One active connection per Run per browser tab.

### Five Forge entities

Five new domain entities are catalogued in this file:

- `forge-run` — one execution, backed by `forge_runs`.
- `forge-step` — phase within a run, backed by `forge_steps`.
- `forge-finding` — single review/audit finding, backed by `forge_findings`.
- `forge-tool-call` — LLM tool invocation, inline JSONB on `forge_steps.tool_calls`.
- `forge-output` — artifact produced (PR, report, diff, file, log), backed by `forge_outputs`.

Schema for the four backing tables lives at `db/entities.md § Forge tables`.

### UI primitives

Three primitives ship in `@rapoport-studio/ui` and are catalogued in `ui.md § Forge primitives`:

- `<PhasePipeline>` — 4-phase vertical accordion, status pill + meta line per phase.
- `<LiveLogStream>` — SSE-driven monospace stream, auto-scroll with pause-on-hover.
- `<FindingRow>` — severity-colored row primitive for review findings, CVA variants.

All three consume the existing studio `<Markdown>` chokepoint and `<DiffView>` from RAP-127. **No new code-rendering primitive is invented in Forge UI** — that's a hard constraint.

### Identities

Reuses existing platform bot identities: `LINEAR_BOT_API_KEY` (read + comment), `GITHUB_BOT_TOKEN` (PR create / merge), Supabase service role for run-state writes from server actions. No new identities.

### Auto-comments to Linear

On `createPRAction` success: comment with PR URL. On `mergePRAction` success: comment with merge SHA. On run-failure: comment with error summary. Run start, phase transitions, individual findings: no auto-comment (spam prevention). Cancellation: no auto-comment.

### Cost guard

Soft warn at 80% of `cost_cap_usd` (default $4.00 of $5.00); hard stop at 100% with `failed_reason = 'cost_cap_exceeded'`. Per-run override via direct DB update in v1; modal UI deferred to `add-forge-modals`.

### Auth boundary

The route group `(forge)/layout.tsx` is server-gated on `app_metadata.platform_owner === true`. Non-owners redirect to `/`. RLS on `forge_*` tables enforces the same boundary at the database. Defense-in-depth: server actions re-check `platform_owner` before any DB write or external call.

### Out of scope (locked at archive)

The change explicitly defers each of the following to its own future change:

- **In-UI start/cancel for Builder** — deferred to `add-forge-build-from-ui` (v1 path remains `pnpm forge build` / `pnpm forge cancel` from the terminal).
- **Failed-state rich UX** — `add-forge-failed-states`.
- **Modal layer** (Cancel confirm, Cost cap, per-run Settings) — `add-forge-modals`.
- **File tree** inside the Task — defer until concrete need.
- **Runs list view** — shipped at `/tasks` via `add-forge-discovery-surface` (read-only index of recent runs across all projects, plus a project-scoped Forge runs card on `/projects/[slug]`; see `studio-workspace/spec.md § Task discovery`).
- **`/forge` dashboard** — still deferred; cross-project metrics (success rate, average cost, p95 duration) wait for usage signal.
- **Pause / intervene / resume in Phase 2 (Builder)** — v1 is cancel-only; pause/resume is `add-forge-pause-resume` (v1.3).
- **Multi-user** — v1 is `platform_owner` only. `add-forge-multi-user` if/when needed.
- **Mobile responsive** — desktop-only at ≥1024px.
- **Audit / spec / render / mirror-export UIs** — those Forge commands generate **new** Tasks rather than execute within a Task.

### Track ledger

Implementation shipped across six tracks in dependency order; tracks B and C landed in parallel after A.

| Track | PR | Linear | What landed |
|---|---|---|---|
| A | [#286](https://github.com/rapoport-studio/rapoport.studio/pull/286) (preceded; tracker referenced) | [RAP-131](https://linear.app/rapoport-studio-slr/issue/RAP-131) | DB schema (4 tables, RLS) + `packages/db` types |
| B | [#287](https://github.com/rapoport-studio/rapoport.studio/pull/287) | [RAP-143](https://linear.app/rapoport-studio-slr/issue/RAP-143) | UI primitives + severity tokens |
| C | [#288](https://github.com/rapoport-studio/rapoport.studio/pull/288) | [RAP-144](https://linear.app/rapoport-studio-slr/issue/RAP-144) | Server runtime — 15 actions + SSE + `loadTask` |
| D | [#290](https://github.com/rapoport-studio/rapoport.studio/pull/290) | [RAP-145](https://linear.app/rapoport-studio-slr/issue/RAP-145) | Page composition + F rail icon |
| E | (deferred) | — | Smoke + polish — manual lifecycle exercise on a real Linear issue |
| F | (this PR) | [RAP-146](https://linear.app/rapoport-studio-slr/issue/RAP-146) | Spec amendments + archive |

## Phase 2 (Implement) UI lit (applied 2026-05-09 from change `add-forge-builder-orchestrator-ui`)

Implements the observational Implement phase in studio: `PersistenceAdapter` Builder hooks, `<LiveBuilderPanel>`, and extended SSE snapshots shipped under RAP-172. Authoritative proposal/design/tasks: `openspec/archive/2026-05-09-add-forge-builder-orchestrator-ui/` — this marker is pointer-only (per `archive/2026-05-09-sync-forge-builder-spec/design.md § D5`).

---

## Worktree pnpm install — Phase B (applied 2026-05-09 from change `add-forge-worktree-pnpm-install`)

Phase B of worktree isolation: `installWorktreeDeps()` added to `core/worktree.ts`, integrated into `commands/build/orchestrator.ts` between `setupWorktree()` and `BuildState` construction. Lockfile-hash skip optimisation and sentinel convention documented above in § Per-issue worktree isolation › Phase B. Authoritative proposal/design/tasks: `openspec/archive/2026-05-10-add-forge-worktree-pnpm-install/` — this marker is pointer-only.

## Workflow timeline cross-reference (applied 2026-05-12 from change `establish-workflow-spec`)

Forge executes stages 4–7 of the workflow timeline (Plan → Build → Verify → Finalize) per [`workflow.md`](../workflow.md). Stage entry is `forge plan`; stage exit is `forge build` post-step finalization. `BuildOutcome` is the exit metric for Stage 7.

## ReviewResult shape + evidence field (applied 2026-05-14 from change `add-spec-acceptance-gate`)

### `ReviewResult` (Zod schema at `packages/forge/src/core/anthropic.ts`)

```ts
export const EvidenceItemSchema = z.object({
  file: z.string(),
  line: z.number().int().positive(),
  concern: z.string().min(1),
});

export const ReviewResultSchema = z.object({
  verdict: z.enum(['pass', 'fail', 'failed-but-not-the-agents-fault', 'needs-human']),
  confidence: z.number().min(0).max(1),
  summary: z.string(),
  reasoning: z.string(),
  evidence: z.array(EvidenceItemSchema),  // required field
  suggestedNextStep: z.string().optional(),
  escalated: z.boolean(),
}).refine(
  (r) => r.verdict === 'pass' || r.evidence.length >= 1,
  { message: 'verdicts other than `pass` must cite at least one {file, line, concern} item' },
);
```

**Refinement rule:** `evidence[]` must be non-empty for any verdict other than `pass`. The reviewer cannot ship a prose verdict without pointing at specific code. This is the structural anti-rationalization mechanism introduced in RAP-738.

**Back-compat:** `~/.forge/builds/<RAP-NN>/review.json` files written before this change load with `evidence: undefined`; `ReviewResult` on wire always has `evidence[]`.

### Six assertion shapes (spec-acceptance gate)

`packages/forge/src/core/acceptance.ts` defines the typed assertion specs that `muse draft` generates into `verification.md` and that `forge build` runs at the spec-acceptance checkpoint. Design source-of-truth: `openspec/archive/2026-05-14-add-spec-acceptance-gate/design.md § 3`.

```ts
export type AssertionSpec =
  | { kind: 'file-exists'; path: string; raw: string }
  | { kind: 'grep-returns-N'; marker: string; path: string; expected: number; raw: string }
  | { kind: 'test-passes'; command: string; raw: string }
  | { kind: 'PR-exists-for-branch'; branch: string; minimum: number; raw: string }
  | { kind: 'state-field-equals'; jqPath: string; statePath: string; expected: string; raw: string }
  | { kind: 'reviewer-confirms'; claim: string; raw: string };

export type AssertionStatus = 'passed' | 'failed' | 'skipped-llm-carveout' | 'parse-error';

export interface AssertionResult {
  spec: AssertionSpec | { raw: string };  // raw-only when parse-error
  status: AssertionStatus;
  evidence?: string;  // stderr/stdout tail (~300 chars) on failed
}
```

**Shape summary:**

| # | Kind | Surface syntax | How checked |
|---|---|---|---|
| 1 | `file-exists` | `Done when: [ -f <path> ]` | `fs.existsSync(path)` |
| 2 | `grep-returns-N` | `Done when: grep -c '<marker>' <path> returns <N>` | `execFileSync('grep', ...)` → compare count |
| 3 | `test-passes` | `Done when: pnpm <cmd>` | spawn pnpm, exit code 0 + `FAILURE_MARKERS` scan |
| 4 | `PR-exists-for-branch` | `Done when: gh pr list --head <branch> returns ≥<N>` | `execFileSync('gh', ...)` → count |
| 5 | `state-field-equals` | `Done when: jq -r '<path>' <state.json> returns '<value>'` | `execFileSync('jq', ...)` → compare |
| 6 | `reviewer-confirms` | `Reviewer confirms: <claim>` | Not deterministic — surfaced to AI reviewer; reviewer must address in `evidence[]` |

---

## Project membership authz boundary (applied 2026-05-15 from change `redesign-canvas-permission-model`)

**Non-normative note.** Forge runs are deliberately gated by `platform_owner` only and do **not** consume `project_members`. This is an explicit design choice, not an oversight.

- The route group `(forge)/layout.tsx` gates on `app_metadata.platform_owner === true`. Non-platform-owners redirect to `/`.
- RLS on all `forge_*` tables (`forge_runs`, `forge_steps`, `forge_findings`, `forge_outputs`) enforces the same platform_owner boundary at the database layer.
- Server actions re-check `platform_owner` before any DB write or external call (defense in depth).
- `project_members` roles are not evaluated at any point in the Forge access path.

Rationale: Forge manages source code, GitHub PRs, and Linear issues under bot credentials; granting project members access to Forge runs would require separate multi-tenant authz design covering credential isolation, per-project Linear team scoping, and per-project GitHub app installation scoping. This is filed as `add-forge-multi-user` and is explicitly out of scope for `redesign-canvas-permission-model`.

If and when `add-forge-multi-user` ships, this note should be replaced with the multi-tenant boundary contract.

**Why these six:** derived from an empirical 179-file archive audit. 46% of archived `Done when:` lines matched command-returns-result shapes (2/4/5); 43% matched pnpm-test shape (3); 10% matched operator-blocked / human-attested (6). No line in the corpus used a pattern outside these six.
