diff --git a/docs/designs/product/DECISIONS.md b/docs/designs/product/DECISIONS.md index 260f7bb6..b9e2d159 100644 --- a/docs/designs/product/DECISIONS.md +++ b/docs/designs/product/DECISIONS.md @@ -158,6 +158,7 @@ check enforces the mechanical half. Full rationale: | DL-210 | Forge integration testing adds two live-contract tiers above the DL-174 hermetic pyramid: (1) a hermetic golden-fixture replay leg (committed `go/internal/forge/testdata/` fixtures replayed through the stub RoundTripper, an untagged Go test riding moon affected-detection on every forge-affected PR + the main/nightly sweep, no `ci.yml` project enumeration) and (2) a `//go:build livegithub` live-credentials oracle (same scenarios against a throwaway `RigelBuild/compass-forge-testbed` + a Linear test team under test-only bot PATs ≠ the `server_only` production secrets), REQUIRED on forge-affected PRs + nightly, covering GitHub and Linear co-equally (the GitHub-only F1 reviewer≠author 422→APPROVE headline; Linear's PR/review family is `ErrUnsupported`, not a scenario). This is a NEW secret-bearing per-PR CI step — a standalone decision, NOT an amendment: D2's full-stack deterministic tier (`compass-dogfood-e2e/design.md:815-830`) stays secret-free and unchanged; a forward pointer next to D2 records the carve-out. Fork-PR secret safety: the same-repo-head-guarded step goes vacuously green on fork heads (GitHub withholds secrets there); accepted, with the push-to-main run + a maintainer same-repo re-push convention as the catch (0 forks today) | Active (Matt, 2026-08-18) | [forge integration testing §Approach](compass-forge-integration-testing/design.md#approach) | | DL-226 | A message owed to an OFFLINE channel agent member is never silently stranded: the server wakes the member by RESUMING its most recent session (a system-authorized internal sibling of the public resume path, via a new `LatestSessionForAccount` read; fresh start only for a never-started agent; per-agent singleflight + not-live pre-check as cost control — agent-triggered starts are a core product capability, and `StartAgentSession`'s adminOnly PUBLIC door is untouched). Durability split: a mentioned member outside the sweep set (unsubscribed, non-home, non-mandatory) gets a durable owed-mention row `(agent_account_id, message_id)` written at the settle edge before the wake and swept subscription-independently on session start AS A STEER (D5 mention→steer preserved; steer-only precedence intact), cleared on the frozen message_id ack; a subscribed/home/mandatory member gets no owed row — the D2 cursor sweep is its durable backstop and the wake (both the mention arm and the plain-deliver arm) is pure latency. Broadcast mentions (`@everyone`/`@agents`) wake too; N-start amplification accepted. The residual pre-settle window is accepted for MVP (RIG-2490). Amends D5/OQ-3's offline clause by citation | Active (Matt, 2026-08-21); residual pre-settle window closed by DL-240 (RIG-2490, 2026-08-23) | [mention offline redelivery §Decisions](compass-mention-offline-redelivery.md#decisions-ruled-by-matt-2026-08-21) | | DL-240 | The RIG-1641 OQ-5 residual pre-settle mention-loss window (a mention to an offline out-of-sweep-set member, lost when a crash or bus overrun drops `MessagePosted` before the settle-edge `routeMentions` pass) is closed by a per-message delivery marker, NOT a high-water cursor: a nullable `messages.mentions_routed_at BIGINT` (unix ms) set by the consumer after the settle-edge pass, with a recovery scan at consumer start + the `Lagged()` overrun re-subscribe reading `WHERE mentions_routed_at IS NULL` (skipping message_ids in `c.held`), replaying the ONE shared `routeMentions` body, then marking. The killed cursor could advance past a LOWER in-flight seq (`messages.seq` is `BIGSERIAL` assigned at INSERT but the bus event publishes post-commit, `comms.go:361-375`) whose crash-dropped event then stranded the mention forever — a new silent loss the marker cannot express. Seed-forward on first deploy (no historical backfill; compass pre-live). Extends DL-226's no-loss invariant from the settle edge back to the message-post commit; DL-226's wake+owed-mention mechanism is unchanged | Active (Matt, 2026-08-23) | [mention offline pre-settle closure §Resolved decisions](compass-mention-offline-redelivery-pre-settle-closure.md#resolved-decisions) | +| DL-241 | The agent forge native toolset is ten single-purpose tools, one per `ForgeCallRequest` arm (`forge_get_issue`, `forge_get_pull_request`, `forge_list_issues`, `forge_comment_on_issue`, `forge_comment_on_pull_request`, `forge_submit_review`, `forge_create_issue`, `forge_create_pull_request`, `forge_subscribe`, `forge_unsubscribe`), each a native `AgentTool` over a thin `ForgeBroker` on the `RunnerTransport.forge()` seam; `forge_subscribe`/`forge_unsubscribe` ship the complete surface now but return the server's in-band `unimplemented` until the poll-driver lane lands the `agent_forge_subscriptions` writer (DL-163). Multi-forge is exposed: every tool takes an optional forge selector (`forge_provider` + optional `forge_host`, unset = the configured default GitHub forge, DL-202) so an agent targets Linear (issues-only, `repo` = team key, DL-051; PR/review arms return in-band `unimplemented`) as well as GitHub. Mirrors DL-212's comms-toolset count row; the tool-count claim is load-bearing for future toolset-refresh rows | Active (Matt, 2026-08-24) | [forge tools §Tool set and shape](compass-agent-forge-tools/design.md#tool-set-and-shape--ten-native-tools-one-per-arm) | ## Agent roles & prompts diff --git a/docs/designs/product/compass-agent-forge-tools/design.md b/docs/designs/product/compass-agent-forge-tools/design.md new file mode 100644 index 00000000..606442a2 --- /dev/null +++ b/docs/designs/product/compass-agent-forge-tools/design.md @@ -0,0 +1,760 @@ +# Compass agent forge tools + +Status: Active + +Design for the TypeScript agent-side leg of the forge surface: a `forge()` +method on the frozen `RunnerTransport` seam plus a native tool set (`forge.ts`) +that exposes all ten `ForgeCallRequest` arms to the containerized first-party +agent, one tool per arm (the two subscription arms ship now but return the +server's in-band `unimplemented` until the `agent_forge_subscriptions` store +writer lands), with a +multi-forge selector so an agent targets Linear as well as the default GitHub +forge, wired into the container entrypoint's `customTools`. This is +the direct forge sibling of the comms-tools record +([`compass-agent-comms-tools/design.md`](../compass-agent-comms-tools/design.md), +its T3) and the lifecycle tools (`packages/compass-agent/src/lifecycle.ts`): +same broker shape, same identity model, same registration path. Everything +below the agent — the Server write chokepoint, the hub relay, the Runner +gateway leg, and the proto carrier — is COMPLETE and frozen elsewhere +([`compass-forge-write-path/design.md`](../compass-forge-write-path/design.md)); +this record consumes it and designs nothing on the Go side. + +**Grounding.** This record and the code it describes live together in this +repository; citations resolve against this tree directly (`docs/…`, `go/…`, +`proto/…`, `packages/…`). Anchors were verified against this workspace at +authoring (2026-08-24). Named rather than blanket, per the comms-tools +convention: a line range is a claim about a commit and drifts as the file +grows, so load-bearing citations below name the symbol as well as the path. + +Tracker: RIG-2672. + +## Problem / Intent + +The forge write path is built end-to-end and has no caller. The Server-side +chokepoint dispatches all eight domain arms +(`go/server/forge.go` `ExecuteForgeCallAsAccount` — createIssue, +commentOnIssue, createPullRequest, commentOnPullRequest, submitReview, +getIssue, listIssues, getPullRequest; only subscribe/unsubscribe return +`CodeUnimplemented`, gated on the poll-driver lane's subscription-store +writer), the hub relay exists (`go/internal/runnerhub/relay_forge.go` +`Hub.RelayForgeCall` over the `ForgeCaller` seam, mounted at +`go/server/serve.go` `hub.SetForgeCaller(forgeSvc)` via +`buildForgeWriteService`), the Runner gateway leg exists and is tested +(`go/internal/runner/gateway/forge.go` `Gateway.Forge`, fail-closed), and the +proto carrier + generated TS types exist +(`ForgeCallRequest`/`ForgeCallResult`/`ForgeCallError`, +`packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts`). + +But the agent cannot reach any of it: `RunnerTransport` +(`packages/compass-agent/src/transport/index.ts`) exposes `comms()` and +`lifecycle()` and no `forge()`, and no `forge.ts` exists (comms ships five +tools, lifecycle two, forge zero). This record designs the missing TS leg — +the transport method, the `ForgeBroker`, the ten native tools (the two +subscription tools built now over the stub arms) with their multi-forge +selector and per-tool prompt guidance, and the `cli.ts` wiring — and nothing +else. + +## Approach + +### Decisions (pre-resolved by Matt — not reopened below) + +1. **Full surface: all ten `ForgeCallRequest` arms become tools.** Every arm + the wire carries ships as an agent tool in this record — the eight arms + `ExecuteForgeCallAsAccount` dispatches for real PLUS `subscribe`/ + `unsubscribe`. No partial MVP subset, no hidden arms. +2. **One tool per arm.** Single-purpose, well-documented tools — agents perform + better with them — mirroring the comms/lifecycle one-tool-per-verb + precedent. Names: `forge_get_issue`, `forge_get_pull_request`, + `forge_comment_on_issue`, `forge_comment_on_pull_request`, + `forge_submit_review`, `forge_create_issue`, `forge_create_pull_request`, + `forge_list_issues`, `forge_subscribe`, `forge_unsubscribe`. +3. **`forge_subscribe`/`forge_unsubscribe` ship NOW, returning the server's + in-band `unimplemented` until the writer lands.** The backend arms are + `CodeUnimplemented` stubs (`go/server/forge.go`) pending the + `agent_forge_subscriptions` store writer, which the poll-driver lane owns + ([`compass-forge-poll-driver/design.md`](../compass-forge-poll-driver/design.md), + DL-163: the tables land writer-less; that lane brings the writer). Matt + ruled build-all: the tools ship now so the surface is stable and complete; + the two arms simply error at runtime (rendered as a clean in-band + `unimplemented`, with the per-tool guidance saying "not yet wired") until + the writer lands, at which point they light up with no contract rework and + no re-registration. +4. **V1 is multi-forge: the `ForgeRef` selector is exposed on every tool.** + The wave needs GitHub AND Linear (it files Linear issues and opens GitHub + PRs), and the substrate resolves a per-provider coordinate end to end + (`forgeProviderRegistry.resolve`, `go/server/forge.go`; the co-equal Linear + write provider `go/internal/forge/linear.go`, built + unit-tested). Every + tool takes an optional `forge_provider` (+ optional `forge_host`); unset = + the configured default GitHub forge (DL-202). Linear is issues-only + (DL-051): `repo` = team key, PR/review arms return `unimplemented`. Matt + ruled this in, overruling the earlier GitHub-only-v1 / ForgeRef-deferred + scoping. + +### The built substrate this composes with + +- **The Server is the sole write chokepoint, and it is live.** + `go/server/forge.go` `ExecuteForgeCallAsAccount` is the `ForgeCaller` entry + point and per-arm dispatch: it fails a missing caller closed, dispatches the + `call` oneof to the eight arm methods, and returns every domain outcome + in-band on the `ForgeCallResult` (an unset arm is `CodeInvalidArgument`). + Writes resolve identity (`resolveIdentity` — DL-050 stamp Author from the + agent's own handle + owner handle + session id), stamp the owner header + server-side, dedup creates on `client_request_id` (`dedup`, DL-206), and + record the DL-055 ownership row (`record`). Reads are answered from the + projection/store for tracked artifacts with a live-fetch fallback + (`getIssue`/`getPullRequest`/`listIssues`, per the write-path record's OQ-A + ruling). Nothing agent-side re-implements any of this. +- **The relay + gateway legs mirror comms/lifecycle exactly.** + `go/internal/runnerhub/relay_forge.go` `Hub.RelayForgeCall` resolves the + relayed `session_id` to its bound agent account (fail-closed `CodeNotFound`; + no `ForgeCaller` wired → `CodeUnavailable`) and delegates under the RESOLVED + account — never request-asserted, never admin. + `go/internal/runner/gateway/forge.go` `Gateway.Forge` resolves + container→session (fail-closed `CodePermissionDenied` when unbound) and + forwards `RelayForgeCall{session_id, call}`; a nil result is `CodeInternal` + ("Mirrors Lifecycle exactly", forge.go). The agent presents no token and + asserts no account, so every tool below inherits the DL-050 attribution and + the F1 author/reviewer credential roles with zero new authz code. +- **The proto carrier and its generated TS types exist.** + `proto/compass/v1/agent_gateway.proto` `rpc Forge(ForgeCallRequest) returns + (ForgeCallResult)` on `AgentGateway`; `ForgeCallRequest` carries `call_id`, + the ten-arm `call` oneof (arms 2–11), an optional `ForgeRef forge` (unset = + configured default GitHub forge, DL-202), and `client_request_id` (create + arms only, DL-206). `ForgeCallResult` retypes the domain arms to canonical + `compass.v1.Issue`/`PullRequest` (DL-069/DL-092) plus `CommentRef`/`ReviewRef` + write acks (forge.proto) and the in-band `ForgeCallError{code, message, + retry_after_ms}`. The TS gen is already in the internal agent tree + (`packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts` + `ForgeCallRequest`/`ForgeCallResult` + the arm request schemas; + `gen/compass/v1/forge_pb.ts` `CommentRef`/`ReviewRef`; + `gen/compass/v1/compass_pb.ts` `Issue`/`PullRequest`). This record adds NO + proto and regenerates nothing. +- **The transport seam is frozen and the client exists.** `RunnerTransport` + (`packages/compass-agent/src/transport/index.ts`) is the agent's handle on + the `AgentGateway` RPCs over the per-container Unix socket + ([`compass-agent-runner-transport/design.md`](../compass-agent-runner-transport/design.md) + Decisions #3–#4): `comms`/`lifecycle` are one-line unary delegations + (`createUnixSocketTransport`: `comms: (req) => client.comms(req)`). The + generated Connect client already carries the `forge` method (the service + schema includes the `Forge` RPC), so adding `forge()` to the interface and + the impl is one interface member + one delegation line — no new socket, no + new Connect surface, no biome-carve change (the carve already scopes + `packages/compass-agent/src/transport/**`). +- **The tool/broker shape is established twice over.** `comms.ts` and + `lifecycle.ts` each define: a one-method structural transport subset + (`CommsTransport`/`LifecycleTransport`) so tools depend on one method and a + test fakes one method; a thin broker (`CommsBroker`/`LifecycleBroker`) + delegating `call()` to the transport with a broker-scoped + `#idempotencyNonce` and `idempotencyKey(toolCallId)` = + `` `${nonce}:${toolCallId}` `` (the cross-session tool-call-id collision the + comms record's errata documents); exported arktype `parameters` objects + (non-blank `.narrow` bounds repeated in descriptions because `.narrow` has + no JSON Schema form); a `*Failure(result, toolName, expected)` error mapper + (in-band `error` arm → thrown `Error` with `attr(code)` + `flat(detail)`; + wrong arm → protocol violation); and render-guarded output via the shared + `attr`/`flat` (`render-guard.ts`). `forge.ts` mirrors all of it. +- **Registration is `customTools` at the composition root.** `cli.ts main()` + constructs the brokers over the one transport + (`new CommsBroker(transport)`, `new LifecycleBroker(transport)`), merges + `nativeTools = [...createCommsTools(…), ...createLifecycleTools(…)]`, and + passes `customTools: [...mcp.tools, ...nativeTools]` into + `createAgentSession` — the natives flow through the + customTools→state.tools→`#withNatives` path. Forge adds one broker and one + spread to that list. + +### Tool set and shape — ten native tools, one per arm + +All ten tools live in one new `packages/compass-agent/src/forge.ts`, +authored as OMP `AgentTool`s with arktype parameters, closing over a +`ForgeBroker`. Approval levels follow the comms precedent (`comms.ts`: +mutations `approval: "write"`, reads `approval: "read"`; the container runs +headless with write-approval tools auto-executing, per the comms record): + +| Tool | Arm (`call.case`) | Approval | Result arm | +| --- | --- | --- | --- | +| `forge_get_issue` | `getIssue` | read | `issue` | +| `forge_get_pull_request` | `getPullRequest` | read | `pullRequest` | +| `forge_list_issues` | `listIssues` | read | `issues` | +| `forge_comment_on_issue` | `commentOnIssue` | write | `issueComment` (CommentRef) | +| `forge_comment_on_pull_request` | `commentOnPullRequest` | write | `prComment` (CommentRef) | +| `forge_submit_review` | `submitReview` | write | `review` (ReviewRef) | +| `forge_create_issue` | `createIssue` | write | `issue` | +| `forge_create_pull_request` | `createPullRequest` | write | `pullRequest` | +| `forge_subscribe` | `subscribe` | write | `subscribed` (`unimplemented` until the writer lands) | +| `forge_unsubscribe` | `unsubscribe` | write | `unsubscribed` (`unimplemented` until the writer lands) | + +Parameter surfaces map 1:1 onto the proto request messages +(`agent_gateway.proto` `CreateIssueRequest`…`SubmitReviewRequest`), with the +wire's documented bounds carried into descriptions: + +- `repo` is REQUIRED on every tool (`"/"` on GitHub, team key on + Linear) — the wire rejects an empty `repo` as `invalid_argument` and the + tool schema enforces non-blank up front, so the model learns the rule from + the schema description rather than a server round-trip. +- Write bodies (`body`) are sent WITHOUT the owner header — the Server stamps + it (DL-050). The per-tool prompt guidance says so explicitly ("never write + an attribution header yourself; the server adds it"), because a model that + has seen stamped bodies on the forge will otherwise imitate them and the + stamp chokepoint would strip-then-restamp (correct but noisy). +- `forge_create_issue` and `forge_create_pull_request` set + `client_request_id = broker.idempotencyKey(toolCallId)` (DL-206 + whole-chain dedup; broker-scoped nonce per the comms/lifecycle errata). + The non-create arms send none — the field is ignored there by contract. +- `forge_submit_review.verdict` is a closed enum + (`"approve" | "request_changes" | "comment"`) in the tool schema, so an + invalid verdict never leaves the container; `comments[]` items carry + `path` (required), `line`, optional `side` (`"LEFT" | "RIGHT"`, default + RIGHT) per `ReviewCommentInput`. +- `forge_list_issues` bounds `limit` to `1..100` (`0`/omitted = server + default 30, capped at 100 per the wire comment) and `state` to + `"open" | "closed" | "all"` with omitted = open. +- **Create bodies are OPTIONAL; comment/review bodies are required.** + `forge_create_issue` and `forge_create_pull_request` take `body?` — the + Server stamps an owner header into the body (DL-050), so an empty agent + body is well-formed and non-empty on the forge; forcing non-blank would + make the model invent filler for a title-only issue. `comment`/`review` + bodies carry no server-supplied content and stay required-non-blank. +- **`forge_submit_review` needs a body unless it approves.** GitHub rejects a + `request_changes`/`comment` review with an empty body (the forge returns + `invalid_argument`); the schema `.narrow`s `body` required unless + `verdict = "approve"`, closing the rule in-container rather than on a + server round-trip — the same schema-first rationale as the non-blank `repo`. +- **Multi-forge selector on every tool (`forge_provider` + `forge_host`, + both optional).** The wire carries an optional `ForgeCallRequest.forge` + (`ForgeRef`: `provider` + `host`; unset = the configured default GitHub + forge, DL-202), and the substrate resolves it per-provider end to end + (`forgeProviderRegistry.resolve`, `go/server/forge.go`). Wave parity needs + both forges — the wave files Linear issues AND opens GitHub PRs — so both + fields are exposed (Matt's ruling, overruling the earlier single-forge-v1 + deferral). Omitted = GitHub. `forge_provider: "linear"` targets the built, + unit-tested Linear write provider (`go/internal/forge/linear.go`), which is + ISSUES-ONLY (DL-051): `repo` is the Linear TEAM KEY (e.g. `"SEA"`), not + `/`, and the PR/review arms return in-band `unimplemented` + (`ErrUnsupported` → `unimplemented`, DL-203). `forge_host` omitted resolves + the provider's default host (A3); an unknown/unconfigured coordinate is an + in-band `not_found`, never a wrong-repo write. The tool maps the string + enum to the generated `ForgeProvider` and sets `forge` only when populated, + so an omitted selector is a nil `ForgeRef` on the wire. + +### Result rendering — refs verbatim-guarded, bodies fenced + +Two classes of result, two renderers, both under the established +render-guard discipline: + +- **Write acks** (`CommentRef`, `ReviewRef`, and the create arms' returned + `Issue`/`PullRequest`): a single renderer-authored line — + `Created issue # in : ` / `Commented on PR #: ` / + `Submitted review on PR #: `. Numbers and the verdict pass + through `attr` (they are in its `[\w.:-]+` class); `url` and `repo` do NOT — + `attr`'s shape class excludes `/`, so it rejects every well-formed + `https://…` URL and every `/` repo as `(malformed)` + (`render-guard.ts` `attr`, `/^[\w.:-]+$/`). They go through a dedicated + shape guard sized for URLs and `owner/name` slugs — a `render-guard.ts` + sibling of `attr` keeping the constrain-don't-escape doctrine (a value + failing the shape degrades to `(malformed)`, never escapes) — NOT `attr`, + NOT the fence. **Dedup-hit branch (DL-206):** a replayed create returns a + skeletal `Issue`/`PullRequest` carrying only `{forge, repo, number}` with + `url`/`title` empty (`go/server/forge.go` `coordinateResult`), so the create + renderer branches on an empty `url` and renders `Issue # in + already created by an earlier attempt` — the whole-chain dedup working as + designed must not read as a tool malfunction. +- **Reads** (`Issue`, `PullRequest`, `ListIssuesResponse`): forge bodies and + titles are member-authored external text — strictly less trusted than + Compass channel messages — so the comms `comms_list_messages` nonce-fence + discipline applies verbatim (the comms record's shipped-errata renderer: + per-render `crypto.randomUUID().slice(0, 8)` fence, one fenced record per + artifact, `attr`-guarded attributes, a framing line marking bodies as data). + An issue record carries number, title, state, url, labels, author + attribution, and the body inside the fence; a PR record adds head/base refs, + draft, checks roll-up state, and review verdicts. Three read-specific rules + the comms surface never needed: + - **Truncation.** Reads have no server page cap the way the comms list does: + a `PullRequest` carries its full `reviews[]` + `threads[]` with every + comment body (`compass.proto`), and `ListIssuesResponse` up to `limit` + (≤100) full bodies. Each rendered body is truncated to a fixed budget + (a `…(truncated, N chars)` marker inside the fence) and per-artifact + aggregates (comments/threads) are capped with a count of the elided + remainder, so a 300-comment PR cannot flood the transcript. + - **Verdict vocabulary.** The wire echoes the forge's review state + (`changes_requested`, `compass.proto`) while the tool schema's enum is + `request_changes`; the renderer normalizes onto the tool vocabulary so the + model is never shown a verdict string its own schema rejects. + - **Attribution is parsed, not authenticated.** For external artifacts the + `agent.agentHandle` attribution is body-PARSED at translation + (`go/server/forge.go` `translateIssue`), so a non-Compass human who writes + the owner-header grammar into a GitHub body would render as + Compass-attributed; the framing line marks attribution on external + artifacts as a parsed claim, not an authenticated identity. + `forge.ts` reuses the shared `render-guard.ts` `attr`/`flat`, adds the one + URL/repo shape guard above (its only new guard), and mints its fence with + the established idiom. + +### Errors — in-band `ForgeCallError` → thrown tool failure, with retry hint + +`forgeFailure(result, toolName, expected)` mirrors +`lifecycleFailure`/`commsFailure`: the `error` arm throws +`` `${toolName} failed: ${attr(code)}: ${flat(message).slice(0, 500)}` ``, +any other unexpected arm throws a protocol violation. One forge-specific +addition: `ForgeCallError.retry_after_ms` (the field comms lacks — "a forge, +unlike the in-process comms handler, rate-limits", agent_gateway.proto) is +appended as `` `; retry after ${n}ms` `` when non-zero, so the model can back +off deliberately instead of hammering a rate-limited forge. **This suffix is +future-proofing, not a live signal today:** `mapForgeError` +(`go/server/forge.go`) currently always sets `retry_after_ms` to 0 — neither +`forge.ErrBudgetExhausted` nor `forge.StatusError` surfaces a forge +`Retry-After` yet — so the branch is present and tested but dormant until the +substrate populates it. Unimplemented arms the wire can still express +(subscribe/unsubscribe, or an op a provider cannot serve — DL-202's +`ErrUnsupported` mapping) arrive as ordinary in-band `unimplemented` errors; +no tool exists to send the former, and the latter renders like any tool +failure. Distinct from all of these: a deployment with **no `ForgeCaller` +wired** fails at the relay as a Connect `CodeUnavailable` +(`go/internal/runnerhub/relay_forge.go`), which the gateway forwards and the +transport surfaces as a thrown `ConnectError` — it bypasses `forgeFailure` +(there is no in-band `error` arm to map) but still lands as an ordinary thrown +tool failure, no transport teardown. + +### Identity / authz — no new authz CODE, but a new reachable surface + +Forge calls attribute to the agent's account via the same session→account +Server-side binding as comms/lifecycle: the Runner structurally owns which +container (hence which 1:1 session) a call arrived on, forwards +`session_id` on `RelayForgeCall`, and the Server resolves the account and +stamps the DL-050 owner header itself under the F1 author/reviewer credential +roles (`buildForgeWriteService` validates both `server_only` secrets at +startup; DL-052 — the write credential never enters the container). The agent +presents no token; a no-`ForgeCaller` deployment fails closed at the relay +(`CodeUnavailable`, surfaced as a thrown tool error per the Errors section), +never a transport teardown. This record writes no authz code and introduces +no credential. + +**What IS new is the reachable surface, and the record states it plainly.** +"Nothing new" is true of code, not of exposure. The substrate ships NO scope +rejection (`go/server/forge.go` trust-model: per Resolved decision 2, MVP +single-trust-domain, the caller is recorded for attribution but no scope +rejection ships — A8), and `repo` does not enter the credential key ("one +credential pair serves every repo on a coordinate", `forgeCoordinate`). This +record is the first to hand a MODEL a free-text `repo` over that org-wide +credential: a hallucinated or prompt-injected `repo` writes a real issue/PR +into any repository the shared credential can reach, with DL-050 attribution +as the only audit trail. Unlike comms (which enforces channel membership), +forge enforces nothing — the containment here is prompt-level, not +authz-level. The mitigation is one per-write prompt-guidance line ("operate +only on the repositories your task names") plus the DL-050 attribution trail; +hard scope enforcement is A8's frozen deferral, not reopened here. + +## Alternatives considered + +- **Fewer, multiplexed tools (one `forge_call` with an `op` parameter, or + read/write pairs).** Rejected by Matt's ruling: single-purpose tools with + their own schemas and descriptions are what agents drive reliably; a + multiplexed tool trades ten precise JSON Schemas for one union schema the + model must navigate free-form. Also breaks the comms/lifecycle + one-tool-per-verb house precedent for no token savings that matters (tool + schemas are sent once per session). +- **Deferring `forge_subscribe`/`forge_unsubscribe` until the writer lands.** + Considered and OVERRULED by Matt: build the complete tool surface now even + though the `subscribe`/`unsubscribe` arms return `CodeUnimplemented` until + the poll-driver lane lands the `agent_forge_subscriptions` writer + (`go/server/forge.go` — the two arms are `CodeUnimplemented` stubs today). + A stable, complete surface is worth more than hiding two arms: the tool set + never changes shape when the writer lands (no re-registration, no prompt + churn), and the runtime error is honest — the per-tool guidance states the + arm is not yet wired, so the model learns "not available yet", not "broken". + This composes with the poll-driver lane + ([`compass-forge-poll-driver/design.md`](../compass-forge-poll-driver/design.md), + DL-163: the tables land writer-less; that lane brings the writer). +- **Deferring the `ForgeRef` (provider/host) selector.** Considered and + OVERRULED by Matt: expose it now. The wave needs BOTH forges — it files + Linear issues AND opens GitHub PRs — and the substrate already resolves a + per-provider coordinate end to end (`forgeProviderRegistry.resolve`, + `go/server/forge.go`; the co-equal Linear write provider + `go/internal/forge/linear.go` is built and unit-tested). An unexposed + selector would strand a shipped backend. The hallucination cost the earlier + deferral cited is bounded by shape: an unknown/unconfigured coordinate is an + in-band `not_found`, never a wrong-repo write, and `host` omitted resolves + the provider's default (A3). +- **A single shared broker for comms+lifecycle+forge.** Rejected: the + one-method structural-subset pattern (`CommsTransport` etc.) exists + precisely so each tool family depends on one transport method and a unit + test fakes one method; a shared broker re-couples them. +- **Rendering read results as raw JSON.** Rejected: forge bodies are + untrusted external markdown; the comms record's transcript-forgery analysis + applies with more force (anyone on the internet can author an issue body). + The nonce-fence renderer is the established answer; JSON would leave the + boundary to whatever the model infers about quoting. + +## Global Constraints + +Every task below inherits these; they are not repeated per task. + +- **No new wire surface.** This record adds no proto, no gen, no RPC. The + `Forge` RPC, its envelopes, and the TS gen exist; `buf` tasks and the + SEA-1267 gen-fence are untouched (the fence already covers the `ForgeCall*` + family and `AgentGateway` — `proto/moon.yml` `gen-fence` script — and no + public tree is regenerated here). +- **Egress seal preserved.** The forge call rides the existing per-container + Unix socket — a local hop, not a network address. No forge credential, + token, Server address, or account identity enters the agent container + (DL-052: the Server holds the sole write credentials as `server_only` + declared secrets). +- **No new `@connectrpc` imports outside the carve.** `forge()` lands inside + `packages/compass-agent/src/transport/**`, the one biome + `noRestrictedImports` override the transport record established; `forge.ts` + imports only generated types via the `compassv1.ts` barrel, like + `comms.ts`/`lifecycle.ts`. +- **Owner header is Server-stamped, never agent-written (DL-050).** Tool + bodies go up bare; per-tool prompt guidance states it; no TS code + constructs or strips a header. +- **Red→green testing** (`rule://red-green-testing`): each task lands its + failing tests first (`forge.test.ts` against a fake `ForgeTransport`; + transport test extension), then the smallest implementation to green. +- **Formatting/lint gates:** biome for TS (`direnv exec . moon run + compass-agent:lint`), tests via `compass-agent:test`, markdownlint for this + record (MD013 disabled repo-wide). +- **Frozen-record convention:** this record freezes on merge; later changes + supersede by citation. It consumes — never re-decides — the frozen + transport record (DL-015/016/017), the forge write path (DL-200…DL-206), + DL-049 (sibling `ForgeCall*` family), DL-050/DL-052 (stamping/credentials), + DL-069/DL-092 (canonical types on the wire), and DL-202 (ForgeRef + addressing). +- **arktype pin discipline:** `arktype` stays pinned exact to what the SDK + resolves (the `comms.ts` note); this record adds no new dependency. + +## Plan + +Two tasks. T1 is the transport member + broker (the seam extension); T2 is +the tool set + wiring and depends on T1's `ForgeBroker`. Both are TS-only, +test-covered against fakes, and land together or stacked (the driver's call — +one PR carrying both, or T1 then T2 via jj-vine, since T2 depends on T1's +broker). + +### T1 — Transport: `forge()` on `RunnerTransport` + socket impl + `ForgeBroker` + +In `packages/compass-agent/src/transport/index.ts`: + +- Add the interface member, mirroring `comms`/`lifecycle` exactly: + + ```ts + export interface RunnerTransport { + comms(req: CommsCallRequest): Promise; + lifecycle(req: LifecycleCallRequest): Promise; + forge(req: ForgeCallRequest): Promise; + // …publishSpine / postConversationFrame / control / close unchanged + } + ``` + + with `ForgeCallRequest`/`ForgeCallResult` type-imported from + `../gen/compass/v1/agent_gateway_pb` alongside the existing envelope + imports. +- Add the one-line delegation in `createUnixSocketTransport`: + + ```ts + forge: (req) => client.forge(req), + ``` + + The generated `AgentGateway` client already carries `forge` (unary); no + session-manager, runtime, or close-path change. + +In a new `packages/compass-agent/src/forge.ts` (broker half; tools are T2): + +```ts +/** + * The one transport method the forge tools consume — a structural subset of + * `RunnerTransport` (transport/index.ts), so `createUnixSocketTransport()`'s + * result satisfies it directly while a unit test fakes a single method. + */ +export interface ForgeTransport { + forge(req: ForgeCallRequest): Promise; +} + +/** + * A thin adapter over the forge leg of the Runner transport. `call` delegates + * straight to `transport.forge(req)`; the Connect unary owns correlation and + * deadlines. Cancellation is not plumbed (the comms.ts precedent): an aborted + * turn does not cancel an in-flight create — it lands, and the DL-206 + * idempotency key means a re-issue dedupes rather than double-creating. + */ +export class ForgeBroker { + constructor(transport: ForgeTransport); + /** The account-safe idempotency key for a create made under `toolCallId`. */ + idempotencyKey(toolCallId: string): string; // `${#nonce}:${toolCallId}` + call(req: ForgeCallRequest): Promise; +} +``` + +The broker carries the same private `#idempotencyNonce = crypto.randomUUID()` +as `CommsBroker`/`LifecycleBroker`, for the same reason (the errata-documented +cross-session tool-call-id collision under the Server's +`(agent_account_id, client_request_id)` unique memo, DL-206). + +Barrel exports: `compassv1.ts` re-exports the forge envelope types + arm +request schemas + result value types it does not yet carry +(`ForgeCallRequest`, `ForgeCallRequestSchema`, `ForgeCallResult`, +`CreateIssueRequestSchema` … `SubmitReviewRequestSchema`, +`ReviewCommentInputSchema`, and the canonical `Issue`/`PullRequest` + +`CommentRef`/`ReviewRef` types from their gen leaves), following the existing +grouped-export style. `index.ts` exports `ForgeBroker` and (T2) +`createForgeTools`, mirroring `CommsBroker`/`createCommsTools`. + +**Interfaces:** exactly as quoted — `RunnerTransport.forge(req: +ForgeCallRequest): Promise`; `ForgeTransport`; +`ForgeBroker.call(req: ForgeCallRequest): Promise`; +`ForgeBroker.idempotencyKey(toolCallId: string): string`. + +**Test cycle (red→green):** extend the transport tests with the `forge` +delegation (the fake-client pattern the comms/lifecycle members use); new +`forge.test.ts` broker cases — `call` forwards to a fake `ForgeTransport` and +returns its result; `idempotencyKey` is nonce-prefixed, stable per broker, +distinct across brokers. `direnv exec . moon run compass-agent:test` red +first, then green; biome clean. + +### T2 — Agent: `createForgeTools` (ten tools) + `cli.ts` wiring + prompt guidance + +In `packages/compass-agent/src/forge.ts` (same file, tool half): + +```ts +/** + * The native forge tool set. Ten tools, one per `ForgeCallRequest` arm. Two + * of them — `forge_subscribe`/`forge_unsubscribe` — return the server's + * in-band `unimplemented` until the poll-driver lane lands the + * `agent_forge_subscriptions` writer; they ship now so the surface is stable + * (Matt's build-all ruling). Every tool takes an optional forge selector + * (`provider` + `host`) so a call targets Linear as well as the default + * GitHub forge. Wired into the container entrypoint by `cli.ts main()`: + * merged into the session's `customTools` and registered as `#withNatives` + * natives. + */ +export function createForgeTools(broker: ForgeBroker): AgentTool[]; +``` + +Exported parameter schemas (arktype, one per tool, exported for wire-contract +tests like `postParameters`/`spawnParameters`); the non-blank `.narrow` bound +on every required string, repeated in the description because `.narrow` has no +JSON Schema form: + +```ts +// The optional multi-forge selector, spread into EVERY tool below. Unset = +// the configured default GitHub forge (DL-202); `provider` names Linear / +// GitLab / Forgejo; `host` disambiguates two instances of one provider and +// omitted resolves the provider's default (A3). `create(ForgeRefSchema, …)` +// only when the agent sets it, so an unset selector is a nil ForgeRef on the +// wire (existing default-forge callers unchanged). +const forgeSelector = type({ + "forge_provider?": type("'github' | 'linear' | 'gitlab' | 'forgejo'"), + "forge_host?": type("string"), // omitted = the provider's default host +}); +// EVERY tool schema below also spreads `...forgeSelector` (elided in each body +// for brevity — the eight non-subscription sketches show only their arm-specific +// fields). An implementer adds `...forgeSelector,` to all ten object literals. +export const getIssueParameters = type({ + repo: /* non-blank */ "Repository as / (GitHub) or team key (Linear)", + issue_number: type("1 <= number.integer"), +}); +export const getPullRequestParameters = type({ + repo: /* non-blank */, + pull_number: type("1 <= number.integer"), +}); +export const listIssuesParameters = type({ + repo: /* non-blank */, + "state?": type("'open' | 'closed' | 'all'"), // omitted = open + "labels?": type("string[]"), + "limit?": type("1 <= number.integer <= 100"), // omitted = server default 30 +}); +export const commentOnIssueParameters = type({ + repo: /* non-blank */, + issue_number: type("1 <= number.integer"), + body: /* non-blank */ "Markdown comment body; do NOT include any attribution header — the server stamps it", +}); +export const commentOnPullRequestParameters = type({ + repo: /* non-blank */, + pull_number: type("1 <= number.integer"), + body: /* non-blank, same stamping note */, +}); +export const submitReviewParameters = type({ + repo: /* non-blank */, + pull_number: type("1 <= number.integer"), + verdict: type("'approve' | 'request_changes' | 'comment'"), + "body?": type("string"), // required unless verdict='approve' (see .narrow) + "comments?": type({ + path: /* non-blank */, + line: type("1 <= number.integer"), + "side?": type("'LEFT' | 'RIGHT'"), // omitted = RIGHT + body: /* non-blank */, + }).array(), +}).narrow((v, ctx) => + v.verdict === "approve" || (v.body?.trim().length ?? 0) > 0 + ? true + : ctx.reject("body is required unless verdict is 'approve'")); +export const createIssueParameters = type({ + repo: /* non-blank */, + title: /* non-blank */, + "body?": /* stamping note; optional — the server stamps a header into an empty body (DL-050) */, + "labels?": type("string[]"), +}); +export const createPullRequestParameters = type({ + repo: /* non-blank */, + title: /* non-blank */, + "body?": /* stamping note; optional (DL-050) */, + head_ref: /* non-blank */ "The branch you already pushed with your git credential", + "base_ref?": type("string"), // omitted = repo default branch + "draft?": type("boolean"), +}); +// Built now (Matt's build-all ruling) though the arms are CodeUnimplemented +// stubs until the poll-driver lane lands the subscription writer; the tool +// renders the server's in-band `unimplemented` and the description says so. +export const subscribeParameters = type({ + ...forgeSelector, + repo: /* non-blank */, + kind: type("'issue' | 'pull_request'"), + number: type("1 <= number.integer"), +}); +export const unsubscribeParameters = type({ + ...forgeSelector, + subscription_id: /* non-blank */ "The id returned by forge_subscribe", +}); +``` + +Every `/* non-blank */` above is shorthand for the same required-non-blank +idiom spelled out concretely on `submit_review`'s body — +`type("string").narrow((v, ctx) => v.trim().length > 0 ? true : ctx.reject("must not be blank"))` — +with the trailing string literal (where shown) as the arktype description; an +implementer must copy that bound, not the literal `/* non-blank */` comment. + +Every schema also spreads `...forgeSelector` (shown once above, elided in each +body for brevity): the optional `forge_provider`/`forge_host` pair is on all +ten tools. When either is set, `execute` builds +`create(ForgeRefSchema, { provider: , host: forge_host ?? "" })` +and sets `ForgeCallRequest.forge`; when both are unset it leaves `forge` nil +(the default-GitHub path). The string enum maps to the generated +`ForgeProvider` (`'linear'` → `FORGE_PROVIDER_LINEAR`, etc.); an unknown +coordinate is the server's in-band `not_found`, never a wrong-repo write. + +Each `execute` builds `create(ForgeCallRequestSchema, { callId: toolCallId, +call: { case: , value: create(, …) }, forge, clientRequestId })` +(`forge` set only when the selector is populated; `clientRequestId` only on the +two create arms, = `broker.idempotencyKey(toolCallId)`), awaits `broker.call`, +checks the expected result arm (else `forgeFailure`), and renders per the +Approach (write acks as one shape-guarded line — `url`/`repo` through the +URL/repo shape guard, numbers/verdict through `attr`; reads through the +nonce-fence renderer; `forge_subscribe` renders its `subscription_id` from the +`subscribed` arm, `forge_unsubscribe` an ack from `unsubscribed` — but until +the writer lands both arms only ever return the `error` arm as `unimplemented`, +so their happy-path render is dormant like the retry hint). The +`issue_number`/`pull_number`/`number` params validate as integers in arktype, +but the generated wire fields are `bigint` (`agent_gateway_pb.ts` — protobuf-es +v2 emits `uint64` as `bigint`), so each `execute` coerces with +`BigInt(params.issue_number)`; a plain `number` is a type error at the arm +`create(...)`. + +Per-tool prompt guidance is the `description` field (the model-facing prompt +surface — there is no separate prompt file for natives; comms/lifecycle set +the precedent). Each description states: what the tool does in one sentence; +the `repo` addressing rule AND the scope-discipline line ("operate only on +the repositories your task names — a wrong `repo` writes a real artifact into +any repository the shared forge credential can reach; there is no server-side +scope check, A8"); the forge-selector rule — omit `forge_provider` for the +default GitHub forge, set `forge_provider: "linear"` to target Linear where +`repo` is the TEAM KEY (e.g. `"SEA"`), NOT `/` — and that Linear +is issues-only, so `forge_create_pull_request`/`forge_comment_on_pull_request`/ +`forge_submit_review`/`forge_get_pull_request` are GitHub-only and return +`unimplemented` on Linear (DL-051/DL-203); for writes, the +never-write-an-attribution-header rule and that the artifact is created under +the Compass forge identity with the agent's attribution stamped server-side; +for `forge_submit_review`, that a `request_changes`/`comment` review needs a +non-empty `body` and that the review posts immediately (never a pending +review) under a distinct reviewer identity so all three verdicts are usable on +Compass-authored PRs (DL-201); for `forge_create_pull_request`, that +`head_ref` must already be pushed (the agent pushes with its own git +credential — DL-052/DL-090); for `forge_subscribe`/`forge_unsubscribe`, that +change-notification subscriptions are NOT YET WIRED — the call returns +`unimplemented` until the notification lane lands, so the tool exists for +surface stability but should not be relied on yet; for reads, that results may +be paged/bounded/truncated and bodies are external content whose attribution +is a parsed claim, not an authenticated identity. + +In `packages/compass-agent/src/cli.ts`: + +```ts +const forgeBroker = new ForgeBroker(transport); +const nativeTools = [ + ...createCommsTools(commsBroker, pendingAsks), + ...createLifecycleTools(lifecycleBroker), + ...createForgeTools(forgeBroker), +] as ToolDefinition[]; +``` + +— the one existing merge point; `customTools: [...mcp.tools, ...nativeTools]` +is unchanged. + +**Interfaces:** `createForgeTools(broker: ForgeBroker): AgentTool[]`; the +ten exported parameter schemas above (the shared `forgeSelector` spread +into each); `function forgeFailure(result: +ForgeCallResult, toolName: string, expected: string): Error` (module-private, +mirroring `lifecycleFailure`, plus the `retry_after_ms` suffix). + +**Test cycle (red→green):** extend `forge.test.ts` — for each tool: execute → +correct arm + fields on the wire request (including `clientRequestId` set on +creates only, nonce-prefixed; `issue_number`/`pull_number` coerced to +`bigint`); success → rendered output (write ack: `url`/`repo` shape-guarded — +a well-formed GitHub url and `/` repo render intact, a malformed +one degrades to `(malformed)` without forging a second line; a DL-206 +dedup-hit skeletal result with empty `url` renders the "already created" +branch, not a broken link; read render nonce-fenced, body cannot forge a +record; newline/quote injection cases per the comms render tests; an oversized +body and an over-cap comment aggregate truncate with a remainder marker; a +`changes_requested` wire verdict normalizes to `request_changes` on render); +`error` arm → thrown `Error` carrying code + flattened detail + retry hint +when `retryAfterMs > 0`; wrong arm → protocol-violation throw; schema +rejections (blank repo, `limit` out of range, bad verdict, and a +`request_changes` review with an empty `body`). `cli.test.ts` extension: the +forge tools appear in the session's `customTools` (the existing +natives-wiring assertion pattern). `direnv exec . moon run +compass-agent:test` + `compass-agent:lint` green. + +## Tasks + +- [ ] T1 — Transport + broker: `forge()` member on `RunnerTransport` + + one-line `createUnixSocketTransport` delegation; `ForgeTransport` + + `ForgeBroker` (nonce-scoped `idempotencyKey`) in new `forge.ts`; + `compassv1.ts` barrel exports for the forge envelopes/arm schemas/canonical + result types; transport + broker tests red→green; biome clean. +- [ ] T2 — Tools + wiring: `createForgeTools` with the ten tools (exact + schemas above; creates carry the DL-206 key and coerce numbers to `bigint`; + the optional `forge` selector — `provider` enum + optional `host` — built on + every tool, unset = default GitHub, `create(ForgeRefSchema,…)` only when set; + the `url`/`repo` shape guard added to `render-guard.ts`; write acks through + that guard with the dedup-hit branch; reads nonce-fenced + truncated + + verdict-normalized; `forge_subscribe`/`forge_unsubscribe` built over the + `subscribe`/`unsubscribe` arms, rendering the server's in-band + `unimplemented` cleanly; `forgeFailure` with the dormant retry hint), per-tool + prompt guidance in descriptions (incl. the repo-scope discipline line, the + Linear-vs-GitHub `repo`-shape rule, and the PR/review-are-GitHub-only note); + `cli.ts` `ForgeBroker` + spread into `nativeTools`; `index.ts` exports; the + forge tool family added to `packages/compass-agent/AGENTS.md` (the package + contract `comms.ts` defers to); `forge.test.ts` + `cli.test.ts` extensions + red→green; biome clean. + +## Ledger-impact + +One proposed DECISIONS.md row (appended by the driver at PR time, wherever +the ledger then lives — compass-repo RIG-2577 T2 may relocate it to +`docs/designs/DECISIONS.md`): + +> The agent forge native toolset is ten single-purpose tools, one per +> `ForgeCallRequest` arm (`forge_get_issue`, `forge_get_pull_request`, +> `forge_list_issues`, `forge_comment_on_issue`, +> `forge_comment_on_pull_request`, `forge_submit_review`, +> `forge_create_issue`, `forge_create_pull_request`, `forge_subscribe`, +> `forge_unsubscribe`), each a native `AgentTool` over a thin `ForgeBroker` +> on the `RunnerTransport.forge()` seam; `forge_subscribe`/`forge_unsubscribe` +> ship the complete surface but return the server's in-band `unimplemented` +> until the poll-driver lane lands the `agent_forge_subscriptions` writer +> (DL-163). Multi-forge is exposed: every tool takes an optional forge +> selector (`provider` + optional `host`, unset = the configured default +> GitHub forge, DL-202) so an agent targets Linear (issues-only, `repo` = team +> key, DL-051) as well as GitHub; PR/review arms on a non-GitHub provider +> return in-band `unimplemented`. + +This mirrors how DL-212 records the comms toolset; the tool-count claim is +load-bearing for future toolset-refresh rows.