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,
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);
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
Section titled “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
Section titled “Approach”Decisions (pre-resolved by Matt — not reopened below)
Section titled “Decisions (pre-resolved by Matt — not reopened below)”- Full surface: all ten
ForgeCallRequestarms become tools. Every arm the wire carries ships as an agent tool in this record — the eight armsExecuteForgeCallAsAccountdispatches for real PLUSsubscribe/unsubscribe. No partial MVP subset, no hidden arms. - 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. forge_subscribe/forge_unsubscribeship NOW, returning the server’s in-bandunimplementeduntil the writer lands. The backend arms areCodeUnimplementedstubs (go/server/forge.go) pending theagent_forge_subscriptionsstore writer, which the poll-driver lane owns (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-bandunimplemented, 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.- V1 is multi-forge: the
ForgeRefselector 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 providergo/internal/forge/linear.go, built + unit-tested). Every tool takes an optionalforge_provider(+ optionalforge_host); unset = the configured default GitHub forge (DL-202). Linear is issues-only (DL-051):repo= team key, PR/review arms returnunimplemented. Matt ruled this in, overruling the earlier GitHub-only-v1 / ForgeRef-deferred scoping.
The built substrate this composes with
Section titled “The built substrate this composes with”- The Server is the sole write chokepoint, and it is live.
go/server/forge.goExecuteForgeCallAsAccountis theForgeCallerentry point and per-arm dispatch: it fails a missing caller closed, dispatches thecalloneof to the eight arm methods, and returns every domain outcome in-band on theForgeCallResult(an unset arm isCodeInvalidArgument). 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 onclient_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.goHub.RelayForgeCallresolves the relayedsession_idto its bound agent account (fail-closedCodeNotFound; noForgeCallerwired →CodeUnavailable) and delegates under the RESOLVED account — never request-asserted, never admin.go/internal/runner/gateway/forge.goGateway.Forgeresolves container→session (fail-closedCodePermissionDeniedwhen unbound) and forwardsRelayForgeCall{session_id, call}; a nil result isCodeInternal(“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.protorpc Forge(ForgeCallRequest) returns (ForgeCallResult)onAgentGateway;ForgeCallRequestcarriescall_id, the ten-armcalloneof (arms 2–11), an optionalForgeRef forge(unset = configured default GitHub forge, DL-202), andclient_request_id(create arms only, DL-206).ForgeCallResultretypes the domain arms to canonicalcompass.v1.Issue/PullRequest(DL-069/DL-092) plusCommentRef/ReviewRefwrite acks (forge.proto) and the in-bandForgeCallError{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.tsForgeCallRequest/ForgeCallResult+ the arm request schemas;gen/compass/v1/forge_pb.tsCommentRef/ReviewRef;gen/compass/v1/compass_pb.tsIssue/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 theAgentGatewayRPCs over the per-container Unix socket (compass-agent-runner-transport/design.mdDecisions #3–#4):comms/lifecycleare one-line unary delegations (createUnixSocketTransport:comms: (req) => client.comms(req)). The generated Connect client already carries theforgemethod (the service schema includes theForgeRPC), so addingforge()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 scopespackages/compass-agent/src/transport/**). - The tool/broker shape is established twice over.
comms.tsandlifecycle.tseach 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) delegatingcall()to the transport with a broker-scoped#idempotencyNonceandidempotencyKey(toolCallId)=`${nonce}:${toolCallId}`(the cross-session tool-call-id collision the comms record’s errata documents); exported arktypeparametersobjects (non-blank.narrowbounds repeated in descriptions because.narrowhas no JSON Schema form); a*Failure(result, toolName, expected)error mapper (in-banderrorarm → thrownErrorwithattr(code)+flat(detail); wrong arm → protocol violation); and render-guarded output via the sharedattr/flat(render-guard.ts).forge.tsmirrors all of it. - Registration is
customToolsat the composition root.cli.ts main()constructs the brokers over the one transport (new CommsBroker(transport),new LifecycleBroker(transport)), mergesnativeTools = [...createCommsTools(…), ...createLifecycleTools(…)], and passescustomTools: [...mcp.tools, ...nativeTools]intocreateAgentSession— the natives flow through the customTools→state.tools→#withNativespath. Forge adds one broker and one spread to that list.
Tool set and shape — ten native tools, one per arm
Section titled “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 AgentTools 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:
repois REQUIRED on every tool ("<owner>/<name>"on GitHub, team key on Linear) — the wire rejects an emptyrepoasinvalid_argumentand 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_issueandforge_create_pull_requestsetclient_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.verdictis a closed enum ("approve" | "request_changes" | "comment") in the tool schema, so an invalid verdict never leaves the container;comments[]items carrypath(required),line, optionalside("LEFT" | "RIGHT", default RIGHT) perReviewCommentInput.forge_list_issuesboundslimitto1..100(0/omitted = server default 30, capped at 100 per the wire comment) andstateto"open" | "closed" | "all"with omitted = open.- Create bodies are OPTIONAL; comment/review bodies are required.
forge_create_issueandforge_create_pull_requesttakebody?— 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/reviewbodies carry no server-supplied content and stay required-non-blank. forge_submit_reviewneeds a body unless it approves. GitHub rejects arequest_changes/commentreview with an empty body (the forge returnsinvalid_argument); the schema.narrowsbodyrequired unlessverdict = "approve", closing the rule in-container rather than on a server round-trip — the same schema-first rationale as the non-blankrepo.- Multi-forge selector on every tool (
forge_provider+forge_host, both optional). The wire carries an optionalForgeCallRequest.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):repois the Linear TEAM KEY (e.g."SEA"), not<owner>/<name>, and the PR/review arms return in-bandunimplemented(ErrUnsupported→unimplemented, DL-203).forge_hostomitted resolves the provider’s default host (A3); an unknown/unconfigured coordinate is an in-bandnot_found, never a wrong-repo write. The tool maps the string enum to the generatedForgeProviderand setsforgeonly when populated, so an omitted selector is a nilForgeRefon the wire.
Result rendering — refs verbatim-guarded, bodies fenced
Section titled “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’ returnedIssue/PullRequest): a single renderer-authored line —Created issue #<number> in <repo>: <url>/Commented on PR #<n>: <url>/Submitted <verdict> review on PR #<n>: <url>. Numbers and the verdict pass throughattr(they are in its[\w.:-]+class);urlandrepodo NOT —attr’s shape class excludes/, so it rejects every well-formedhttps://…URL and every<owner>/<name>repo as(malformed)(render-guard.tsattr,/^[\w.:-]+$/). They go through a dedicated shape guard sized for URLs andowner/nameslugs — arender-guard.tssibling ofattrkeeping the constrain-don’t-escape doctrine (a value failing the shape degrades to(malformed), never escapes) — NOTattr, NOT the fence. Dedup-hit branch (DL-206): a replayed create returns a skeletalIssue/PullRequestcarrying only{forge, repo, number}withurl/titleempty (go/server/forge.gocoordinateResult), so the create renderer branches on an emptyurland rendersIssue #<n> in <repo> 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 commscomms_list_messagesnonce-fence discipline applies verbatim (the comms record’s shipped-errata renderer: per-rendercrypto.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
PullRequestcarries its fullreviews[]+threads[]with every comment body (compass.proto), andListIssuesResponseup tolimit(≤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 isrequest_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.agentHandleattribution is body-PARSED at translation (go/server/forge.gotranslateIssue), 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.tsreuses the sharedrender-guard.tsattr/flat, adds the one URL/repo shape guard above (its only new guard), and mints its fence with the established idiom.
- Truncation. Reads have no server page cap the way the comms list does:
a
Errors — in-band ForgeCallError → thrown tool failure, with retry hint
Section titled “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
Section titled “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
Section titled “Alternatives considered”- Fewer, multiplexed tools (one
forge_callwith anopparameter, 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_unsubscribeuntil the writer lands. Considered and OVERRULED by Matt: build the complete tool surface now even though thesubscribe/unsubscribearms returnCodeUnimplementeduntil the poll-driver lane lands theagent_forge_subscriptionswriter (go/server/forge.go— the two arms areCodeUnimplementedstubs 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, 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 providergo/internal/forge/linear.gois 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-bandnot_found, never a wrong-repo write, andhostomitted resolves the provider’s default (A3). - A single shared broker for comms+lifecycle+forge. Rejected: the
one-method structural-subset pattern (
CommsTransportetc.) 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
Section titled “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
ForgeRPC, its envelopes, and the TS gen exist;buftasks and the SEA-1267 gen-fence are untouched (the fence already covers theForgeCall*family andAgentGateway—proto/moon.ymlgen-fencescript — 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_onlydeclared secrets). - No new
@connectrpcimports outside the carve.forge()lands insidepackages/compass-agent/src/transport/**, the one biomenoRestrictedImportsoverride the transport record established;forge.tsimports only generated types via thecompassv1.tsbarrel, likecomms.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.tsagainst a fakeForgeTransport; transport test extension), then the smallest implementation to green. - Formatting/lint gates: biome for TS (
direnv exec . moon run compass-agent:lint), tests viacompass-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:
arktypestays pinned exact to what the SDK resolves (thecomms.tsnote); this record adds no new dependency.
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
Section titled “T1 — Transport: forge() on RunnerTransport + socket impl + ForgeBroker”In packages/compass-agent/src/transport/index.ts:
-
Add the interface member, mirroring
comms/lifecycleexactly:export interface RunnerTransport {comms(req: CommsCallRequest): Promise<CommsCallResult>;lifecycle(req: LifecycleCallRequest): Promise<LifecycleCallResult>;forge(req: ForgeCallRequest): Promise<ForgeCallResult>;// …publishSpine / postConversationFrame / control / close unchanged}with
ForgeCallRequest/ForgeCallResulttype-imported from../gen/compass/v1/agent_gateway_pbalongside the existing envelope imports. -
Add the one-line delegation in
createUnixSocketTransport:forge: (req) => client.forge(req),The generated
AgentGatewayclient already carriesforge(unary); no session-manager, runtime, or close-path change.
In a new packages/compass-agent/src/forge.ts (broker half; tools are T2):
/** * 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<ForgeCallResult>;}
/** * 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<ForgeCallResult>;}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<ForgeCallResult>; ForgeTransport;
ForgeBroker.call(req: ForgeCallRequest): Promise<ForgeCallResult>;
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
Section titled “T2 — Agent: createForgeTools (ten tools) + cli.ts wiring + prompt guidance”In packages/compass-agent/src/forge.ts (same file, tool half):
/** * 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:
// 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 <owner>/<name> (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: <mapped enum>, 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: <arm>, value: create(<ArmSchema>, …) }, 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 <owner>/<name> — 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:
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 <owner>/<name> 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.
- T1 — Transport + broker:
forge()member onRunnerTransport+ one-linecreateUnixSocketTransportdelegation;ForgeTransport+ForgeBroker(nonce-scopedidempotencyKey) in newforge.ts;compassv1.tsbarrel exports for the forge envelopes/arm schemas/canonical result types; transport + broker tests red→green; biome clean. - T2 — Tools + wiring:
createForgeToolswith the ten tools (exact schemas above; creates carry the DL-206 key and coerce numbers tobigint; the optionalforgeselector —providerenum + optionalhost— built on every tool, unset = default GitHub,create(ForgeRefSchema,…)only when set; theurl/reposhape guard added torender-guard.ts; write acks through that guard with the dedup-hit branch; reads nonce-fenced + truncated + verdict-normalized;forge_subscribe/forge_unsubscribebuilt over thesubscribe/unsubscribearms, rendering the server’s in-bandunimplementedcleanly;forgeFailurewith the dormant retry hint), per-tool prompt guidance in descriptions (incl. the repo-scope discipline line, the Linear-vs-GitHubrepo-shape rule, and the PR/review-are-GitHub-only note);cli.tsForgeBroker+ spread intonativeTools;index.tsexports; the forge tool family added topackages/compass-agent/AGENTS.md(the package contractcomms.tsdefers to);forge.test.ts+cli.test.tsextensions red→green; biome clean.
Ledger-impact
Section titled “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
ForgeCallRequestarm (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 nativeAgentToolover a thinForgeBrokeron theRunnerTransport.forge()seam;forge_subscribe/forge_unsubscribeship the complete surface but return the server’s in-bandunimplementeduntil the poll-driver lane lands theagent_forge_subscriptionswriter (DL-163). Multi-forge is exposed: every tool takes an optional forge selector (provider+ optionalhost, 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-bandunimplemented.
This mirrors how DL-212 records the comms toolset; the tool-count claim is load-bearing for future toolset-refresh rows.