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

---
title: Forge — Spec
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Engineering contract for Forge — architecture, configuration, multi-project routing, required env vars, dependencies, and boundaries.
---
# Forge — spec

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

---

## Architecture

Forge is a thin `Forge` handle plus per-command pipelines that share `core/` utilities. Eighteen commands (the `cli.ts:37-56` whitelist) divide into legacy six (single Anthropic call or simple fan-out), Builder-mode pipelines (state-machine + SDK subprocess + GitHub/Linear integration), and a pair of cross-cutting utilities (`install-skills`, `mcp-server`):

```
                     ┌─ Legacy pipelines (Anthropic Messages API direct) ───┐
                     │                                                       │
src/cli.ts ──┬──► audit/orchestrator.ts ───┐                                │
             │                              │                                │
             ├──► spec/checker.ts          ─┤                                │
             │                              ├──► core/anthropic.ts (callClaude + consumeSSEStream)
             ├──► review/reviewer.ts       ─┤    core/json-retry.ts (callClaudeWithJsonRetry)
             │                              │    core/anthropic.ts (MODELS, MODEL_PRICING, computeCost)
             ├──► estimate/estimator.ts    ─┤    core/spec-reader.ts (parseRelevantSpecs, buildSpecContext)
             │                              │    core/project-context.ts (cached project context)
             ├──► render/orchestrator.ts   ─┤    core/persist.ts (PersistenceAdapter shim)
             │                              │
             └──► mirror-export/orchestrator.ts (no Anthropic — pure tar.gz pipeline)

             ┌─ Builder-mode pipelines (Anthropic Agent SDK via query() + Linear + GitHub) ─┐
             │                                                                               │
             ├──► inbox/orchestrator.ts ──────► core/linear-client.ts (LinearClient.searchIssues)
             │                                                                               │
             ├──► inspect/orchestrator.ts ────► core/linear-client.ts + core/spec-reader.ts
             │                                                                               │
             ├──► plan/orchestrator.ts ───────► core/linear-client.ts + core/anthropic.ts
             │      + plan-builder.ts          (Muse buildBuilderModePrompt — packages/muse)
             │                                                                               │
             ├──► build/orchestrator.ts ──────► query() from @anthropic-ai/claude-agent-sdk
             │      + subprocess.ts            core/build-registry.ts (in-process AbortController)
             │      + checkpoints.ts           core/lock.ts (~/.forge/locks/build.lock)
             │      + verification.ts          core/worktree.ts (per-issue git worktree)
             │      + reviewer.ts              core/lessons.ts (~/.forge/lessons-learned.md)
             │      + finalize.ts              core/state.ts (BuildState.json)
             │                                                                               │
             ├──► epic/orchestrator.ts ───────► topo-sort.ts + iterates over build
             │
             ├──► status/orchestrator.ts ─────► core/state.ts (read-only)
             │
             └──► cancel/orchestrator.ts ─────► core/build-registry.ts § cancelSentinelPath (~/.forge/state/<RAP>.cancel-requested)

Shared:
  core/orchestration.ts — runAgentsInParallel<T> primitive (audit + future orchestrated review)
  core/persistence-adapter.ts — Builder-mode hooks for studio dashboard streaming
  core/metrics.ts — frozen v1 BuildMetrics schema (Maestro/Muse-consumable)
  core/github-client.ts — single Octokit wrapper (build PR creation, packages/connections)
  core/auth.ts — loadForgeIdentity() three bot identity sources
  core/acceptance.ts — spec-acceptance gate: parseVerificationMd() + runDeterministicChecks() (added 2026-05-14)
  conventions/ — per-engagement OpenSpec adapter (studio vs unbind)

src/index.ts ──►  initForge() handle  ──► reads ForgeConfig, resolves project context
src/worker-prompts.ts ──► library-only plan prompt builders (Worker-runtime safe; deliberately separate from plan/plan-builder.ts per contract — invariants pinned in tools/check-forge-spec.mjs)
```

Per-command lazy `import()` from `cli.ts` keeps startup cheap — only the pipeline you invoke loads its deps. The Builder-mode pipelines share more `core/` surface than the legacy six because the runtime substrate (state, lock, worktree, cancel sentinel, lessons) lives at the core layer.

### Untrusted input boundary

Untrusted text inputs (Linear bodies, PR review comments, PR-branch repo content, Telegram messages, downstream API responses) are wrapped in `<untrusted_input source="...">` sentinel tags at prompt-build time. The agent's system prompt forbids following instructions inside the block. Convention is documented in [`openspec/_methodology/security/untrusted-input-convention.md`](../../_methodology/security/untrusted-input-convention.md).

---

## Configuration

`loadConfig()` resolves config from three sources, in priority order:

1. **Explicit options** passed to `initForge()`
2. **Config file** — `forge.config.mjs` / `.js` / `.json` walked up from `cwd`
3. **Environment** — `FORGE_PROJECT_CONTEXT_PATH`, `FORGE_ISSUE_PREFIX`

The resolved `ForgeConfig` shape:

| Field | Purpose |
|---|---|
| `projectContextPath` | Path to a markdown file describing the project, agent rules, conventions. Embedded in every Anthropic prompt. Per-project override available in `projects[slug].projectContextPath`. |
| `issuePrefix` | Legacy single-project Linear team prefix (e.g. `AI-`). **Back-compat only** — new code reads `forge.resolveProject(slug).issuePrefix` instead. Synthesizes a `projects.<slug>` entry when `projects` is omitted. See § Multi-project routing. |
| `linearTeamId` | Linear team UUID. Required for `audit --create-issues` and `estimate`. |
| `outputDir` | Where reports land. Default `.forge/`. |
| `models` | Override `MODELS.opus` / `MODELS.sonnet`. Default to current Claude IDs. |
| `modules` | Consumer-supplied module registry (`Record<string, ModuleConfig>`). Default: `{}` — empty registry. Required for any non-trivial `audit` / `spec` run. |
| `auditPromptsDir` | Optional path to a consumer-owned audit-prompt corpus. Default: in-package `commands/audit/prompts/`. Resolved relative to the config file's directory. |
| `fixSystemPromptPath` | Optional path to a consumer-owned `forge spec --fix` system prompt. Default: in-package `commands/spec/prompts/fix-proposal.md`. Resolved relative to the config file's directory. |
| `persistence` | Optional `PersistenceAdapter` (see `entities.md § Persistence` — three legacy report methods plus four optional Builder-mode lifecycle hooks). Default `undefined` — `--persist` warns and no-ops on audit/spec/estimate when unset. |
| `projects` | Multi-project routing map (`Record<slug, ProjectConfig>`). One entry per engagement Forge serves from this config — studio's own repo plus any external engagements. See § Multi-project routing. |

Apps consume via `initForge()`:

```ts
import { initForge } from "@rapoport-studio/forge";

const forge = await initForge({
  projectContextPath: "./AGENTS.md",
  issuePrefix: "AI-",
});
```

---

## Multi-project routing

Forge serves N engagements from one config. Each engagement is a `ProjectConfig` keyed by a stable lowercase slug (`studio`, `unbind`, `vivod`, …). The studio's own repo declares `projects.studio` explicitly; external engagements add sibling entries.

This unblocks two flows that single-project Forge couldn't handle:

1. **Cross-prefix Linear routing.** `forge inbox --project=unbind` queries `UNB-` issues, not `RAP-`.
2. **Cross-repo execution.** `forge build --project=unbind` operates against the consuming repo (`rapoport-studio/unbind.life`), with its own `worktreeRoot` and `openspecRoot` paths, while the studio's own repo continues to be the default.

### `ProjectConfig` shape

```ts
interface ProjectConfig {
  /** Linear issue-key prefix, uppercase (e.g. "RAP", "UNB"). Required. */
  issuePrefix: string;
  /** GitHub `owner/name`. Required. */
  repo: string;
  /** Optional bot identity for commit signature attribution. */
  botAccount?: string;
  /** Absolute path to the worktree clone for this project. `~` is expanded. */
  worktreeRoot?: string;
  /** Convention adapter slug (forge knows how the consumer's openspec/ is laid out). */
  conventionAdapter?: string;
  /** Linear team UUID, when explicitly configured. */
  linearTeamId?: string;
  /** Path to the OpenSpec corpus root for this project, relative to the config. */
  openspecRoot?: string;
  /** Path to the Markdown context file for prompt injection (overrides root config). */
  projectContextPath?: string;
}
```

### `--project` flag

A global flag parsed at the top of `cli.ts`, hoisted to every command handler:

```bash
forge audit --project=unbind <module>
forge inbox --project=studio
forge build --project=unbind RAP-NN  # cross-repo execution
```

Without the flag, default-project resolution kicks in.

### Default-project resolution

`forge.resolveProject(slug?)` resolves in this precedence order:

1. **Explicit `slug` argument** (`--project=<slug>` or programmatic call).
2. **`FORGE_PROJECT` env var** — useful in CI workflows that fan out across engagements.
3. **`cwd` ancestry** — first project whose `worktreeRoot` is an ancestor of `process.cwd()`. Lets a developer `cd ~/repos/unbind && forge inbox` and have it route correctly without a flag.
4. **Back-compat synthesized slug** — when `projects` is absent in the source config, the loader synthesizes a single project from the legacy top-level fields (`issuePrefix`, `projectContextPath`, etc.). The slug is derived from the nearest `package.json`'s `name` (with a fallback to `default`).
5. **`projects.studio`** if declared — the studio's own repo is the well-known default.
6. **Single entry** — when `projects` has exactly one entry, it wins regardless of slug.
7. Otherwise: `ForgeConfigError("could not resolve project; pass --project=<slug>")`.

The resolved object is `ResolvedProject` — same fields as `ProjectConfig` plus `slug: string` (the resolved key) and `repoOwner` / `repoName` (split from `repo`). `worktreeRoot` is always populated and absolute.

`worktreeRoot` is resolved (RAP-398) in this precedence order:

1. **Explicit `worktreeRoot`** declared on the `ProjectConfig`.
2. **`git rev-parse --show-toplevel`** walked up from the user's invocation directory. When `cwd` sits inside a secondary worktree (`.git` is a file pointing to the primary `.git/worktrees/<name>/`), this resolves to the **primary** repo root — never to the secondary worktree's own root.
3. **Config-file directory** as a last-resort fallback when the loader is invoked outside a git repo (CI fixtures, test runs).

The legacy `forge.config.mjs:113` pattern `worktreeRoot: __dirname` is forbidden: it pins the path to the loaded config file's directory, which is wrong whenever the operator runs `pnpm forge` from a feature-branch worktree of `main`. Memory `forge_dispatch_repo_root` documented the workaround (create a separate clean worktree on main and invoke from there); RAP-398 makes the resolution implicit so the workaround is unnecessary.

### Threading through commands

CLI handlers compute `const project = forge.resolveProject(projectSlug)` once at the top of each handler, then thread:

- `project.issuePrefix` → `LinearClient` construction (so `parseIssueKey` matches the engagement's prefix).
- `project.worktreeRoot` → `repoRoot` argument for orchestrators.
- `project.slug` → the second positional argument to every `PersistenceAdapter` method (RAP-85 Phase 4 — `entities.md § Persistence`).

Programmatic callers go through `Forge.resolveProject()` on the handle returned from `initForge()`. The `Forge.inbox / inspect / plan / build` methods accept an optional `projectSlug?` parameter that delegates to the same resolver.

### Back-compat with single-project configs

A legacy config that omits `projects` entirely keeps working. The loader synthesizes a single `ProjectConfig` from the top-level `issuePrefix` + `projectContextPath` and registers it under a slug derived from the nearest `package.json`. New code should read via `forge.resolveProject()`; the top-level legacy fields stay readable on the resolved config for one release cycle.

A consumer migrating from single-project to multi-project: add a `projects: { <yourSlug>: { issuePrefix, repo, ... } }` block to `forge.config.mjs` and remove the legacy top-level `issuePrefix` once nothing reads it.

---

## Required env vars

Consolidated reference for all env vars Forge reads. Each marked **required-by** (which commands require it), **optional-fallback** (what happens if absent), and **source** (where it lives in production).

| Variable | Required by | Optional fallback | Source |
|---|---|---|---|
| `ANTHROPIC_API_KEY` | All commands except `--estimate` mode; required for `callClaude` and SDK `query()` invocations | None — non-`--estimate` invocation fails with "ANTHROPIC_API_KEY environment variable is required" | Infisical org "Rapoport Studio SLR", path `/` |
| `LINEAR_API_KEY` | `forge estimate`, `forge audit --create-issues` (user-side runs) | None — invocation fails with clear stderr | Infisical, path `/` |
| `LINEAR_TEAM_ID` | `forge audit --create-issues` | None — invocation fails | Infisical or fallback to `LINEAR_TEAM_ID` constant in `forge.config.mjs` |
| `LINEAR_BOT_API_KEY` | Builder mode (`forge build`, `forge epic`) when running as `rapoport-studio-bot` Linear identity | Falls back to `LINEAR_API_KEY` with `console.warn` (per `core/auth.ts § loadForgeIdentity`); production builds (`NODE_ENV=production`) fail loudly | Infisical, path `/` |
| `GITHUB_TOKEN` | Local-developer `forge build` / `forge cancel --delete-branch`; falls back here when `GITHUB_BOT_TOKEN` is unset | None for production runs (bot token required); local dev gets a `console.warn` if using personal PAT | Local `~/.gitconfig` or per-developer env |
| `GITHUB_BOT_TOKEN` | Production Builder mode (`forge build` invoked by `rapoport-studio-bot`); `commands/build/finalize.ts § pushBranch`, `core/github-client.ts § createPullRequest` | Falls back to `GITHUB_TOKEN` with `console.warn`; production builds fail loudly | Infisical, path `/`. Fine-grained PAT, 90-day expiry, `Contents`/`Pull requests`/`Issues` read-write scope. **No `Administration` permission** (RAP-47) |
| `SUPABASE_SERVICE_ROLE_KEY` | `forge build` migrations apply step | None — migration-apply step skips with `console.warn` if absent; build proceeds without migration changes | Infisical, path `/` |
| `FORGE_PROJECT_CONTEXT_PATH` | All commands (via `initProjectContext`) | Falls back to nearest ancestor `AGENTS.md` discovered by `findProjectContextPath` walk from cwd | `forge.config.mjs § projectContextPath` (recommended) or env var (fallback) |
| `FORGE_ISSUE_PREFIX` | `forge inspect`, `forge plan`, `forge build`, `forge estimate`, `forge cancel` (Linear key parsing) | Falls back to `forge.config.mjs § issuePrefix` (recommended pattern); env var only used as override | `forge.config.mjs` (recommended) |
| `FORGE_PROJECT` | All commands when `--project=<slug>` flag is not passed | Falls back to default-project resolution (cwd ancestry → only-project → `'studio'` fallback per `loadConfig().resolveProject()`) | Per-developer env or unset (most operators use `--project` flag or default resolution) |
| `NODE_ENV` | Production identity gating in `core/auth.ts § loadForgeIdentity` (production fails loudly when bot tokens absent) | If unset, falls back to lenient (developer) mode with warnings | OS / CI environment |

### Identity flow summary

`packages/forge/src/core/auth.ts § loadForgeIdentity` is the single entry point that consumes the above. It prefers bot tokens (`LINEAR_BOT_API_KEY`, `GITHUB_BOT_TOKEN`, `SUPABASE_SERVICE_ROLE_KEY`) and falls back to personal PATs (`LINEAR_API_KEY`, `GITHUB_TOKEN`) with `console.warn`. The `LinearClient` and `GitHubClient` constructors accept an explicit `apiKey` / `token` parameter so all command pipelines (`audit`, `spec`, `review`, `estimate`, `inbox`, `inspect`, `plan`, `build`, `epic`, `status`, `cancel`) route through one loader without per-command env wiring.

### Secrets provenance

All secrets live in Infisical (org "Rapoport Studio SLR", project "rapoport-studio", workspace ID `7d67e5e8-3693-487d-bb18-9fbcb288c96e`) at root path `/` with `--env=prod`. Local CLI usage: `infisical run --env=prod --path=/ -- pnpm forge <args>` injects the variables. CI uses the same Infisical token through `infisical secrets get --plain` per `.github/workflows/*.yml`. CI/CD never persists secrets locally — they exist in process env only.

For production Cloudflare Worker deployments (studio Phase 2 UI calling Forge programmatically): secrets are sync'd via `wrangler secret put` after every `opennextjs-cloudflare deploy`, per platform memory `worker_secrets_drift_from_infisical.md`.

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

---

## Operator shell environment (RAP-406)

Forge ships supporting dispatch scripts (`/tmp/forge-sniper.sh`, `/tmp/forge-dispatch.sh`, the babysitter loops documented in `_methodology/research/forge-audit-run-2-*.md`) that operators copy into their own toolboxes. macOS ships `/bin/bash` at **3.2** (Apple has not updated it since 2007); these scripts must avoid bash-4-only idioms or they silently mis-behave on the developer's primary platform.

Forbidden bash-4 idioms — operator dispatch scripts MUST NOT use these:

| Idiom | Bash-3.2 alternative |
| -- | -- |
| `${VAR,,}` (lowercase expansion) | `echo "$VAR" \| tr '[:upper:]' '[:lower:]'` |
| `${VAR^^}` (uppercase) | `echo "$VAR" \| tr '[:lower:]' '[:upper:]'` |
| Associative arrays (`declare -A`) | Use parallel indexed arrays, or jq for keyed data |
| `mapfile` / `readarray` | `while IFS= read -r line; do ...; done < <(cmd)` |
| `&>` redirect | `>file 2>&1` |

Two acceptable workarounds when an operator's script grows past bash-3.2's expressiveness:

1. **Switch to `zsh`** — macOS's default since Catalina. `#!/usr/bin/env zsh` shebang. Same syntax for almost everything except the lowercase/uppercase expansions (zsh uses `${(L)VAR}` / `${(U)VAR}`).
2. **Switch to `#!/usr/bin/env bash`** AND require an explicit Homebrew bash install (`brew install bash`). The shebang then picks up `/opt/homebrew/bin/bash` (4.x or 5.x). Scripts shipped this way must document the prerequisite in their top comment.

`packages/forge/src/cli.ts` itself runs under Node and is unaffected by host bash version. The constraint applies only to operator-facing shell scripts.

Memory `forge_audit_2026_05_10` documented the original incident (silent failure of `${ISSUE,,}` on macOS) that motivated this section.

---

## Dependencies

### Runtime

| Package | Version | Used by |
|---|---|---|
| `chalk` | `^5` | terminal colors in report printers |
| `glob` | `^11` | code-file resolution from agent globs (`commands/audit/context-builder.ts:3`, `commands/spec/context-builder.ts:3`) |
| `tar` | `^7` | deterministic `.tar.gz` archive creation in `commands/mirror-export/orchestrator.ts` (gzip + portable + fixed mtime) |
| `tsx` | `^4` | ESM loader hook registered in `bin/forge.mjs` so the compiled CLI can dynamically import `@rapoport-studio/muse/mirror`'s raw-`.ts` source under Node |
| `zod` | `^3` | Render output schema validation (`commands/render/types.ts`) and shared response-parsing helper (`core/json.ts`) |

### Internal

| Package | Version | Used by |
|---|---|---|
| `@rapoport-studio/muse` | `workspace:*` | `core/mirror-loader.ts` re-exports `loadMirror` from the `@rapoport-studio/muse/mirror` subpath. Consumed by `commands/render/orchestrator.ts`. The boundary exception is documented in § Boundaries. |

#### Build order (RAP-401)

Forge's `tsc` build needs `@rapoport-studio/muse/dist/mirror/index.d.ts` resolvable from its consumer subpath. Fresh `pnpm install && pnpm --filter @rapoport-studio/forge build` from a clean workspace fails with `Cannot find module '@rapoport-studio/muse'` because muse hasn't been built yet.

The fix is one of:

- **Build-graph declaration (preferred):** add muse as a `dependsOn` in `turbo.json` for the `forge#build` task so `pnpm --filter @rapoport-studio/forge build` builds muse first.
- **Feature-flag the import:** stub `core/mirror-loader.ts` to lazy-require `@rapoport-studio/muse/mirror` only when `commands/render/orchestrator.ts` is exercised. Keeps muse optional at build time but adds a hot-path indirection.

The build-graph option wins on developer ergonomics; the feature-flag option is a fallback only if muse's deferred-package status (per CLAUDE.md § Package boundaries) makes the build-graph dep unacceptable. Track the chosen shape in the RAP-401 PR.

### Dev

| Package | Version | Used by |
|---|---|---|
| `@types/node` | `^22.0.0` | type checking |
| `@types/tar` | `^6` | type checking for the `tar` runtime dep (no bundled types) |
| `typescript` | `^5.6.0` | `tsc` build |
| `vitest` | `^3` | `__tests__/*.test.ts` |

No peer dependencies declared.

---

## Boundaries

Enforced by `eslint.config.mjs`. Forge is a near-leaf — one narrow internal-package exception.

- Forge **MAY NOT** import from `@rapoport-studio/{ui,db,config,i18n,auth,canvas}` or any app.
- Forge **MAY** import from `@rapoport-studio/muse/mirror` (read-only loader subpath only). This is the only internal-package exception, justified because (a) forge is a dev/CI tool, never bundled into apps; (b) the muse mirror subexport is pure Node — `zod` + `gray-matter` only — with no Worker-runtime coupling; (c) reimplementing the schema in forge would create drift between the loader and its only non-Studio consumer. The single cross-package import lives in `packages/forge/src/core/mirror-loader.ts`; all other forge code consumes the loader through this shim. Future "just one more" pressure must surface as a separate change proposal, not an in-flight expansion of this exception.
- Forge **MAY** import from external packages.
- Forge **MAY** read repo files via filesystem (`fs.readFile`) — by design, since it operates on consumer code.
- Forge **MAY NOT** be a runtime dependency of either app — it's a dev/CI tool only.

`forge` is invoked from npm scripts, CI workflows, or the developer terminal. Both apps remain shippable to the edge without bundling Forge.

---

## Conventions

### One module per command

`audit/`, `spec/`, `review/`, `estimate/` — each has its own orchestrator, context builder, prompt set, and report formatter. Cross-command code lives in `core/`.

### Lazy imports in `cli.ts`

The CLI dispatches with dynamic `import()` so each command's dependencies (prompt files, persist shim, etc.) load only when invoked. Keeps `forge --help` fast.

### Direct `fetch` over SDKs

Both Anthropic and Linear are accessed via raw `fetch` rather than their SDKs. Tradeoff: more code (auth, retry, parsing), full control (no version coupling, transparent failure modes, no implicit retries).

### Reports are filesystem-first

Every command writes JSON + Markdown reports to disk before any optional persistence. Consumers can ignore `--persist` and consume the files directly.

### No business logic in `core/`

`core/` contains generic clients and utilities (Anthropic, Linear, persist, spec-reader, project-context). Per-command logic stays in `commands/<name>/`. This boundary is enforced by code review, not lint rules.

### Verifier and orchestrator pattern (audit)

Each agent runs independently against the same module context. The Opus verifier runs after all agents complete, weighing findings by agent type (security findings weigh more than SEO findings). Final report combines original findings + verifier verdicts.

---

## Extension

### Adding a new audit agent

1. Add a prompt file under `src/commands/audit/prompts/<agent-name>.md`.
2. Add the agent to the relevant module's `agents` block in the registry.
3. Add weight in the verifier (`commands/audit/verifier.ts`).
4. Add report-formatting logic (`commands/audit/report.ts`) if the agent produces a novel finding shape.

### Adding a new command

1. Create `src/commands/<name>/` with `orchestrator.ts`, `context-builder.ts`, `prompts/`, `report.ts`.
2. Wire into `cli.ts` dispatch (lazy import).
3. Add tests in `__tests__/<name>/`.
4. Update `commands.md § Command surface` table.

### Adding a new project as Forge consumer

1. Provide a module registry in `ForgeConfig.modules` (see `entities.md § Module registry`). Optionally override `auditPromptsDir` / `fixSystemPromptPath` if the consumer's architecture differs from Forge's defaults.
2. Provide an agent prompt set (or override the defaults) — the in-package prompts are coupled to a specific architecture style.
3. Provide a project context file (`AGENTS.md` or similar) describing the project's conventions.
4. Provide a persistence adapter if `--persist` is used (or skip persistence).

---


## Event emission

Forge emits lifecycle events to `~/.rapoport/events/YYYY-MM-DD.jsonl` via `@rapoport-studio/cli-core/events`. All emissions are **fire-and-forget** — Forge never fails due to event emission.

| Event kind | Emitted when | Key payload fields |
|---|---|---|
| `run.started` | Any Forge command begins (via the `withRunEvents` wrapper in `core/instrumentation.ts`, after Forge init) | `command`, `args` |
| `run.finished` | The same command completes (success or error) | `exitCode: 0\|1`, `durationMs` |
| `model.call` | Anthropic API call completes inside `callClaude` | `model`, `role`, `tokensIn`, `tokensOut`, `costUSD` |
| `pr.opened` | GitHub PR is successfully created by the build orchestrator | `url: prUrl`, `branch: branchName` |
| `acceptance.gate` | The spec-acceptance gate finishes inside `forge build` (`emitAcceptanceGate`) | `passed`, `failed`, `skippedLlmCarveout`, `verdict: 'pass'\|'fail'` |
| `memory_tool_*` | Every Memory-tool read/write performed by `core/memory-adapter.ts` (cross-build lessons store) — see § Memory tool for cross-build lessons | per-event; structured log of the Memory operation |

`run.started` / `run.finished` cover **every** command, not just `build` / `plan` — the `withRunEvents` bracket wraps the whole dispatch in `cli.ts:100`. The `acceptance.gate` event is not yet part of the cli-core `EventSchema` discriminated union, so `emitAcceptanceGate` writes it directly to the append log rather than through `emit()`.

Each event envelope carries:
- `runId`: stable identifier for the run, produced by `randomUUID()` (a bare UUID; `core/instrumentation.ts:20`). It is **not** a structured `forge-<key>-<cmd>-<timestamp>` string.
- `cli: 'forge'`
- `linearId`: derived from the current git branch via `getCurrentLinearIssue()` (optional — omitted when no Linear issue is detected)

All state is managed via `packages/forge/src/core/forge-events.ts`. Call `setForgeRunContext` once per run (done by `withRunEvents`), then use the named helpers (`emitRunStarted`, `emitRunFinished`, `emitModelCall`, `emitPrOpened`, `emitAcceptanceGate`). Events are appended via `proper-lockfile`-guarded appends so concurrent Forge processes write safely. Consumed by `maestro lineage <linear-id>` (Phase 3).

### Reading metrics

Separately from the event log, every `forge build` writes a frozen-schema `BuildMetrics` record after the reviewer runs. The schema lives in `core/metrics.ts` (`BuildMetricsSchema`, `schemaVersion: 1`) and is re-exported from `@rapoport-studio/forge/core` so Maestro/Muse can parse it without depending on the build orchestrator. It persists in **two** places:

1. **Per-build snapshot** — `~/.forge/builds/<RAP-NN>/metrics.json` (one JSON object per build).
2. **Workspace-wide append log** — `~/.forge/metrics/index.jsonl` (one line per build; the file an operator aggregates over).

Key fields (every field is required — the computation site fills zeros / empty arrays rather than omitting):

| Field | Meaning |
|---|---|
| `issueKey`, `wave` | Issue key; wave number (`null` for single-wave builds). |
| `startedAt`, `completedAt`, `durationSec` | ISO 8601 timestamps + wall-time seconds. |
| `outcome` | `completed` \| `failed` \| `cancelled` \| `dead-subprocess` (`dead-subprocess` is distinct from `failed` so crash/concurrency regressions can be isolated). |
| `specChurn` / `codeChurn` | git-diff line + file counts inside / outside `openspec/`. |
| `driftRatio` | `codeChurn.linesAdded / max(specChurn.linesAdded, 1)` — advisory: `<5` precise spec, `5–20` normal, `>20` agent improvising. |
| `plan` | `gatesDeclared`, `gatesPassedFirstTry`, `retryFired`, `retryRecovered`. |
| `toolCalls` | `read` / `edit` / `write` / `bash` / `other` counts + `filesEditedNotInPlan`. |
| `costByRole` | Per-role USD cost (`architect`, `builder`, `reviewer`). |
| `costTotal` | Sum of `costByRole`, stored explicitly so consumers sort without summing. |
| `cacheHitRate` | `cacheRead / (input + cacheCreation + cacheRead)` from the transcript (0–1). |
| `review` | Reviewer `verdict`, `confidence`, `escalated`. |
| `acceptanceAssertions` | Optional — `total` / `passed` / `failed` / `skippedLlmCarveout` / `parseError` from the spec-acceptance gate. Absent on builds with no `verification.md`. |

To read a single build: `cat ~/.forge/builds/<RAP-NN>/metrics.json`. To aggregate cost/token/acceptance data across builds, parse `~/.forge/metrics/index.jsonl` (e.g. `jq -s 'map(.costTotal) | add' ~/.forge/metrics/index.jsonl` for total spend, or `jq -s 'map(.cacheHitRate) | add/length'` for the mean cache hit rate). Field renames are breaking changes and must bump `schemaVersion`.

---

## Open questions

Resolved during the May 2026 stabilization arc:

- **~~Cross-process cancel~~** — RESOLVED 2026-05-09 by RAP-153 Phase A. `forge cancel <RAP-NN>` from a separate terminal writes an empty sentinel at `~/.forge/state/<RAP-NN>.cancel-requested`. The Builder orchestrator polls for the sentinel at every checkpoint gate (`enterCheckpoint` is the polling site); on observation it unlinks the sentinel and throws `CancelledByExternalSignal`. The catch block routes that error through the same `aborted` branch as the in-process `AbortController`. See `entities.md § Cancel runtime` for the full mechanism.
- **~~Streaming vs batch responses~~** — RESOLVED prior to May 2026 in `core/anthropic.ts:275-347` (`consumeSSEStream`). All non-`--json` invocations stream by default; SSE events from `message_start` / `content_block_delta` / `message_delta` are parsed and per-line `text_delta` writes to stderr when stdin is TTY. `--json` mode bypasses streaming and uses the non-stream path. Cost-tracking accounts for streamed responses identically to batch.
- **~~Model selection per agent~~** — RESOLVED 2026-05-11 by `improve-forge-throughput` C-2. Per-audit-agent model bindings via `ModuleConfig.agents[<slug>].model` (module-registry defaults) layered with `ForgeConfig.models.auditAgents` (consumer override). Studio module-registry defaults documented in § Performance optimizations § Per-agent model tiering. Cost-conscious carve-out for explicitly-named low-stakes audit sub-pipelines (`seo`, `growth`, `pm`, `ui-designer`) per agent-model-mapping.md § D-9.

Remaining open:

- **Reasoning vs solving.** Forge separates "find issues" (audit) from "fix issues" (out of scope). Some consumers might want a fix-and-PR loop instead. That's a different product shape; not blocked, not planned.
- **Self-improvement / dogfooding.** Forge could audit itself. Today the registry doesn't include a `forge` module. Adding one would generate issues against Forge's own code — useful but currently unwired. Activation gate: when external consumer (Unbind, Vivod, Dentour) onboarding stabilizes, dogfood Forge-audits-Forge in studio.

---

## Modern AI stack (applied 2026-05-13 from change upgrade-forge-to-modern-ai-stack)

> Research basis: [nodejs-ai-frameworks-landscape-2026.md](../../_methodology/research/nodejs-ai-frameworks-landscape-2026.md) · [ai-risk-frameworks-and-indexing.md](../../_methodology/research/ai-risk-frameworks-and-indexing.md)

### Inference layer

Single adapter at `packages/forge/src/inference/index.ts` using the Vercel AI SDK (`ai` + `@ai-sdk/anthropic`). Per-persona functions live in `packages/forge/src/inference/personas/`:

| File | Exported function | Anthropic options |
|---|---|---|
| `personas/architect.ts` | `callArchitect` | `claude-opus-4-8`, adaptive thinking when an effort level resolves, `maxTokens` default `65536`, cacheControl ephemeral |
| `personas/builder.ts` | `callBuilder` | `claude-sonnet-4-6`, cacheControl ephemeral |
| `personas/verifier.ts` | `callVerifier` | `claude-opus-4-8`, cacheControl ephemeral |
| `personas/planner.ts` | `callPlanner` | `claude-sonnet-4-6`, cacheControl ephemeral |

Each function composes `generateText` / `streamText` / `generateObject` from `ai` with `providerOptions.anthropic` for Anthropic-specific features (thinking, cacheControl). Tools are passed as Zod schemas; the SDK converts them to the Anthropic tool format. Streaming uses `streamText`, returning an `AsyncIterable`.

Pre-migration: `core/anthropic.ts` used raw `fetch()`. That file is now deleted; `commands/audit/batch.ts` is the sole remaining direct `@anthropic-ai/sdk` consumer (Anthropic Message Batches API — no Vercel SDK equivalent at migration time).

### Agent Skills

Skills are per-developer instructional memory injected into the architect and builder system prompts.

**Directory layout:**

```
~/.forge/
└── skills/
    ├── rls-pairing/
    │   ├── SKILL.md
    │   └── examples/
    ├── state-json-cross-check/
    │   └── SKILL.md
    └── auto-flags-eof/
        └── SKILL.md
```

**`SKILL.md` frontmatter contract:**

```yaml
---
name: rls-pairing          # kebab-case, ≤64 chars
description: >             # ≤1024 chars; injected into system prompt
  When generating Supabase migrations with new tables or RLS-relevant
  columns, always include RLS policies in the same migration file.
allowed-tools: [read_file, write_file, run_test]
---
```

**Discovery** (`packages/forge/src/skills/discovery.ts`): at Forge startup, walks `~/.forge/skills/*/SKILL.md`, parses YAML frontmatter (gray-matter), validates `name` and `description`, and returns a skill index. The index (name + description, ~100 tokens per skill) is injected into the architect and builder system prompts. Full skill body is loaded only when the architect's chain-of-thought references a matching name.

Hard cap: total skills index ≤ 2000 tokens. Discovery fails with a clear message if exceeded, forcing the operator to prune.

**Seed skills** (`packages/forge/src/seed-skills/`): three studio-canonical skills committed to the repo:
- `rls-pairing` — always pair new Supabase table migrations with RLS policies
- `state-json-cross-check` — cross-check `state.json` after every build checkpoint
- `auto-flags-eof` — auto-append missing EOF newlines in generated files

`forge install-skills` copies seed skills to `~/.forge/skills/` on first run (no overwrite). Skills are not committed to the consuming repo — they are per-developer local files.

`tools/lint-skills.mjs` validates frontmatter on all `~/.forge/skills/*/SKILL.md` files in CI.

### MCP server

`packages/forge/src/mcp-server/` implements the Model Context Protocol server interface via `@modelcontextprotocol/sdk`.

**Exposed tools:**

| MCP tool | Wraps |
|---|---|
| `forge_build` | `forge build <issue-id>` |
| `forge_plan` | `forge plan <issue-id>` |
| `forge_dispatch` | `forge dispatch --background ...` |
| `forge_status` | `forge status` |
| `forge_archive` | `forge archive <change-slug>` |
| `forge_epic_close` | `forge epic --close <epic-id>` |

Each tool has a Zod input schema and a typed result that includes `state.json` contents. All tool implementations acquire the global build lock (same path as CLI) — concurrent MCP requests serialize.

**Transports:**
- `pnpm forge mcp-server` — stdio transport (default)
- `pnpm forge mcp-server --http :3030` — HTTP transport for remote MCP clients

**Claude Code client config:**

```json
{
  "mcpServers": {
    "rapoport-forge": {
      "command": "pnpm",
      "args": ["--filter", "@rapoport-studio/forge", "mcp-server"]
    }
  }
}
```

### 4-layer prompt cache architecture

| Layer | Content | TTL | Notes |
|---|---|---|---|
| 1 | System prompt + persona role + skills index | 5 min | Stable per persona; always a cache hit after warmup |
| 2 | Tool definitions + MCP tool schemas | 5 min | Stable per turn |
| 3 | OpenSpec bundle / repo-map / capability index | 1 hour | Stable across an epic; 2× write cost amortized over many reads |
| 4 | Current task + diff context | no cache | Always unique |

Implemented in `packages/forge/src/cache/breakpoints.ts`:

```typescript
export function buildCachedMessages(args: {
  systemPrompt: string;   // L1
  tools: Tool[];          // L2
  openspecBundle: string; // L3
  currentTask: string;    // L4
}): CoreMessage[] {
  return [
    { role: 'system', content: args.systemPrompt,
      providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } } },
    // L2: cache breakpoint set at tools registration via Vercel SDK
    { role: 'system', content: args.openspecBundle,
      providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } } },
    { role: 'user', content: args.currentTask },
  ];
}
```

**Observability** (`packages/forge/src/observability/cache-metrics.ts`): logs per turn:
- `cache_creation_input_tokens` — tokens written to cache (billed at 1.25× base rate)
- `cache_read_input_tokens` — tokens read from cache (90% discount vs base rate)
- Ratio of cache reads to creates

If ratio < 2:1 after a stable warmup period (≥10 consecutive runs), breakpoints are mis-placed; investigate layer boundaries.

---

## Spec-acceptance gate — core/acceptance.ts (applied 2026-05-14 from change `add-spec-acceptance-gate`)

`packages/forge/src/core/acceptance.ts` added as a shared core module. It provides two exports consumed by `commands/build/orchestrator.ts`:

- `parseVerificationMd(markdown: string): AssertionSpec[]` — parses the `## Acceptance` section of `openspec/changes/<slug>/verification.md` into typed assertion specs.
- `runDeterministicChecks(specs, ctx): Promise<AssertionResult[]>` — runs each assertion deterministically (filesystem checks, `grep`, `pnpm`, `gh`, `jq` invocations) and returns pass/fail/skipped results.

The module depends only on `node:child_process`, `node:fs`, and `node:path`. It has no internal-package imports (per CLAUDE.md § Package boundaries). Design source-of-truth: `openspec/archive/2026-05-14-add-spec-acceptance-gate/design.md § 3`.

The module is wired into the build orchestrator between the `migrations` checkpoint and the optional `code-review` checkpoint. See `verifier.md § Spec-acceptance gate` for the full runtime behaviour and six assertion shape table.

---

## Untrusted input boundary (applied 2026-05-16 from change `enforce-untrusted-input-boundary`)

### Untrusted input boundary

Untrusted text inputs (Linear bodies, PR review comments, PR-branch repo content, Telegram messages, downstream API responses) are wrapped in `<untrusted_input source="...">` sentinel tags at prompt-build time. The agent's system prompt forbids following instructions inside the block. Convention is documented in [`openspec/_methodology/security/untrusted-input-convention.md`](../../_methodology/security/untrusted-input-convention.md).

---

## Sandbox (applied 2026-05-16 from change `add-forge-docker-sandbox`)

Forge Builder mode executes bash commands on behalf of the Claude agent. Without isolation, a compromised or manipulated agent prompt could read host secrets, exfiltrate data, or destroy files outside the build worktree. The sandbox layer wraps every bash call in a controlled execution environment.

### SandboxProvider interface

`packages/forge/src/core/sandbox/types.ts` defines the provider contract:

```ts
interface SandboxHandle {
  readonly buildId: string;
  readonly workingDir: string;   // path inside the sandbox where the worktree is mounted
  readonly provider: 'docker' | 'host';
}

interface SandboxProvider {
  start(args: {
    worktreeRoot: string;            // absolute host path
    envAllowlist: readonly string[]; // env var names to forward; values come from host process.env
    buildId: string;
  }): Promise<SandboxHandle>;

  exec(args: {
    handle: SandboxHandle;
    command: string;
    cwd?: string;
    timeoutMs?: number;
  }): Promise<ExecResult>;

  stop(handle: SandboxHandle): Promise<void>;
}
```

`start` is called once per build. `exec` is called per bash command. `stop` is called on build completion, failure, or cancellation. Provider-agnostic: a future `podmanSandbox()` or `firecrackerSandbox()` slots in without changing call sites.

### Default provider

The default provider is determined by the Phase 5 performance smoke test outcome:

- **`docker` (target default):** Docker provider, enforces all isolation guarantees below. Enabled only when `docker_walltime ≤ 1.30 × host_walltime` on representative builds.
- **`host` (current Phase 4/5 default):** Host bash provider, no isolation. Each build logs `[UNSAFE] Host bash provider in use` on every `start()`. Production builds should migrate to `docker` once the perf gate clears.

Provider is configured in `forge.config.mjs § sandbox.provider`. CLI overrides: `--sandbox=docker` / `--sandbox=host` (the parser only accepts `=`-delimited syntax, not a space-separated value) or `--unsafe-host-bash` (see `commands.md`).

### Docker provider isolation model

When `dockerSandbox()` is active:

#### Filesystem

- The container's root (`/`) is the `node:22-bookworm-slim` image filesystem — completely separate from the host filesystem.
- **Only one bind-mount:** `<worktreeRoot>:/work:rw`. The worktree is accessible as `/work` inside the container.
- Host paths (`~/.config/`, `~/.ssh/`, `~/.forge/`, any path outside the worktree) are inaccessible from the container. Attempts return `ENOENT`.
- **Residual:** The worktree itself is mounted rw, so destructive commands targeting `/work` (or `/` which recurses into it) will affect the worktree. The worktree can be recreated via `git worktree add`. Host directories above the worktree root are safe.

#### Environment

- `buildSandboxEnv({ hostEnv, allowlist })` filters `process.env` to only the keys in `envAllowlist`. All other host env vars are dropped.
- Default allowlist (per `forge.config.mjs § sandbox.docker.envAllowlist`): `ANTHROPIC_API_KEY`, `GITHUB_BOT_TOKEN`, `LINEAR_BOT_API_KEY`, `PATH`, `HOME`, `USER`.
- Every env var must be explicitly opted in. Mistakes surface immediately as Forge errors, not as silent leakage.

#### Network

- A per-build bridge network `forge-sandbox-<buildId>` is created via `docker network create`.
- On Linux: iptables egress rules allow traffic only to resolved IPs of the `outboundAllowlist`. All other forwarded traffic from the container subnet is dropped.
- Default outbound allowlist: `registry.npmjs.org`, `github.com`, `objects.githubusercontent.com`, `api.anthropic.com`, `api.linear.app`.
- **macOS gap:** iptables rules are skipped on macOS (`docker-network.ts` checks `process.platform === 'linux'`). Container network is unrestricted on macOS developer machines. Full egress enforcement requires Linux deployment.

#### Lifecycle

`crash-recovery.ts` sweeps stale containers on every `forge build` invocation: lists `docker ps --filter name=forge-`, cross-checks against `~/.forge/state/*.json`, and stops/removes any orphaned containers.

### Threat model reference

| Threat | Mitigation layer | Status |
|---|---|---|
| AT-04 — bash reads host secrets (`~/.config/infisical/`, `~/.ssh/`) | Filesystem isolation (single `/work` mount) | Mitigated on both macOS + Linux |
| AT-05 — bash destroys files outside worktree | Filesystem isolation (only worktree mounted) | Partially mitigated; worktree itself is rw-mounted |
| AT-06 — agent force-pushes to `main` | **Branch protection** (not sandbox) — sandbox allows github.com for normal operation | **Follow-up required:** main branch protection not yet configured |
| Outbound exfiltration | iptables egress rules | Mitigated on Linux; gap on macOS |

See [`openspec/_methodology/security/agents-threat-model.md`](../../_methodology/security/agents-threat-model.md) for full threat scores and mitigations.

### `--unsafe-host-bash` escape hatch

When Docker has real failure modes (daemon crashes, image-build failures, Apple Silicon issues), `--unsafe-host-bash` overrides the configured provider to `hostSandbox()` for one run:

- Logs `[UNSAFE] Host bash provider in use for build <buildId>` to stderr on every `start()`.
- Injects `[UNSAFE] Host bash in use; sandbox disabled.` into every Anthropic prompt for that build (visible in chat output).
- No filesystem, environment, or network isolation applies.

Use only when Docker is genuinely unavailable and the build cannot wait. Document the reason in the PR description. See `commands.md § forge build` for the full flag reference.

---

## Per-role effort + adaptive thinking (applied 2026-05-16 from change `forge-effort-adaptive-thinking-control`)

Forge inference sets reasoning effort per persona. `core/anthropic.ts` exports `EffortLevel` (`low | medium | high | xhigh | max`), `ForgeRole` (`architect | planner | builder | verifier` — distinct from `ModelRole`), `EFFORT_DEFAULTS` (architect/planner → `xhigh`, builder → `medium`, verifier → unset), and `resolveEffort(role, override)`.

The `buildOmittingProvider` fetch-wrap in `inference/index.ts` injects `output_config.effort` and `thinking: { type: 'adaptive' }` into the wire body, gated by `supportsEffort` / `supportsAdaptiveThinking` (effort: Opus 4.5/4.6/4.7 + Sonnet 4.6; adaptive thinking: Opus 4.6/4.7 + Sonnet 4.6; never Haiku). This is a documented bridge — `@ai-sdk/anthropic` 1.2.x has no native `output_config`; remove the injection branch when a typed `outputConfig` field ships.

`forge plan` and `forge build` accept `--effort=<level>` to override the per-role default. The architect persona `maxTokens` default is `65536` (`personas/architect.ts:40`) for `xhigh` headroom — this value supersedes any older `8000`-token figure. Design source-of-truth: [`openspec/archive/2026-05-16-forge-effort-adaptive-thinking-control/design.md`](../../archive/2026-05-16-forge-effort-adaptive-thinking-control/design.md).

### How model tiering and effort compose

`resolveModel` and `resolveEffort` (both in `core/anthropic.ts`) are two orthogonal layers, resolved independently for each call:

- **`resolveModel(role, config)` — *which* model.** Governed by `ModelRole` (`architect` | `builder` | `reviewer` | `reviewerEscalation`). Built-in `ROLE_DEFAULTS`: architect → Opus (`claude-opus-4-8`), builder / reviewerEscalation → Sonnet (`claude-sonnet-4-6`), reviewer → Haiku (`claude-haiku-4-5`). A consumer's `forge.config.mjs § models[role]` override wins over the default. The reviewerEscalation tier climbs one step from Haiku to Sonnet on low-confidence or JSON-parse failure rather than to Opus — the reviewer is a structured-JSON classifier and Sonnet already handles those reliably for ~5× less cost than Opus (RAP-217 Phase 5 comment in `core/anthropic.ts:98-104`). This is the RAP-213/RAP-214/RAP-217 per-role model-tiering layer.
- **`resolveEffort(role, override)` — *how hard* it thinks.** Governed by the separate `ForgeRole` type (`architect` | `planner` | `builder` | `verifier`) and `EFFORT_DEFAULTS`: architect / planner → `xhigh`, builder → `medium`, verifier → unset (no effort field sent). The `--effort=<level>` CLI flag is the override. This is the newer RAP-926 layer; it does not change which model is called.

The two are not alternatives — every Builder-mode call picks a model from the tiering layer **and** an effort level from the effort layer. Effort is only forwarded for models where `supportsEffort` returns true (Opus 4.5/4.6/4.7 + Sonnet 4.6); for Haiku the effort field is omitted. Note the two `*Role` type unions differ deliberately: `ModelRole` has `reviewerEscalation` (no effort equivalent), `ForgeRole` has `planner` (the planner reuses the builder/architect model bindings but carries its own effort default).

---

## Structured outputs replace json-retry (applied 2026-05-16 from change `adopt-structured-outputs-retire-json-retry`)

The hand-rolled JSON retry loop (`core/json-retry.ts`) is removed. `inference/structured.ts` exports `callForgeStructured`, which uses the Vercel AI SDK `generateObject` (`mode: 'tool'`) so the Anthropic API enforces schema conformance server-side — no retry loop, no markdown-fence stripping.

The five JSON call sites — planner, spec checker, audit verifier, audit orchestrator, build reviewer — pass draft-07 JSON Schema modules from `core/schemas/`. Design source-of-truth: [`openspec/archive/2026-05-16-adopt-structured-outputs-retire-json-retry/design.md`](../../archive/2026-05-16-adopt-structured-outputs-retire-json-retry/design.md).

---

## OpenSpec bundle pinned to 1h cache tier (applied 2026-05-16 from change `pin-openspec-bundle-to-1-hour`)

The OpenSpec-bundle system block (`cache/breakpoints.ts`, layer L3) is pinned to `cache_control: { type: 'ephemeral', ttl: '1h' }` so an epic-length build reuses it across the whole run instead of re-writing every 5 minutes. The other breakpoint layers retain their existing TTLs. Design source-of-truth: [`openspec/archive/2026-05-16-pin-openspec-bundle-to-1-hour/design.md`](../../archive/2026-05-16-pin-openspec-bundle-to-1-hour/design.md).

---

## Memory tool for cross-build lessons (applied 2026-05-16 from change `forge-memory-tool-for-cross-build`)

The informal `core/lessons.ts` cross-build store is now backed by the Claude Platform Memory tool. `core/memory-adapter.ts` wraps the Memory tool client behind the typed taxonomy in `core/schemas/lessons-schema.ts` (repo conventions, failure patterns, verification-gate quirks). `core/lessons.ts` keeps its public exports — no call-site changes — and gains a circuit-breaker: if the Memory tool is unavailable, Forge falls back to the previous in-process store with a `WARN` log instead of crashing. Every Memory read/write emits a structured `memory_tool_*` log event. Operational procedures live in the archived runbook. Design source-of-truth: [`openspec/archive/2026-05-16-forge-memory-tool-for-cross-build/design.md`](../../archive/2026-05-16-forge-memory-tool-for-cross-build/design.md).

---

## Server-side compaction for long builds (applied 2026-05-16 from change `server-side-compaction-for-long-forge`)

Forge can request server-side compaction (`context-management-2025-06-27`) on the architect/planner inference paths and context editing (`context-editing-2025-06-27`) on the builder loop, gated by per-agent config flags — `FORGE_ARCHITECT_COMPACTION`, `FORGE_PLANNER_COMPACTION`, `FORGE_BUILDER_COMPACTION`, `FORGE_BUILDER_CTX_EDIT` — all **default OFF**. Compaction/context-editing are restricted to Opus 4.7/4.6 + Sonnet 4.6. Flag graduation to production requires the runbook's promotion criteria (measured headroom improvement, zero quality regression, two successful long-epic runs) and is enforced by a CI lint guard. Design source-of-truth: [`openspec/archive/2026-05-16-server-side-compaction-for-long-forge/design.md`](../../archive/2026-05-16-server-side-compaction-for-long-forge/design.md).

---

## Outbound prompt secret redaction (applied 2026-05-26 from change `add-forge-prompt-secret-redaction`)

Every outbound Anthropic request from Forge passes through `scrubPrompt` (`packages/forge/src/core/secret-scrub.ts`) before the network call. The scrub regex-strips seven known-secret patterns — `anthropic-key`, `github-personal-token`, `github-oauth-token`, `github-server-token`, `linear-api-key`, `jwt-three-segment`, `hex-token-long` — and replaces each match with `[REDACTED-<pattern-name>]`. Both the `system` value (string or `SystemBlock[]`) and the `userMessage` are scrubbed; redaction counts are aggregated across both surfaces.

**De-duplication (applied 2026-05-26).** The pure primitives (`SCRUB_PATTERNS`, `scrubString`, `scrubSystem`, types) now re-export from `@rapoport-studio/cli-core/security` per the rule-of-three lift (Forge + Muse + cli-core/llm — all three consumers wired). The Forge-specific `scrubPrompt` glue stays local because the `{ system, userMessage }` shape is `callClaude`-specific (cli-core/security hosts shape-aware helpers for raw-Anthropic and Vercel-AI-SDK shapes, but not this one). Consumers' `import { scrubPrompt } from './secret-scrub.js'` paths are unchanged. Source-of-truth for the primitives: [`openspec/current/cli-core.md § Security primitives`](../cli-core.md).

When at least one pattern matches, `callClaude` emits a `prompt.secret_redaction` event (defined in `packages/cli-core/src/events/{kinds,schema}.ts`) carrying `role`, `model`, and the per-pattern match counts — **never the value** (`D-FPSR-2`). Pavel can grep the event log for spike-detection; volume signals either a real near-miss or a false-positive pattern that needs tuning.

The pattern set is the floor, not the ceiling (`D-FPSR-1`); new vendor key shapes are added by amendment to `SCRUB_PATTERNS`. The scrub runs once on the full body (`D-FPSR-3`); it does not care about provenance, only shape — a secret in operator-trusted content (a Pavel-pasted Linear issue body) is caught the same way a secret in untrusted content would be.

Threat closed: **AT-07** in [`openspec/_methodology/security/agents-threat-model.md`](../../_methodology/security/agents-threat-model.md) — RPN 12 → 4 post-mitigation. Source-of-truth: [`openspec/archive/2026-05-26-add-forge-prompt-secret-redaction/`](../../archive/2026-05-26-add-forge-prompt-secret-redaction/).

**Follow-up not in scope here:** Muse's separate Anthropic-SDK call sites need a parallel mitigation — tracked as `Q-FPSR-3` in the archive's `design.md`.

---

## Builder file-write path-scope enforcement (applied 2026-05-26 from change `enforce-forge-commit-file-path-scope`)

Every `Write` and `Edit` tool invocation by the Forge Builder subprocess passes through `validateWritePath` (`packages/forge/src/commands/build/path-scope-validator.ts`) before the SDK is allowed to perform the write. The validator:

1. Rejects absolute paths unconditionally
2. Resolves the input via `path.resolve(worktreeRoot, ...)` and rejects paths that escape the worktree root
3. Rejects paths inside the worktree that fall outside the **declared scope** for the active change

**Declared scope** is computed once at build start in `commands/build/orchestrator.ts` as the union of:
- The change folder itself (`openspec/changes/<active-slug>/`)
- Every path token extracted from the change's `tasks.md` by `parse-affected-paths.ts` (permissive parse over the studio's well-known top-level prefixes: `openspec/`, `packages/`, `apps/`, `supabase/`, `tools/`, `public/`, `scripts/`, `.github/`)

A rejection returns a tool-use error to the model (via `canUseTool`'s `behavior: 'deny'`) with a human-readable description of the verdict — the model can correct and retry within its budget rather than crashing the build (`D-FCFPS-2`). Every rejection emits a `commit_file.path_rejection` event (defined in `packages/cli-core/src/events/{kinds,schema}.ts`) carrying tool name, attempted path, resolved path, and verdict reason; the value is path-only metadata, never file content.

The validator falls through to allow when the active change has no declared-scope roots (e.g. a build dispatched outside the OpenSpec workflow). This is the safer default — without an allowlist, the legacy unguarded behaviour stands. When the change folder exists, its own root is always included, so the allowlist is non-empty in the normal path.

Threat closed: **AT-12** in [`openspec/_methodology/security/agents-threat-model.md`](../../_methodology/security/agents-threat-model.md) — RPN 12 → 4 post-mitigation. Source-of-truth: [`openspec/archive/2026-05-26-enforce-forge-commit-file-path-scope/`](../../archive/2026-05-26-enforce-forge-commit-file-path-scope/).

**Follow-ups not in scope here:** other write tools (`update_proposal`, `update_design`, `update_tasks`) gain analogous validators when a concrete attack surface is observed (`Q-FCFPS-3`); symbolic-link target traversal (`Q-FCFPS-2`); read-side scope (`read_repo_file`) is tracked separately under RAP-830 / AT-14.

---

## Per-issue cost cap + per-day alert (applied 2026-05-26 from change `add-forge-per-issue-cost-cap`)

Forge now caps cumulative Anthropic spend per `RAP-NNN` **across all `forge build` invocations**, not just per-build. The per-build cap (`budgets.build`, default $15) was always reset to $0 at the start of every `forge build`; an auto-build label loop or operator-error re-dispatch of the same issue could burn arbitrary $ as each invocation started fresh budget.

Two new `ForgeConfig.budgets` fields close the gap:

- **`budgets.perIssue`** (default `$30`, hard cap). The orchestrator reads any prior state file for this `issueKey` at init via `tryReadStateForCarryOver` (`packages/forge/src/core/state.ts`) and seeds the new `BuildState.cumulativePriorBuildsCostUsd` with `prior.costAccumulated + prior.cumulativePriorBuildsCostUsd`. On every cost event, `recordRoleCost` (`commands/build/orchestrator.ts`) checks `(cumulativePriorBuildsCostUsd + costAccumulated) >= perIssueCap` and throws `BudgetExceededError` with role `'perIssue'` when the cumulative crosses the threshold. Rationale: $30 is 2× the per-build cap — leaves headroom for legitimate retries + a 3-wave epic while catching "loop fired the same issue 10 times" at the second re-dispatch (`D-FPICC-1`).

- **`budgets.dailyAlert`** (default `$50`, **soft** alert). On every cost event, `recordRoleCost` lazily sums `costAccumulated + cumulativePriorBuildsCostUsd` across every `~/.forge/state/*.json` file dated today (UTC) via `sumTodaysSpendUsd`; if the total crosses the threshold, one stderr warning per build is emitted (the `dailyAlertFired` closure boolean ensures once-per-build, not once-per-event). **No hard stop** — a hard daily kill would crash legitimate multi-issue same-day shipping; the alert is signal, the per-issue cap is the protection (`D-FPICC-2`).

Both helpers are best-effort (`D-FPICC-3`): missing or corrupt state files are silently skipped. The daily aggregation is `O(n)` over files in the state dir, but `n` is small (one file per active issue at any time, days-not-thousands cadence).

`BudgetExceededError.role` (`packages/forge/src/errors.ts`) is widened to accept `'perIssue'`. The remediation hint ("Raise `forge.config.mjs` `budgets.<role>`") works correctly for the new pseudo-role without further changes.

Threat closed: **AT-11** in [`openspec/_methodology/security/agents-threat-model.md`](../../_methodology/security/agents-threat-model.md) — RPN 8 → 4 post-mitigation. Source-of-truth: [`openspec/archive/2026-05-26-add-forge-per-issue-cost-cap/`](../../archive/2026-05-26-add-forge-per-issue-cost-cap/).

**Follow-ups not in scope here:** hard kill above some N× the daily alert (`Q-FPICC-1`); decay of `cumulativePriorBuildsCostUsd` after N days of issue inactivity (`Q-FPICC-2`); per-vendor caps (Anthropic-only today); studio UI cost dashboard.
