Read this first. This is the coordination contract for the parallel build. The foundation (this doc + @forq/shared + @forq/api-client + the app bootstraps) is done and frozen-by-default. Build agents implement inside their owned directory only and depend on these contracts verbatim. If you think a contract needs to change, change @forq/shared + this doc in one PR and announce it — every other agent depends on it.
Ground rules:
@forq/sharedis the single source of truth. All domain types, zod schemas, API request/response shapes,RunStatus, andForqConfiglive there. Never redefine a shape locally — import it.- Do not edit
package.jsonfiles. Every dependency each workspace needs is already declared. If something is genuinely missing, flag it. - Stay in your directory (ownership map below). The seams between slices are the interfaces in
apps/server/src/contracts.ts(backend) and the component inventory + route map (frontend). - Error envelope everywhere: non-2xx responses are exactly
{ error: { code, message, details? } }(ApiErrorin shared; codes inApiErrorCode; status mapping inERROR_STATUS). UseapiError()/ thefail()route helper. - ESM, TypeScript strict,
noUncheckedIndexedAccesson. Import sibling files with the.jsextension (NodeNext) in server/shared/cli; web uses bundler resolution. - The web app and the CLI both consume
@forq/api-client. Build it once, well.
server-boxd -> apps/server/src/boxd/
server-github -> apps/server/src/github/
server-runs -> apps/server/src/runs/
server-routes -> apps/server/src/routes/
server-config-triage -> apps/server/src/config/, apps/server/src/triage/
web-ui -> apps/web/src/ui/
web-shell -> apps/web/src/app/
web-repos-queue -> apps/web/src/views/repos/, apps/web/src/views/queue/
web-runs -> apps/web/src/views/runs/
web-golden-settings -> apps/web/src/views/golden/, apps/web/src/views/settings/
web-data -> apps/web/src/api/, apps/web/src/mocks/
cli -> apps/cli/src/
Foundation-owned (do not edit without coordinating): root config, tsconfig.base.json, packages/shared/**, packages/api-client/**, apps/server/src/{index,env,contracts,services,openapi}.ts, apps/server/src/routes/{index,_shared,health}.ts (other route files are stubs for server-routes/owners to fill), apps/web/src/{main.tsx,App.tsx,styles/theme.css,vite-env.d.ts}, apps/web/vite.config.ts, apps/web/index.html.
Note on routes: the per-resource route files (
boxd.ts,repos.ts,golden.ts,actions.ts,runs.ts,webhooks.ts,auth.ts) currently return501stubs.server-routesowns wiring them to services; the service implementations are owned by the respectiveserver-*slice. Routes pull services off Hono context (c.get("services")) — never import concrete impl classes.
Every name below is both a zod schema (value) and its inferred type (same identifier). import { Run } from "@forq/shared" gives you both.
Enums (enums.ts)
RunStatus—queued | triaging | provisioning | implementing | testing | pr_opened | needs_human | aborted | failed.TERMINAL_RUN_STATUSES,isTerminalRunStatus()provided. The lifecycle.ActionKind—preview | implement | triage | githubFixStrategy—single-shot | speculativeCodingAgent—claude-code | opencode | aiderTargetType—issue | prGithubOp—close | comment | labelFundingMode—owner-funded | contributor-byoGoldenStatus—not_configured | provisioning | ready | syncing | errorTreeNodeStatus—exploring | passing | failing | pruned(drives ForkTree colors)TriageSuggestedAction—implement | preview | comment | close | needs_human | skipIssueState—open | closed | allRunEventType—status | log | tree | result | error | heartbeat(SSE discriminator)LogLevel—debug | info | warn | error
Common (common.ts) — RepoId, RunId, InstallationId, Timestamp (ISO string), ApiErrorCode, ApiError, apiError(), ERROR_STATUS, PaginationQuery, paginated(item), Paginated<T>, HEADERS.
Repo (repo.ts) — GitHubActor {login, avatarUrl, htmlUrl}, Repo, RepoDetail (Repo + branches/topics/language).
Issue/PR (issue.ts) — Label {name,color,description}, QueueItem (unified issue/PR row; PR-only fields optional), IssueDetail.
Golden (golden.ts) — GoldenDeployConfig, GoldenConfig {install,build,start,port,branch,deploy}, Golden (status + config + boxId + previewUrl + sync info).
Config (config.ts) — TriageConfig, TestConfig, ActionsConfig, FundingConfig, ForqConfig (the forq.yml schema), ResolvedForqConfig {repoId, source, path, sha, config, raw}.
Run (run.ts) — ActionTarget {type, number, ref?}, TriageVerdict {act, confidence, reason, suggestedAction, labels, duplicateOf, model}, TreeNode, TreeEdge, ForkTree, RunPhase, RunResult, Run, RunEvent, SSE_EVENT_NAME ("run-event").
Boxd / session — BoxdStatus, Session ({authenticated, user, installationIds, boxdConnected, appInstallUrl} — appInstallUrl is the GitHub App's …/installations/new URL, derived server-side from the App's own metadata; null in mock or when unresolved. The "no repositories" empty state links here).
Endpoints (endpoints.ts) — API_BASE_PATH (/api/v1), ENDPOINTS registry (id/method/path/idempotent/sse/summary per endpoint), buildPath(path, params), EndpointId, HttpMethod.
Run = {
id, repoId, action: ActionKind, status: RunStatus,
target: ActionTarget, strategy: FixStrategy|null, agent: CodingAgent|null,
funding: FundingMode|null, title: string|null,
timeline: RunPhase[], // lifecycle transitions, for the timeline UI
result: RunResult|null, // terminal artifacts (prUrl, previewUrl, diff, triage, question…)
previewUrl: string|null, prUrl: string|null, // list-render mirrors of result
hasTree: boolean, // true when strategy === 'speculative'
boxId: string|null, error: string|null, idempotencyKey: string|null,
createdAt, updatedAt, startedAt: string|null, finishedAt: string|null,
}Discriminated by type. Wire frame: event: run-event, data: <JSON RunEvent>, id: <seq>. seq is monotonic per run (use as Last-Event-ID to resume).
RunEvent = {
type: RunEventType, runId, seq, at,
status?, // type === 'status'
level?, message?, source?, // type === 'log'
node?, edge?, // type === 'tree'
result?, // type === 'result'
error?, // type === 'error'
}Request/response columns name @forq/shared schemas. POSTs marked idem honor an Idempotency-Key header. Errors: ApiError envelope with the status from ERROR_STATUS.
| Method | Path | Req body / query | Response | Description |
|---|---|---|---|---|
| GET | /health |
— | HealthResponse |
Liveness (the one live endpoint in the foundation) |
| GET | /boxd/status |
— | BoxdStatusResponse (BoxdStatus) |
boxd connection + account; mock:true when no BOXD_API_KEY |
| GET | /repos |
— | ListReposResponse {items:Repo[]} |
Installed repos |
| GET | /repos/:repoId |
— | GetRepoResponse (RepoDetail) |
Repo detail + golden status |
| GET | /repos/:repoId/issues |
ListIssuesQuery (state,label,cursor,limit) |
ListIssuesResponse (Paginated<QueueItem>) |
Open issues, paginated |
| GET | /repos/:repoId/pulls |
ListPullsQuery |
ListPullsResponse (Paginated<QueueItem>) |
Open PRs, paginated |
| GET | /repos/:repoId/config |
— | GetConfigResponse (ResolvedForqConfig) |
Resolved forq.yml |
| PUT | /repos/:repoId/golden |
PutGoldenRequest (GoldenConfig) |
PutGoldenResponse (Golden) |
Create/configure golden |
| GET | /repos/:repoId/golden |
— | GetGoldenResponse (Golden) |
Golden status |
| POST idem | /repos/:repoId/golden/sync |
— | SyncGoldenResponse (Golden) |
Resync golden to main |
| GET | /golden |
— | ListGoldensResponse {items:Golden[]} |
Configured goldens (fleet index) |
| POST idem | /golden/setup |
GoldenSetupRequest {repoId,branch} |
GoldenSetupResponse {jobId,golden} |
Agent-driven golden creation → job |
| GET sse | /golden/setup/:jobId/events |
— | RunEvent stream |
Live setup logs (reuses RunEvent) |
| POST idem | /actions/preview |
PreviewActionRequest |
RunResponse (Run) |
Spin a preview env → Run |
| POST idem | /actions/implement |
ImplementActionRequest |
RunResponse (Run) |
Implement an issue → Run |
| POST idem | /actions/triage |
TriageActionRequest |
RunResponse (Run) |
Triage; terminal result carries TriageVerdict |
| POST idem | /actions/github |
GithubActionRequest |
GithubActionResponse |
close/comment/label (synchronous, no Run) |
| POST idem | /actions/batch |
BatchActionRequest |
BatchActionResponse {runs:Run[]} |
Whole-queue burndown |
| GET | /runs |
ListRunsQuery (repoId,status,cursor,limit) |
ListRunsResponse (Paginated<Run>) |
List runs |
| GET | /runs/:runId |
— | GetRunResponse (Run) |
Run detail |
| GET (SSE) | /runs/:runId/events |
header Last-Event-ID? |
text/event-stream of RunEvent |
Live log/status/tree stream |
| POST idem | /runs/:runId/abort |
— | AbortRunResponse (Run) |
Abort a run |
| GET | /runs/:runId/tree |
— | GetTreeResponse (ForkTree) |
Speculative tree snapshot (stub data ok) |
| POST | /webhooks/github |
raw GitHub payload (+ signature headers) | WebhookResponse |
GitHub App webhook receiver |
| GET | /auth/session |
— | SessionResponse (Session) |
Current session (stub) |
| GET | /openapi.json |
— | OpenAPI 3.0 doc | Served at root (not under /api/v1) |
@forq/api-client method ↔ endpoint map (one method each): health, boxdStatus, listRepos, getRepo(repoId), listIssues(repoId,query?), listPulls(repoId,query?), getConfig(repoId), getGolden(repoId), putGolden(repoId,body), syncGolden(repoId), previewAction(body), implementAction(body), triageAction(body), githubAction(body), batchAction(body), listRuns(query?), getRun(runId), abortRun(runId), getTree(runId), authSession(), and subscribeRunEvents(runId, onEvent, opts?) → { close } for SSE. Every method takes a final RequestOptions {signal?, idempotencyKey?, headers?}. Construct with createForqClient({ baseUrl, token?, mock? }); mock:true returns shared-shaped fixtures and animates SSE with no server.
Routes depend on these interfaces only, via c.get("services") (Services). Implement your slice's interface in your directory and register the impl in apps/server/src/services.ts (replace the stub<T>() for your field). All return @forq/shared types. Helper types: VmHandle, ExecResult, ListOptions, IssueListOptions.
interface BoxdService { // server-boxd (apps/server/src/boxd/)
readonly mock: boolean;
status(signal?): Promise<BoxdStatus>;
createGolden(repoId, config: GoldenConfig, opts?): Promise<VmHandle>;
getGolden(repoId): Promise<VmHandle | null>;
syncGolden(repoId, branch, opts?): Promise<void>;
fork(repoId, opts?): Promise<VmHandle>; // live-fork the golden (~113ms CoW)
forkFrom(boxId, opts?): Promise<VmHandle>; // branch a speculative node from its parent
get(boxId): Promise<VmHandle | null>;
destroy(boxId): Promise<void>;
}
interface GitHubService { // server-github (apps/server/src/github/)
readonly enabled: boolean;
listInstalledRepos(opts?): Promise<Repo[]>;
getRepo(repoId, signal?): Promise<RepoDetail>;
listIssues(repoId, opts?): Promise<Paginated<QueueItem>>;
listPulls(repoId, opts?): Promise<Paginated<QueueItem>>;
performOp(repoId, target, op, payload, signal?): Promise<{ htmlUrl: string | null }>;
openPullRequest(input: OpenPullRequestInput, signal?): Promise<{ htmlUrl: string; number: number }>;
verifyWebhook(headers, rawBody): Promise<VerifiedWebhook>;
getSession(token?): Promise<Session>;
}
interface ConfigService { // server-config-triage (apps/server/src/config/)
resolve(repoId, opts?): Promise<ResolvedForqConfig>; // fetch+parse+validate forq.yml, fall back to defaults
}
interface TriageService { // server-config-triage (apps/server/src/triage/)
readonly enabled: boolean;
classify(input: TriageInput): Promise<TriageVerdict>; // Nebius OpenAI-compatible fast model
}
interface RunsEngine { // server-runs (apps/server/src/runs/)
start(input: StartActionInput): Promise<Run>; // create Run (status: queued), drive lifecycle
startBatch(input): Promise<Run[]>; // one Run per target
get(runId): Promise<Run | null>;
list(filter: RunFilter): Promise<Paginated<Run>>;
abort(runId): Promise<Run>;
getTree(runId): Promise<ForkTree | null>;
subscribe(runId, onEvent: (e: RunEvent)=>void, opts?: {afterSeq?}): () => void; // SSE source
}
interface Services { boxd; github; config; triage; runs; } // the bag on Hono context
// c.get("services") via AppBindings; key = SERVICES_KEY ("services").Implementation notes for the slices:
- server-boxd: wrap
@boxd-sh/sdk'sCompute(box.create/fork/get/list,Box.exec/writeFile/createProxy/setProxyPort/waitUntilReady/suspend/destroy,token.create).VmHandleis the forq-narrowed view. When!capabilities.boxd, run mock:status().mock === true, fabricate handles. Reuse the on-VM bash platform viaexecfor golden sync / preview / fix (see PLAN §10). - server-github: octokit App auth (
@octokit/auth-app) → installation token;@octokit/restfor issues/PRs/PR-create;@octokit/webhooksforverifyWebhook. Map GitHub issue/PR →QueueItem. Acts as the App, never a user PAT. Stub cleanly when!capabilities.github. - server-config-triage:
ConfigService.resolvefetchesforq.ymlfrom the repo default branch, parses YAML, validates withForqConfig, normalizes yaml snake/kebab → camelCase, falls back to defaults.TriageServicecalls Nebius (NEBIUS_BASE_URL, OpenAI-compatible); when!capabilities.triage, return a deterministicact:trueverdict so the pipeline flows. - server-runs: owns the
RunStatusstate machine (only place that mutates a Run's status), emitsRunEvents, persists runs (in-memory map is fine for v1), implements the four action handlers + the speculative search (best-first overBoxdService.fork/forkFrom, tests as fitness, buildForkTree). Read theIdempotency-Key(router passes it throughstart({idempotencyKey})) and de-dupe. - server-routes: replace each stub handler with: validate input (shared zod schema) → call the service →
ok(c, result)/fail(c, code, msg). For SSE, bridgeruns.subscribe(runId, e => stream.writeSSE({event: SSE_EVENT_NAME, data: JSON.stringify(e), id: String(e.seq)})).
All components are monospace-forward, hairline-bordered, dark-default. Names + prop shapes are the contract — views import these by name. Use the design tokens (section e) via Tailwind utilities (bg-surface, text-strong, border-hairline, text-accent, rounded-md, semantic text-success etc.) and the helpers .ring-hairline, .focus-ring, .elev-1. Prefer forwardRef where it makes sense. Keep everything controlled.
// primitives
Button({ variant?: 'primary'|'secondary'|'ghost'|'danger', size?: 'sm'|'md', loading?: boolean,
iconLeft?: ReactNode, iconRight?: ReactNode, disabled?, type?, onClick?, children })
IconButton({ icon: ReactNode, label: string /* a11y */, variant?: 'ghost'|'secondary'|'danger',
size?: 'sm'|'md', loading?, disabled?, onClick? })
Card({ as?, padded?: boolean, interactive?: boolean, className?, children }) // surface + ring-hairline
Badge({ variant?: 'neutral'|'accent'|'success'|'warning'|'error'|'info', children }) // pill, tinted-muted bg
StatusPill({ status: RunStatus, size?: 'sm'|'md' }) // maps RunStatus -> color (table below); dot + label
Spinner({ size?: number, className? })
Tooltip({ content: ReactNode, side?: 'top'|'right'|'bottom'|'left', children })
EmptyState({ icon?: ReactNode, title: string, description?: string, action?: ReactNode })
CodeBlock({ code: string, language?: string, wrap?: boolean, className? }) // mono, hairline, scroll
// form
TextInput({ value: string, onChange: (v:string)=>void, placeholder?, type?, disabled?,
invalid?: boolean, iconLeft?: ReactNode, onKeyDown?, name?, id? })
Select<T extends string>({ value: T, onChange: (v:T)=>void,
options: { value: T; label: string }[], disabled?, placeholder? })
Checkbox({ checked: boolean, onChange: (c:boolean)=>void, indeterminate?: boolean, label?, disabled? })
// layout / overlay
Tabs({ value: string, onChange: (v:string)=>void, tabs: { value: string; label: ReactNode; count?: number }[] })
Dialog({ open: boolean, onClose: ()=>void, title?: ReactNode, children, footer?: ReactNode, size?: 'sm'|'md'|'lg' })
Toast + useToast: useToast() -> { toast: (t: { title: string; description?: string;
variant?: 'neutral'|'success'|'error'|'info'; durationMs?: number }) => void };
mount <ToastViewport/> once (web-shell).
Table + Row: Table({ columns: Column<T>[], children }) // Column<T> = { key, header, width?, align?, render?(row) }
Row({ selected?: boolean, onClick?, className?, children })
// burndown rows animate status transitions — Row exposes data-status for motion hooks.
// the two showpieces
LogStream({ runId: string, events?: RunEvent[], autoScroll?: boolean, height?: number|string })
// SSE-fed (subscribeRunEvents) or controlled via events[]; renders log lines + status transitions.
ForkTree({ nodes: TreeNode[], edges: TreeEdge[], winningNodeId?: string|null,
onSelectNode?: (id:string)=>void, height?: number|string })
// node = {id,parentId,status,hypothesis,score,...}; colors by TreeNodeStatus (section e);
// amber exploring -> red failing / green passing -> gray pruned; the explode-then-collapse viz.
// chrome (web-shell owns instances in src/app/; web-ui owns the components)
Sidebar({ children }) TopBar({ left?, center?, right? })
ThemeToggle({}) // toggles .dark/.light on <html>, persists to localStorage 'forq-theme'StatusPill color mapping (RunStatus → token):
| status | token (utility) |
|---|---|
| queued | text-status-queued (weak gray) |
| triaging | text-status-triaging (info / purple) |
| provisioning | text-status-provisioning (warning) |
| implementing | text-status-implementing (accent) |
| testing | text-status-testing (warning) |
| pr_opened | text-status-pr-opened (success) |
| needs_human | text-status-needs-human (info / purple) |
| aborted | text-status-aborted (gray) |
| failed | text-status-failed (error) |
ForkTree node color mapping (TreeNodeStatus → token): exploring → node-exploring (amber/peach), passing → node-passing (green), failing → node-failing (red), pruned → node-pruned (gray).
Defined in apps/web/src/styles/theme.css as CSS variables (light + dark) and wired to Tailwind v4 via @theme. Dark is the default/hero. Toggle by putting .dark or .light on <html> (pre-painted in index.html; ThemeToggle flips + persists forq-theme).
- Accent (the ONE signature color, sparingly): light
#dcde8d(chartreuse), dark#fab283(peach). Utilities:text-accent,bg-accent,border-accent; muted fillbg-accent-muted. Focus ring = accent (.focus-ring). - Backgrounds (dark): base
#0a0a0a/ alt#101010; surfaces#151515(bg-surface) /#1a1a1a(bg-raised). Light: base#f8f8f8/ surface#ffffff. Utilities:bg-base,bg-surface,bg-raised,bg-overlay. - Text (dark): strong
rgba(255,255,255,0.94)(text-strong) / base0.62(text-base-fg) / weak0.42(text-weak) / faint0.28(text-faint). Light: strong#171717/ base#6f6f6f/ weak#8f8f8f. - Borders — HAIRLINE: dark
rgba(255,255,255,0.12..0.2), lightrgba(0,0,0,0.12..0.16). Utilitiesborder-hairline/border-hairline-strong; prefer border-as-shadow.ring-hairline(box-shadow 0 0 0 1px). - Semantic: success
#12c905, warning#fbdd46, error#fc533a, info#a753ae(+-mutedtints). Diff:diff-add/diff-add-bg,diff-del/diff-del-bg. Status + node palettes per the tables above. - Radii:
xs 2px, sm 4px, md 6px, lg 8px, xl 10px(rounded-xs…rounded-xl). Spacing base0.25rem. - Type: mono =
JetBrains Mono(PRIMARY — UI + data), fallbackIBM Plex Mono,ui-monospace; sans =Inter(long prose only, opt-in via.prose). Base14px. Weights 400/500/600.font-monois the default;font-sansfor prose.
Aesthetic: refined industrial/utilitarian developer tool. Near-monochrome warm-gray, ONE accent, hairline borders, crisp small radii, dense-but-breathable monospace data. Dark is the hero. Tasteful Motion only (lib: motion). Lavish craft on the two memorable moments: (1) the issue/PR queue burning down with smooth row state transitions; (2) the speculative fork-tree viz (universes exploding then collapsing to green). NO generic AI look, NO purple gradients, NO Inter-as-everything.
createBrowserRouter table lives in apps/web/src/App.tsx (foundation seeds placeholder views; each slice replaces its view). AppLayout chrome (Sidebar/TopBar/ThemeToggle/ToastViewport) is owned by web-shell in src/app/. Data hooks (useQuery wrappers around @forq/api-client) + mocks are owned by web-data in src/api/ + src/mocks/.
| path | view component | owning slice |
|---|---|---|
/ |
ReposView (repo list) |
web-repos-queue |
/repos/:repoId |
RepoOverviewView |
web-repos-queue |
/repos/:repoId/queue |
QueueView (issues/PRs burndown, multi-select, batch) |
web-repos-queue |
/repos/:repoId/golden |
GoldenView (golden config) |
web-golden-settings |
/repos/:repoId/settings |
RepoSettingsView (forq.yml) |
web-golden-settings |
/runs |
RunsView (runs list) |
web-runs |
/runs/:runId |
RunDetailView (timeline + LogStream; ForkTree when speculative) |
web-runs |
/settings |
SettingsView (boxd key, account, theme) |
web-golden-settings |
* |
NotFoundView |
web-shell |
Shared frontend conventions:
- All server state via
@tanstack/react-query; oneForqClientinstance (web-data provides it; foundation wires a mock one inApp.tsx). Toggle live vs mock withVITE_FORQ_MOCK("false"= live; Vite proxies/api→:8787). - Stream a run with
api.subscribeRunEvents(runId, onEvent); feedLogStream/ForkTree. Resume withlastEventId. - Never hardcode colors — use the token utilities. Never reach past
@forq/api-clienttofetchthe API directly.
pnpm install
pnpm typecheck # all workspaces strict
pnpm build # shared -> api-client -> server + web + cli
pnpm dev # server :8787 + web :5173Foundation status: pnpm typecheck and pnpm build are green; server boots (/api/v1/health 200, stubs 501 with the envelope, /openapi.json valid, SSE frames well-formed); web builds (Tailwind v4 + tokens compile); CLI runs in mock mode end-to-end.