|
|
@@ -89,7 +89,7 @@ The container image pins Bun to an exact patch release in `Dockerfile` (`test/do |
| 89 | 89 |
|
| 90 | 90 |
Set `FORGE_REPO=owner/name` to the forge's own code and a pull the crew lands there is written to `$FORGE_DATA/releases/<sha>/` (the newest `FORGE_RELEASES_KEEP`, default 5, are kept), preflighted with that tree's own `--preflight`, and recorded in the `release` table (sha, landed pull, verdict, its line, when). A red preflight is asked again once (the copy of the live database can tear under load); a second red is logged and told to the landed pull. Other repositories are untouched, and nothing is swapped unless `FORGE_SELF_SWAP=1`: then the forge binds its port with SO_REUSEPORT, a green release is started from `releases/<sha>/` beside it on the same port (`FORGE_COMMIT` set to the sha), `/api/v1/health` is read until an answer names that sha and is well (`FORGE_SWAP_BUDGET_S`, default 60), and only then does this process drain as a SIGTERM does — except that it stops accepting at once, so every new connection is the release's, and answers what it already holds (a long push or clone) to the end, for up to five minutes, before it closes. A release not healthy in its budget is killed, the old keeps serving, and the log and the landed pull say so. One swap per process: a landing while another swap is in its budget, or after one succeeded, is released and recorded but not started (the new process runs it from the next restart). The SSH terminal is not handed over yet (the new process cannot bind the old one's port), so a process holding the SSH listener refuses the swap and tells the landed pull; run with `FORGE_SSH_PORT=0` to swap. The swap also needs `net.ipv4.tcp_migrate_req=1` (Linux 5.14+): without it the kernel resets the connections still queued on the old listener when it closes, so a swap is refused (and told to the landed pull) rather than failing requests. The release is a child of the old process, so the swap is for a forge run directly, not under a supervisor that kills its children when the old process exits (PID 1 of a container, a systemd unit with the default `KillMode=control-group`). SO_REUSEPORT lets any process of the forge's user bind the same port and take a share of its connections, so `FORGE_SELF_SWAP=1` assumes nothing else runs as the forge's user on the host network (agents and apps are sandboxed or in cells). `--rollback <sha>` starts the kept release beside the running forge by the same health-read swap (`FORGE_SWAP_BUDGET_S` does not apply: it waits the default 60s), and a release that is not healthy is killed with the old still serving. It refuses, with one line and exit 1, a sha that is not kept (never checked out, or pruned) and a release whose schema has fewer migrations than the live database has applied (`<sha> predates schema N; rolling back would need a down-migration`); a release with a newer schema is allowed, as it migrates forward on boot, and the database's refusal of a future schema is unchanged. It does not drain the forge that was serving: until part 2 of the rollback does, both processes share the port and the operator stops the old one (the command prints that). It does not check `tcp_migrate_req` or SSH either; stopping the old process cleanly needs the same `net.ipv4.tcp_migrate_req=1` setup as the swap, and the SSH port stays with the old process. `/releases` lists the kept releases (short sha, landed pull, when, and since when the live one has been healthy: this process's start); a person who writes the `FORGE_REPO` repository (never an agent: the button is hidden and the POST is a 403) gets a Roll back button on a kept, schema-compatible release, which runs the same refusals and swap and then drains this process (so it needs `FORGE_SELF_SWAP=1`); only readers of that repository and site admins see the page and the home line (anyone else gets a 404), and the forge's SSH terminal has the same, gated the same (`src/release/gate.ts`): the palette's `Releases` shows `live: <sha> since <time>` and the kept releases, only to those readers, and Enter on a rollable release asks to confirm then runs the same rollback as the button (a person who writes the repository only, never an agent; told when `FORGE_SELF_SWAP` is off); the home carries a `live: <sha> since <time>` line linking there. `FORGE_REPO` also names the parent code that spawned child forges fork. |
| 91 | 91 |
|
| 92 | |
**Keeping a domain drill**: `POST /api/v1/drills` (a site admin person; an agent is refused with 403, and a session needs a same-origin request) takes `{domain, seen:{health, board}, backSeconds}`: a domain key from `docs/DOMAINS.md`, what the drill read from health and the board (text, at most 8000 characters each), and whole seconds until the forge was well again (0 to 31536000). The body is capped at 32000 bytes (413). It answers 201 `{id, domain, backSeconds, green, why}`: the verdict is judged against the domain's own words and kept whether green or red. An unknown domain, bad `seen` or bad `backSeconds` is 422. Kept drills are read back on **Settings → Backups**, in its **Failure domains** card (below). To run a drill by hand, `FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url>` (plain `http` is refused unless the host is loopback or you pass `--insecure`) prompts for the fault, watches health and the board until the forge is back, and posts this for you (`docs/DOMAINS.md`). |
| 92 |
**Keeping a domain drill**: `POST /api/v1/drills` (a site admin person; an agent is refused with 403, and a session needs a same-origin request) takes `{domain, seen:{health, board}, backSeconds}`: a domain key from `docs/DOMAINS.md`, what the drill read from health and the board (text, at most 8000 characters each), and whole seconds until the forge was well again (0 to 31536000). The body is capped at 32000 bytes (413). It answers 201 `{id, domain, backSeconds, green, why}`: the verdict is judged against the domain's own words and kept whether green or red. An unknown domain, bad `seen` or bad `backSeconds` is 422. Kept drills are read back on **Settings → Backups**, in its **Failure domains** card (`docs/SNAPSHOT.md`). To run a drill by hand, `FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url>` (plain `http` is refused unless the host is loopback or you pass `--insecure`) prompts for the fault, watches health and the board until the forge is back, and posts this for you (`docs/DOMAINS.md`). |
| 93 | 93 |
|
| 94 | 94 |
If your shell already has `FORGE_URL` set — a crew workspace sets it to the |
| 95 | 95 |
forge you work for — this forge takes it as its own canonical address and |
|
|
@@ -266,697 +266,6 @@ The check cell's memory is on the same page under **Check cell**, and in the ter |
| 266 | 266 |
`owner/name`. `n` mints (the child's name, then its https address) and shows |
| 267 | 267 |
the secret once on its own screen; `x` revokes the one under the cursor. |
| 268 | 268 |
|
| 269 | |
## Page-view beacon |
| 270 | |
|
| 271 | |
Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, no dependency) that sends, once per page view with `sendBeacon`, the route's path, TTFB, load time, LCP, INP (the worst interaction) and CLS to `POST /_measure`. The route is same-origin only, validates every field, caps the body at 512 bytes and the rate per client, and answers 204. Reports land in `page_vital` (bun:sqlite) keyed by route *pattern*; no IP and no user agent string is stored. A unique visitor is a hash under a salt that rotates daily (yesterday's salt is deleted), rows are kept 90 days. Do-Not-Track and the `forge_measure=off` cookie send nothing, and the server stores nothing for them either. The CSP is unchanged: the script is nonce'd like the shell's. |
| 272 | |
|
| 273 | |
### How long the golden path took |
| 274 | |
|
| 275 | |
An app's repository home wears the pace on its app strip, the terminal's **Deploys** screen shows it under the title, `/apps` (and any shelf of apps) says `first preview 14s` in each row's meta line once one has served (one grouped query, `firstPreviews`; the terminal has no apps list of its own to carry it), and `GET /api/v1/repos/:owner/:name/app` returns it as `firstPreviewMs` and `firstProductionChangeMs`. It measures milliseconds from New app to the first preview that served, and from New app to the first production deploy that began after the first merged pull; a figure is left out until it exists. Anyone who can read the repository sees it (`goldenPath` in `src/apps/golden_path.ts`, `appPace` for the terminal). |
| 276 | |
|
| 277 | |
### Every app's visitors |
| 278 | |
|
| 279 | |
An app made from the template ships the same beacon as `static/beacon.js`, posting to `POST /__forge/measure` on the app's own host. The forge answers that door itself (`src/apps/measure.ts`), before the app is woken: same-origin only, 30 reports a minute per client and 600 per app (429), a body over the cap is a 413, and a private app or a sealed pull's preview refuses anyone its gate refuses. The app is the host's, never the body's. Production reports are kept in `app_vital` (`src/apps/vitals.ts`, TTFB, load, LCP, INP and CLS, a visitor hashed under the day's salt, no address, kept 90 days and removed with the app, at most 20,000 rows per app a day); previews store nothing. Only Do-Not-Track opts a visitor out on an app's host: the `forge_measure=off` cookie is set on the forge's host and never reaches an app's. Read the numbers (visitors today and over 7 days, p75 TTFB, load, LCP, INP and CLS) on the app's settings page under **Visitors and speed**, or as `vitals` in an admin's `GET /api/v1/repos/:owner/:name/app`. Both doors show them only to people who administer the repository (`appVisitors` in `src/actions/apps.ts`), so a reader or an anonymous caller sees no `vitals`. The page also draws p75 load for each of the last 30 days as a sparkline (server-rendered, no script); a day with no reports is a gap in the line, not a zero, and an app with none in 30 days says so in words. The same 30 values, oldest first with `null` for a silent day, are `vitals.loadDays` in the API. In the terminal all five are on the second line of the app's **Deploys** screen over ssh, and a third line there shows the 30 days as block characters with `·` for a silent day, then the range. |
| 280 | |
|
| 281 | |
The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker. |
| 282 | |
|
| 283 | |
## The board's notes |
| 284 | |
|
| 285 | |
`/board` opens on its panels. Everything that is not a panel — the forge's |
| 286 | |
health (admins), a planner that declined and waits for you, agent pulls that |
| 287 | |
wait for your review — is folded into one quiet line under the title, "3 notes |
| 288 | |
· 2 need you". It is closed by default; click it (or Tab to it and press |
| 289 | |
Enter) to open the list in place, and it stays open across live updates. A |
| 290 | |
declined planner has its answer box right in its note. The crew's own |
| 291 | |
housekeeping (an issue parked twice) is not a note; it is on `/map`. The |
| 292 | |
terminal's first screen says the declined and review-wait notes too, one line |
| 293 | |
each, under the forge strip; a list cut to three says "+N more". |
| 294 | |
The line is the shared `.quiet-details` disclosure, and the "need you" count |
| 295 | |
carries the attention dot. |
| 296 | |
|
| 297 | |
A fork ahead of its parent offers its commits upstream: on its home a signed-in reader of the parent sees "Offer N commits upstream" (into the new-pull form with both branches chosen) or "Offered in #M" once a pull is open; a writer who has the Following card gets its "Propose upstream" instead, one route not two. In the terminal the fork's About card (`I`) has an `offer` row and `o` opens the same pull form (or the open pull). Both read `upstreamOffer` (`src/actions/offer_upstream.ts`), the open pull by one SQL lookup. |
| 298 | |
|
| 299 | |
## Long threads |
| 300 | |
|
| 301 | |
An issue or pull page renders the opening post and its newest 50 comments |
| 302 | |
(`THREAD_WINDOW`, `src/core/issues.ts`) with the state-change lines between |
| 303 | |
them; above them a plain link, `Show N earlier items`, goes to the same page |
| 304 | |
with `?all=1` (other query parts kept), which renders everything, with no |
| 305 | |
JavaScript. N counts state-change lines as well as comments. A comment's own |
| 306 | |
anchor is `#c-<id>`; links the forge writes to a comment (the feed's replies) |
| 307 | |
go through `commentAnchor()`, which counts comments the way the page cuts |
| 308 | |
them, and carry `?all=1` when the comment is folded away, so they always |
| 309 | |
land. A hand-typed `#c-…` into the folded part cannot be seen by the server |
| 310 | |
(the fragment never leaves the browser), so it needs `?all=1` too. The live |
| 311 | |
region still appends new comments at the bottom. |
| 312 | |
|
| 313 | |
The terminal's issue and pull screens stay whole on purpose: they are a |
| 314 | |
scrolling text view of one thread already in memory, not a page a phone |
| 315 | |
downloads, so there is no weight to cut and no second key to learn. |
| 316 | |
|
| 317 | |
## Live in the terminal |
| 318 | |
|
| 319 | |
`ssh` into the forge and you get the whole thing — repositories, code, issues, |
| 320 | |
pull requests with a real diff viewer, review comments, merges, settings, |
| 321 | |
tokens and keys — as a keyboard-driven terminal app. The same port serves git. |
| 322 | |
|
| 323 | |
```sh |
| 324 | |
ssh -p 2222 localhost # the terminal UI |
| 325 | |
git clone ssh://localhost:2222/you/hello.git # git, same port |
| 326 | |
ssh -p 2222 localhost tell make the header blue # tell the crew in one line |
| 327 | |
``` |
| 328 | |
|
| 329 | |
`tell <words>` takes the rest of the command line as given (no shell expansion; control characters are refused) and tells the crew in the forge's own repository, as the account the key belongs to, through the same action as the web composer. It prints the told line and the full address of the issue, and exits 0; an unknown key, nothing said, too much, or an unverified email prints the reason on stderr, records nothing and exits 1. (Quote the words if your local shell would expand them.) When SSH is on, `/llms.txt` and `/api/v1/here` (the `ssh` field of the "tell the crew" action) name this exact command, so an agent finds it without being taught; with SSH off they say nothing about it. |
| 330 | |
|
| 331 | |
On an app's login, `ssh -p 2222 notes@localhost tell make the header blue` goes through the app terminal's gate (an app you may not enter answers `no app named notes`, and nothing is recorded) and tells the crew of the app's repository, with the app's production address as where it was said: the issue body and `/asked` show `Told from <address>`. |
| 332 | |
|
| 333 | |
An app's open pulls, newest five first, one line each (`#n title · green|red|running|none`), need no tty: run `pulls` on the app's login — `ssh <app>@<forge> pulls`, with the app's page printing the exact command as `FORGE_APP_SSH` (the terminal is off when that is unset). A private app answers only a key its gate admits; sealed pulls show to their author, writers and the crew (`src/apps/door_pulls.ts`). |
| 334 | |
|
| 335 | |
The app's `/__forge/` door on its own host lists the same five pulls with their check word (`#n title · check green`). A running, asleep or dormant preview links to itself, any other pull to its page on the forge. Who sees which is the rule in `src/apps/door_pulls.ts`; a stranger at a private app gets the same 404 as for an unknown app. When more than five are open the door says so in one line; the rest are on the forge. |
| 336 | |
|
| 337 | |
A big repository clones over either transport. To skip its large files until a |
| 338 | |
checkout needs them, run `git clone --filter=blob:none <url>`. See |
| 339 | |
[`docs/GIT.md`](docs/GIT.md). |
| 340 | |
|
| 341 | |
**After a GitHub migration** you choose what happens to the original: leave it |
| 342 | |
alone (the default), archive it, or keep it as a read-only mirror. The choice is |
| 343 | |
the "Afterwards" field on `/migrate`, in the terminal's migrate screen, and |
| 344 | |
`after` (`leave`, `archive`, `mirror`) on `POST /api/v1/migrate`. Archive marks |
| 345 | |
the GitHub repository archived using the account you connected. Mirror leaves |
| 346 | |
GitHub untouched at first, then copies later changes on the branches GitHub had |
| 347 | |
at migration back to it; branches made afterwards, such as the crew's work |
| 348 | |
branches and pull heads, stay on the forge. If someone pushes to GitHub |
| 349 | |
meanwhile, the forge stops rather than overwrite them, and the repository's |
| 350 | |
settings page (and the terminal's status line) says why it stopped and when it |
| 351 | |
last pushed. "Stop mirroring" there, or `U` in the terminal, ends it and leaves |
| 352 | |
GitHub as it is. Archiving needs admin |
| 353 | |
rights on GitHub: without them the migration still succeeds and says the |
| 354 | |
repository was not archived, so you can do it there yourself. Only repositories |
| 355 | |
read through a connected origin have an original to change; a pasted URL does not. |
| 356 | |
|
| 357 | |
**Migrate from the terminal.** Press `M` on the dashboard (or pick "Migrate a |
| 358 | |
repository" in the palette, `ctrl+k`). The list is your connected origins' |
| 359 | |
repositories, private ones marked; `/` filters it and `u` pastes a URL instead. |
| 360 | |
Enter on a repository lists its branches with their last commit, your own |
| 361 | |
newest first and marked "(suggested)", so Enter again takes it; a pasted URL |
| 362 | |
skips this step. The form asks for name, owner, visibility, agent, autonomy and afterwards |
| 363 | |
(leave the original alone, archive it, or keep it as a mirror; `tab` moves, `space` changes); `ctrl+d` migrates. The job is a checklist of |
| 364 | |
stages: `esc` hides it and never cancels, and a toast says when it lands, on the |
| 365 | |
new repository. It is the same `migrateRepo` as `/migrate` and |
| 366 | |
`POST /api/v1/migrate`: the chosen `branch` becomes the default branch and the |
| 367 | |
crew's brief says the app lives on it. The web form takes it too (it lists the |
| 368 | |
picked repository's branches newest first), as does the API's `branch` field. |
| 369 | |
|
| 370 | |
A GitHub migration also carries each milestone (title, description, due date, |
| 371 | |
open or closed) as an issue here, with the due date and GitHub's milestone |
| 372 | |
number kept in `milestone_origin`; carrying again makes no duplicates. Which |
| 373 | |
issues belong to a milestone is not carried yet. |
| 374 | |
|
| 375 | |
On a forge made for a client (one that was spawned, signed in by its claim |
| 376 | |
link), the home is their apps, one plain sentence each and one more saying |
| 377 | |
whether its agents are working, about to start, or waiting: no map, board or |
| 378 | |
settings until asked. `/?all=1` is the whole home, and its "Just my apps" link |
| 379 | |
comes back. Each app row also says what its agents are on ("Working on: …") |
| 380 | |
and what was last finished on it ("Finished 3 days ago: …", its newest merged |
| 381 | |
pull however old, read for all rows in one query); an app with neither says |
| 382 | |
nothing more. Both doors show both lines: the terminal's first screen opens |
| 383 | |
with the same lines, worded by the same helpers (`src/core/client-apps.ts`). |
| 384 | |
|
| 385 | |
**Reading the home as the eye does (#5735, step 1).** `bun scripts/home-read.ts <url>` fetches a page and prints its visible text, then one line: `chars N · words N · fetched in N ms`. Set `FORGE_COOKIE` (for example `forge_session=…; forge_mode=studio`) to read it as the owner; the cookie is sent and never printed. It exits non-zero on any status but 200. The conversion is `pageText` in `src/web/page_text.ts`: scripts, styles, templates, the head and anything `hidden` or `aria-hidden="true"` are dropped, link text stays, blocks sit on their own lines, entities are decoded. The method then continues: (2) a fresh agent with no context reads only that text and answers in at most 150 words, (3) the steward scores it against ground truth on coverage of the 5 things that mattered, errors, and seconds, (4) repeat after every feed change. The steward runs steps 2–4 and logs them on #5735. |
| 386 | |
|
| 387 | |
**Scoring the home (#5805).** `bun scripts/home-eval.ts <url>` is the deterministic half of that loop: it reads `/` with the same text conversion, builds ground truth from the forge's own API (`GET /api/v1/telemetry/home` for landings, the live version, builders and why idle, spend against the ceiling and what waits on the owner; the repository list and each newest commit for repositories quiet 3 days), and prints each of the five facts as `stated`, `missing` or `wrong` with the page's words, then coverage, errors, characters and fetch time. The method and a table of results, one row per feed pull, are in `docs/HOME-EVAL.md`. No dependency was added. |
| 388 | |
|
| 389 | |
Under the figures, an admin's home, the terminal's dashboard and its Flow dialog carry one vitals line (#5935, `src/flow/vitals.ts`): open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets; the autonomy tick line says the same as `pulls` (and `offTarget` when a target is missed). The MCP `flow` tool and `GET /api/v1/telemetry/flow` carry it as `vitals: { says, misses, … }`; the tool's description is unchanged because `tools/list` is at its size ceiling, so an agent finds `vitals` in the result, not the listing. An agent reader's counts cover public repositories only. |
| 390 | |
|
| 391 | |
Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with |
| 392 | |
a 24 h sparkline), the live version and since when, the median check time, the crew builders |
| 393 | |
running of their slots, and the crew's spend today of the forge's daily |
| 394 | |
ceiling (`FORGE_CREW_CEILING_USD`; without one, the spend alone and |
| 395 | |
`no ceiling`), and the age of the last off-box backup (the newer of the |
| 396 | |
mirror's newest snapshot, `mirror/state.json`, and the newest that landed in |
| 397 | |
a destination that is a bucket, `snapshot/state.json`; `src/snapshot/off_box.ts`; `3 h ago`, the exact time in its title), linking to |
| 398 | |
`/settings/backups`. The spend tile links to `/settings/crew`, where the meter is. |
| 399 | |
The backup tile past 24 hours, or with none (`never`), takes the card's existing |
| 400 | |
`data-tone="attn"` and says overdue in its label. |
| 401 | |
The last three (`src/flow/reach.ts`, the `reach` board source's rows) are |
| 402 | |
visitors (`5 today`, `31 in 7 days`), accounts (`1 of 2`, signed in today) and |
| 403 | |
page speed (the slowest quarter's page load over 7 days, and `reacts in 180 ms`, |
| 404 | |
the same for the delay after a tap), from the page-view beacon. Each links to |
| 405 | |
the Reach starting board (`/boards/reach`). The visitor hash rotates daily, so |
| 406 | |
the 7-day visitors is each day's distinct count summed. |
| 407 | |
They count every repository, so only a site admin sees them, on both doors; |
| 408 | |
nobody else does, and neither does the lean client home. On the web each is a |
| 409 | |
card linking to `/board`, `/releases`, `/boards` or `/boards/reach`. The terminal's first screen |
| 410 | |
shows the same nine as lines above the forge strip (`kpiLines` in |
| 411 | |
`src/flow/kpi_words.ts`). They are read when the page or screen loads and |
| 412 | |
do not refresh on their own. |
| 413 | |
|
| 414 | |
On a host that cannot hold cells or the crew's shell sandbox, a site admin |
| 415 | |
sees `Reduced protection: …` in one line, under the filter on the terminal's |
| 416 | |
first screen and in the web footer; nobody else does, and a fully protected |
| 417 | |
forge shows nothing. |
| 418 | |
|
| 419 | |
A new version proves itself once per version: after a boot that changes |
| 420 | |
`FORGE_VERSION`, the forge waits (up to 10 minutes) for its public URL to |
| 421 | |
answer `/healthz` as that version, then runs the smoke checks (`src/release/smoke.ts`) against it and |
| 422 | |
keeps the verdict in `$FORGE_APP_DATA/release/proof.json`. A failure is a |
| 423 | |
`Release X is unproven: …` card on an admin's board and on the terminal's first |
| 424 | |
screen (the same cards), and is paged once; a later passing version clears it. |
| 425 | |
|
| 426 | |
**The outsider suite** (#663) is the proof a person reads before flipping signup |
| 427 | |
to open. It adds the proof only: it removes no gate and reopens nothing. |
| 428 | |
`signup-gate.yaml` and `outsider-gate.yaml` in `trevato-apps/gitops` stay up |
| 429 | |
until a person deletes them there. |
| 430 | |
|
| 431 | |
It tries what a stranger must not do through all the doors a stranger has: |
| 432 | |
|
| 433 | |
- **HTTP API and web form** (`src/release/outsider.ts`): an admin token, a |
| 434 | |
repository or app of their own, any read of the owner's private |
| 435 | |
repositories, and assigning a crew member on a real issue — the JSON |
| 436 | |
`PUT …/issues/:n/assignee` with a token, and the form `POST |
| 437 | |
/:owner/:name/issues/:n/assignee` with the cookie session a browser would |
| 438 | |
hold. A refusal is positively seen: a 403 with its error code, a 404 only for |
| 439 | |
private repositories, or a redirect whose landing page says so. The issue is |
| 440 | |
read back and its assignee and the repository's runs must not have moved, |
| 441 | |
and the owner's own call to the assignee route must answer 422 so a rename |
| 442 | |
turns the suite red. |
| 443 | |
- **MCP** (same module): `tell`, `make_it_yours`, `signup`, and reads of |
| 444 | |
private repositories through `repo`, `usage` and `apps`, each required to |
| 445 | |
answer as an error; `tools/list` must still name them. |
| 446 | |
- **Terminal** (`test/helpers/outsider-tty.ts`, run by |
| 447 | |
`test/outsider-tty.test.ts`): the stranger drives the terminal in process, |
| 448 | |
asks for a new repository, searches for a private one and opens the assign |
| 449 | |
picker on a public issue; the screen must show the refusal and nothing may |
| 450 | |
change. The terminal is a client of the forge's actions, not of HTTP, so it |
| 451 | |
cannot be driven against a live forge: it runs in the gate only. |
| 452 | |
|
| 453 | |
`test/outsider-suite.test.ts` and `test/outsider-tty.test.ts` run in the gate |
| 454 | |
and each prove the suite goes red with a door left open. |
| 455 | |
`FORGE_TOKEN=… bun scripts/smoke.ts <url> --outsider` runs the HTTP and MCP |
| 456 | |
doors against a live forge. **It makes a real account on that forge**, so |
| 457 | |
point it only where that is wanted. Its result is valid for reopening signup |
| 458 | |
only together with a green gate run of the terminal test; HTTP and MCP green |
| 459 | |
alone do not cover the terminal door. Without `FORGE_TOKEN`, `--outsider` |
| 460 | |
exits 2 and the suite reports `owner token` as not tried, so a partial run is |
| 461 | |
never read as a pass. Spend and run bounds are proven in process only; this |
| 462 | |
pull adds the proof but does not reopen signup — the gitops gate stays for a |
| 463 | |
person. |
| 464 | |
|
| 465 | |
`bun scripts/smoke.ts <url> --standards` prints the website-specification |
| 466 | |
report (`src/release/webspec.ts`, `webspec-site.ts`): it fetches each path in |
| 467 | |
forge.json's `app.looks` from `<url>` and prints one line per finding with the |
| 468 | |
specification.website section it cites. Head and header findings come per |
| 469 | |
path. Once per site it also checks the well-known files (robots.txt and the |
| 470 | |
sitemap it names, with each listed same-origin URL, at most 50, fetched |
| 471 | |
anonymously; `/.well-known/security.txt` with a future `Expires:`; the web |
| 472 | |
manifest with a name and icons; `/llms.txt`; `/.well-known/change-password` |
| 473 | |
redirecting) and the error pages (a random path must answer 404 with the app's |
| 474 | |
own HTML page). `--error-path <path>` names a path that is meant to answer 500 |
| 475 | |
with the custom page; the 500 check runs only when it is named, because the |
| 476 | |
report never causes an error on its own. It only reports and always exits 0. |
| 477 | |
`(edge)` marks a finding the edge in front of the app may own (HSTS), so fix it |
| 478 | |
there rather than in the app. Each fetch gives up after 10 seconds. |
| 479 | |
|
| 480 | |
`--standards` also runs axe-core (`src/release/axe.ts`) over each path in light |
| 481 | |
and dark and loads each with JavaScript off, reporting an empty `<main>` or a |
| 482 | |
missing `<h1>`; it needs `chromium` on `PATH` or `FORGE_CHROMIUM` (without one |
| 483 | |
it falls back to a regex read of the served HTML). axe-core is a devDependency |
| 484 | |
of this check only, never loaded by the server: its rules need a rendered page |
| 485 | |
and an accessibility tree, which nothing dependency-free gives. |
| 486 | |
|
| 487 | |
What you asked for: `Ctrl+K`, then **Asked**, is `/asked` in the terminal — your |
| 488 | |
thoughts, then the issues you can write to that are not shipped, oldest first |
| 489 | |
(`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read |
| 490 | |
only, shows a guest nothing, and names nothing of a placement in a repository |
| 491 | |
you cannot read. |
| 492 | |
|
| 493 | |
Review: `/review` lists the asks waiting on you, one row each (title, repository, |
| 494 | |
age, why, and the crew's advice or that it has not advised yet), newest first. A pull's row also carries the check verdict for its head (the pull page's badge: passed, failed, running, cancelled, or none) in the row's trail, and, when its repository is an app, an "Open the preview" link under it (shown for any app, even before a preview is deployed); and, when the pull has a look the viewer may fetch, its preview thumbnail beside production's under that (sized, lazy, never inside the row's link; the pull's is the only link, named for a screen reader, and production's is a named picture; a viewer who may not fetch it sees none); all of it comes from the database, never git. The row's trail also says the pull's size (`3 files changed, +40 −7`), kept per base and head commit (`src/web/studio/review_evidence.ts`); a request never waits on git: until the stat is kept the row says "Diff loading — reload for the size" while the app computes it in the background (at most 4 at once, stopped by closing the app), and a failed stat says "Diff size unavailable" and is not asked again for a minute, so a bad pull cannot spawn git on every visit. Visits while a stat is running share it. Each row also carries its actions under it, as plain form POSTs to `/review/act` (same-origin checked, `rw` scope): on a pull **Merge** (through the same landing gate as anywhere, so a red or unreviewed head is refused with the gate's sentence), **Close**, **Request changes** (with a text box) and **Reply** (with a text box); on an issue **Close** and **Reply**. After an action the page answers 303 to `/review#<the next ask>` (the first that followed it and still waits, else the first left, else `/review`), and the acted ask is gone; a refusal comes back to the same row with its reason. Keys: `j`/`k` move between rows, and on the focused row `m` merges, `c` closes and `r` jumps to the Request-changes box; keys are ignored while a text box has focus, and `?` lists them. `/asks` links to it, and signed out it sends you to sign-in. The terminal has no page of its |
| 495 | |
own for it: the same asks are the dashboard's On you card (acting from the terminal is tracked in #5189). The terminal has no page of its |
| 496 | |
own for it: the same asks are the dashboard's On you card. |
| 497 | |
|
| 498 | |
A client's forge: `Ctrl+K`, then **New forge** (any signed-in person's, as the |
| 499 | |
page is), is `/forges/new` in the terminal — name, for whom, what it is for, |
| 500 | |
apps to fork (`owner/name`, comma-separated), a fresh one-line app, model key |
| 501 | |
(hidden as typed), API base URL, budget, how it follows the code and a size (`small` by default, not sent; `medium` or `large` is; left, right or space choose), through the same `spawnDoor` as the |
| 502 | |
page, `POST /api/v1/forges` and the MCP `spawn`. `Ctrl+P` shows the plan and |
| 503 | |
makes nothing (it shows a Size line when one is chosen); `enter` on the plan spawns, and the claim link is shown once, in |
| 504 | |
a dialog, never kept. |
| 505 | |
|
| 506 | |
The family across forges: `:` → Family in the terminal, `/family` on the web, |
| 507 | |
`GET /api/v1/family` and the MCP `family` tool all give the same view. The |
| 508 | |
forge that sent this one is shown to anyone; the forges it sent, only to a |
| 509 | |
site admin (docs/SPAWN.md, "The family across forges"). Each sent forge also |
| 510 | |
says how many of its apps run and names those that do not ("1 of 2 apps |
| 511 | |
running — bakery asleep"). |
| 512 | |
|
| 513 | |
Passkeys: Settings → Passkeys lists, adds and removes them (several per |
| 514 | |
account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation). |
| 515 | |
The sign-in page's "Sign in with a passkey" gives the same HttpOnly session a |
| 516 | |
password does. Lost your device? "Lost your device?" on the sign-in page leads |
| 517 | |
to `/recover`: give the email on your account and, if it is verified, a link |
| 518 | |
that lasts 30 minutes and works once registers a new passkey and signs you in. |
| 519 | |
The relying party is the address the browser used (or |
| 520 | |
`FORGE_URL`), so a passkey does not follow a forge to another hostname. |
| 521 | |
Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) takes the same: a username and a password with the email optional, or a passkey (which needs an email; challenges from `POST /claim/options`), and a claim with neither a long enough password nor a passkey is refused without spending the link. An admin's everyday account (`web/pages/everyday.ts`) still makes its account with a passkey only (`web/passkey-ceremony.ts`, challenges from `POST /settings/everyday/options`): it reads no password, and without JavaScript the page says a passkey needs it. |
| 522 | |
|
| 523 | |
Password checks are bounded (`src/core/hash-gate.ts`): at most 4 argon2 verifications run at once, and a password over 1024 bytes is never hashed and cannot be set (sign-up, claim and change-password refuse it with "Use 1024 bytes or fewer."). A check over the cap is refused at once, not queued: the caller sees the same `throttled` answer as a full sign-in window (429 with `Retry-After: 1` on HTTP Basic, the API and the sign-in page, "try again" in the terminal; an over-long Basic password is a plain 401), and the attempt is given back to the sign-in windows. The 4-hash cap is a deliberate trade-off: an attacker who keeps 4 hashes busy can make real sign-ins get "try again in 1s"; the per-source sign-in window is what limits how long they can. |
| 524 | |
|
| 525 | |
A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned |
| 526 | |
forge's `parent.json`) lists the parent under "Link an account you have" on `/signin`. The forge |
| 527 | |
registers itself once at the parent's `/oauth/register` (the client id is kept |
| 528 | |
in `rp_client`), sends the person through the parent's authorize page with |
| 529 | |
PKCE, and on return checks state (also bound to the browser by an HttpOnly |
| 530 | |
cookie), nonce, `iss`, `aud`, `exp` and the ES256 signature against the |
| 531 | |
parent's JWKS (`kernel/jwt.ts`; keys are cached ten minutes and an unknown |
| 532 | |
`kid` refetches them once a minute at most). The parent's `sub` — never the |
| 533 | |
username — maps to a local account that is already linked to it; a `sub` nobody |
| 534 | |
holds makes no user and ends on `/signup` (#2883) |
| 535 | |
(`core/federation.ts`; `http/rp.ts` has the two routes). No dependency added. |
| 536 | |
The parent's answers are read by bytes and refused past 16 KiB (64 KiB for the |
| 537 | |
key set), and starting a sign-in is throttled like a password attempt. |
| 538 | |
An account made this way has no password, so the terminal's username-and-password |
| 539 | |
door (`s`) cannot let that person in. They add an SSH key under Settings → Keys |
| 540 | |
once signed in on the web; after that the terminal signs them straight in as |
| 541 | |
that account, as for anyone with a key. |
| 542 | |
|
| 543 | |
Your SSH key is your identity. A key the forge knows signs you straight in; any |
| 544 | |
other key browses public repos as a guest and can press `s` to sign in with a |
| 545 | |
username and password — which links that key to your account, so the next |
| 546 | |
connection is instant. `j`/`k` to move, `enter` to open, `esc` to go back, `/` |
| 547 | |
to filter, `?` for the keys, `:` for the command palette, `t` for the theme. |
| 548 | |
|
| 549 | |
**Milestones.** `:` → *Milestones in a repository* → `owner/name` lists a |
| 550 | |
repository's limbs in the order the crew takes ready work (`enter` opens a |
| 551 | |
limb's own limbs). A person who administers the repository presses `r` to |
| 552 | |
Raise or `l` to Lower the limb under the cursor: a preview of the new order |
| 553 | |
and of the top of the ready list first, then `enter` to apply (`esc` backs |
| 554 | |
out), then `u` to undo the last move. `p` hands the limb to the planner to |
| 555 | |
re-plan as fewer, larger pieces: it lists what the planner will be given, then |
| 556 | |
`enter` confirms; the row reads *with the planner*, and `p` takes it back |
| 557 | |
until the planner starts. It is the same fleet operation the web |
| 558 | |
milestones page uses, and a move is heard by that page when it is open. |
| 559 | |
Anyone else sees the order and no keys to change it; a guest sees only what |
| 560 | |
the web shows them. Whoever may see owner facts also gets two lines under |
| 561 | |
the list for the limb under the cursor: **Cost and landed** (dollars spent, |
| 562 | |
changes landed) and its costliest three issues; a reader or guest sees none. |
| 563 | |
The web page has the same *Cost and landed* section on a milestone, and the |
| 564 | |
milestones API and MCP carry it as `spend` (`usd`, `landed`, `costliest`), |
| 565 | |
owner only. |
| 566 | |
|
| 567 | |
The milestones page's `Server-Timing` names what a plan cost when it had to be |
| 568 | |
built: `plan-graph`, `plan-crew`, `plan-week`, `plan-spend` and `plan-limbs`. A |
| 569 | |
plan kept from an earlier request builds nothing, so those names appear on the |
| 570 | |
request that built it and not on the ones it served; `plan` is the whole phase, |
| 571 | |
which on a kept plan is the one stamp read that says nothing changed. That stamp |
| 572 | |
is itself kept while the forge's write clock has not moved. |
| 573 | |
|
| 574 | |
Working on the UI itself? `bun run src/tui/main.ts --user alice` runs it over |
| 575 | |
stdio against your local data directory. That `--user` flag is a development |
| 576 | |
convenience and exists only there: over SSH, the key decides who you are and |
| 577 | |
nothing else can. |
| 578 | |
|
| 579 | |
Configuration is environment variables, all with defaults: `FORGE_DATA` |
| 580 | |
(`data/`; the forge refuses to boot when it is the checkout itself or a path |
| 581 | |
inside it that git does not ignore), `PORT` (`3000`), `FORGE_SSH_PORT` (`2222`; `0` turns SSH off), and |
| 582 | |
`FORGE_HOST` (`localhost`, the hostname in clone URLs). `FORGE_DRAIN_LINGER_S` |
| 583 | |
(`10`, 0 to 120) is how long a forge sent SIGTERM keeps answering while |
| 584 | |
routers learn it is going, so a roll refuses nobody (`docs/ops/ROLL.md`). |
| 585 | |
Connecting origins |
| 586 | |
adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks), |
| 587 | |
`FORGE_GITHUB_CLIENT_ID` / `FORGE_GITHUB_CLIENT_SECRET`, and |
| 588 | |
`FORGE_ORIGIN_ALLOW` (hosts an operator vouches for on a LAN). Behind a |
| 589 | |
platform edge that signs people in for every app it serves, `FORGE_EDGE_TOKEN` |
| 590 | |
is the edge's secret: a request carrying it and `X-Plat-User` is that person, |
| 591 | |
signed in by the platform if an account of that name exists. An unknown login |
| 592 | |
makes none: it is sent to `/signup`, where the `signup` setting decides, and |
| 593 | |
the name there is pinned to the login the edge vouched for (a visitor the edge |
| 594 | |
has not vouched for is sent through it first), so a platform user's name can |
| 595 | |
never be taken by someone else. The terminal cannot see `X-Plat-User`, so |
| 596 | |
behind an edge sign-up is on the web page only and the terminal says so. Setting |
| 597 | |
`signin.edge` to `off` (Settings → Capacity → "Sign in through the platform |
| 598 | |
edge", boot default `FORGE_SIGNIN_EDGE`, also `/api/v1/admin/capacity` and the |
| 599 | |
MCP `capacity` tool) makes the forge ignore `X-Plat-User` even when the secret |
| 600 | |
matches; people then sign in with a password or a passkey. An admin with |
| 601 | |
neither is refused, so no one locks themselves out. Accounts the edge made |
| 602 | |
cannot sign in while it is off until they add a passkey (a password-less |
| 603 | |
account cannot set a password: `/settings/password` asks for the current one). |
| 604 | |
Account settings show "open-platform.sh: linked as <login>" or "not linked": Link |
| 605 | |
records the login the request carries against the signed-in account, Unlink removes |
| 606 | |
it (same-origin POSTs). An edge sign-in finds the account by its link first, then by |
| 607 | |
name; one login links to one account, and a login that is another account's name |
| 608 | |
cannot be linked. The terminal's "What I can do" screen shows the same line and `u` unlinks (SSH carries no edge header, so linking is on the web page only). |
| 609 | |
On a forge with a parent, settings also show "<parent host>: linked" or "not linked". Link is a same-origin POST that runs the `/signin/parent` PKCE round trip for the signed-in account and, on return, writes its `federated_identity` row (never an account); an identity another account holds, or a second identity for this account, is refused. Unlink removes it. The terminal's "What I can do" shows the line and `p` unlinks; linking needs a browser. |
| 610 | |
Mail out |
| 611 | |
(the link that proves a newcomer's email) is SMTP submission over TLS, |
| 612 | |
configured by the forge-level secrets `MAIL_SMTP_URL` |
| 613 | |
(`smtps://user:pass@host:465`) and `MAIL_FROM`, set live under Settings → |
| 614 | |
Secrets, or at boot by `FORGE_SMTP_URL` / `FORGE_MAIL_FROM`. With neither, no |
| 615 | |
mail is sent and a newcomer waits for the owner to verify them. With |
| 616 | |
`FORGE_TURNSTILE_SECRET` and `FORGE_TURNSTILE_SITEKEY` set (the forge refuses to |
| 617 | |
boot with only the secret), `/signup` renders the Turnstile widget, its CSP |
| 618 | |
allows `https://challenges.cloudflare.com`, and signup asks Cloudflare's |
| 619 | |
siteverify about the posted `cf-turnstile-response` (API: `turnstile_token`), |
| 620 | |
refusing a missing or bad token before it hashes a password or makes an |
| 621 | |
account. Unset, signup is open. The terminal (SSH) cannot show a widget, so |
| 622 | |
with Turnstile on it creates no account, the forge's first neither; everyone |
| 623 | |
is told to sign up on the web page. Accounts made through the platform edge are not checked. Behind a |
| 624 | |
proxy, `FORGE_TRUSTED_PROXIES` (comma-separated CIDR ranges, e.g. |
| 625 | |
`10.42.0.0/16` for a k3s ingress) names the proxies allowed to say who the |
| 626 | |
client is; limits for strangers and the signup audit then see each outside |
| 627 | |
client, read from `X-Forwarded-For` right to left past the trusted hops, or |
| 628 | |
from `FORGE_CLIENT_IP_HEADER` when the edge sets one (`cf-connecting-ip` |
| 629 | |
behind a Cloudflare tunnel, where the ingress rewrites `X-Forwarded-For`; |
| 630 | |
read only from a listed proxy). Once set, only the listed ranges are proxies: |
| 631 | |
this machine is not one unless listed, since every app runs beside the forge. |
| 632 | |
Unset, only this machine may say so, and the named header is never read. Everything a forge |
| 633 | |
instance _is_ lives under the data directory — the SQLite index, the bare |
| 634 | |
repos, and the SSH host key, in plain layout. Tar it up and it moves. |
| 635 | |
|
| 636 | |
`/forges/new`, `POST /api/v1/forges` and the MCP `spawn` make a client's forge from forks of the apps you pick, plus optionally one fresh app from a one-line description (`fresh`, at most 200 characters), and end in one claim link; see `docs/SPAWN.md`. |
| 637 | |
|
| 638 | |
`FORGE_PARENT_URL` names the forge this one was spawned from. It must be |
| 639 | |
`https://` with no credentials or query (the forge refuses to boot otherwise). |
| 640 | |
The parent serves `/releases.json` as `{"releases":[{"name","url"}]}`, newest |
| 641 | |
first; the child reads it with a 3 s timeout, no redirects and a 256 KB cap, |
| 642 | |
caches it under `FORGE_APP_DATA` for 10 minutes, and says "N releases behind |
| 643 | |
<parent>" on a fork's following card, in the terminal's About card (`I`), and as `parentForge` |
| 644 | |
(`{host, behind, releases, fetchedAt, down}`, null when unset) in |
| 645 | |
`GET /api/v1/repos/:owner/:name/following` and the MCP `following` tool. A |
| 646 | |
page never waits for the parent: it shows the cached copy and refreshes in the |
| 647 | |
background, and a parent that is down or answering garbage leaves a "could not |
| 648 | |
be reached" with the age of the last copy. Unset, nothing is shown. Read only: |
| 649 | |
taking a release is not done here. This forge publishes its own |
| 650 | |
`/releases.json` (the version names, each linking to that document), so any |
| 651 | |
forge can be a parent. The terminal shows the count, not the release names. |
| 652 | |
|
| 653 | |
A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen |
| 654 | |
`followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check puts what the parent gained in a pull. |
| 655 | |
A spawn may also name a `size`: `small`, `medium` or `large` (`src/spawn/sizes.ts` maps each to CPU, memory and disk). It is recorded in the plan only when given, so a plan without one hashes as it always did, and an unknown size is a 422 on `size`. The New forge page and the terminal form both offer it, small preselected and not sent; the page shows a refusal beside the select, linked with `aria-describedby`. Nothing enforces the limits yet, and the table's numbers are provisional. `GET /api/v1/forges/sizes` (no sign-in, ETag, cached for 5 minutes) and the MCP tool `sizes` give the same answer — each size's CPU, memory and disk, read from that table, and one sentence on what it is for — so a page or an agent offering a size reads it there. |
| 656 | |
Once an hour, and |
| 657 | |
on demand, a forge that follows and has not severed fetches its parent's code into |
| 658 | |
`refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps |
| 659 | |
`{ behind, parentTip, checkedAt, error }` in `upstream.json` under `FORGE_APP_DATA`; |
| 660 | |
the count shows on the chain page, in the terminal's **Chain** (`c` checks now), and as |
| 661 | |
`upstream` in the API and MCP payloads. `POST /:owner/forge/following/check` (a writer, |
| 662 | |
same-origin) checks on demand; a fetch is cut at 60 s and a failure is recorded, not |
| 663 | |
thrown. In `take-updates` the check then puts `refs/upstream/HEAD` (a fast-forward, or a clean merge commit) on the |
| 664 | |
fork's `forge/take-updates` branch and opens one pull, "Take updates from <parent>", into main, or updates |
| 665 | |
that pull when it is open (`spawn/take.ts`); the fork's check runs on it like on any pull and a person merges it. |
| 666 | |
Main is never moved, so nothing deploys by itself. It is recorded as `taken` in `upstream.json` (the chain line |
| 667 | |
reads "N commits from your parent are in pull #P") and as a `forge.updates_taken` event; a pull closed unmerged |
| 668 | |
is not reopened until the parent moves again. `forge/take-updates` belongs to the check: a commit a person pushes there is overwritten by the next check that finds something different to put on it (open a branch of your own to build on it). On a conflict nothing changes and one open issue, |
| 669 | |
"Take updates from <parent>", names the files. In `tell-me`, a new parent tip makes one inbox card for the owner (an issue on the fork assigned to them, "Your parent forge has N new commits", linking the chain page); `toldTip` in `upstream.json` keeps it to one per tip, and `take-updates` or a severed link makes none. Its chain |
| 670 | |
to the root is at `/:owner/forge/following`, in `chain` of `GET |
| 671 | |
/api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the |
| 672 | |
terminal's **Chain** command; the owner severs it with the page's Sever, |
| 673 | |
`POST …/following/sever` or the MCP `sever` tool (nothing is deleted). Taking |
| 674 | |
updates into a conflict opens the converge issue at every door. See |
| 675 | |
`docs/SPAWN.md` and `docs/FOLLOWING.md`. |
| 676 | |
|
| 677 | |
## Who can see it |
| 678 | |
|
| 679 | |
Every repository page says, beside the Private/Public badge, who can see it in |
| 680 | |
one sentence ("Private: only you and the crew."), and a writer's click goes to |
| 681 | |
Settings. `GET /api/v1/repos/:owner/:name` returns the same words as |
| 682 | |
`audience: { level, sentence }`, the MCP `repo` tool carries it too, and the |
| 683 | |
terminal's About card (`I`) shows it. It gives counts, never names; site |
| 684 | |
admins are not counted. All of them read `audienceFor` in `src/core/audience.ts`. |
| 685 | |
|
| 686 | |
## Archive |
| 687 | |
|
| 688 | |
An admin puts a repository away from **Settings → Archive**, with |
| 689 | |
`PATCH /api/v1/repos/:owner/:name {"archived":true|false}` (admins only; anyone |
| 690 | |
else gets 403), or in the terminal with `a` on the About card (`I`). The |
| 691 | |
repository page and the card then say when it was archived, and an admin gets |
| 692 | |
a Wake button or the same key to undo it. Archiving twice keeps the first date. |
| 693 | |
An archived repository starts no new crew run. Assigning an issue to an agent, |
| 694 | |
queueing a run again, asking the crew to propose a resolution, redirecting a |
| 695 | |
wave and converging a fork each answer "This repository is archived; wake it to |
| 696 | |
start a run."; the API answers 409 `archived`, and the terminal and MCP show |
| 697 | |
the same line. The autonomy tick skips it. A run already going finishes, and |
| 698 | |
Wake undoes all of it. Clone, push and links keep working, and the repository |
| 699 | |
page still answers. |
| 700 | |
|
| 701 | |
An archived repository leaves the web home's apps, the inbox (work assigned to |
| 702 | |
you there included, and the terminal's and MCP's asks), the stalled-steps |
| 703 | |
reader and the explorer's default view; `/explore` shows it with `archived:yes` |
| 704 | |
(the **Show archived** toggle). It stays in `/apps`, search, the palette and |
| 705 | |
the repository API (`GET /api/v1/repos`). |
| 706 | |
|
| 707 | |
`/apps` lists live (unarchived) apps only, with an "N archived" line linking to |
| 708 | |
`/apps?archived=1`, which lists just the archived ones (no All/Live filters, one |
| 709 | |
"Back to apps" link); each view reads its own window of 200 repositories. In |
| 710 | |
the SSH terminal the dashboard's *Yours* leaves archived repositories out too; |
| 711 | |
type in its filter (`/`) to reach them, as the filter searches every |
| 712 | |
repository you can see. |
| 713 | |
|
| 714 | |
A site admin archives many at once from the home. In the **Your apps** card, |
| 715 | |
**Select** shows a checkbox per app (they take no room until then); tick some |
| 716 | |
and press **Archive**. This is the fleet's `archive` operation over those |
| 717 | |
repositories (`POST /home/archive`, same-origin, a site admin's), so it has the |
| 718 | |
fleet's receipt: the page that answers lists what was archived and an **Undo** |
| 719 | |
button for the person who ran it (kept for a day), after which the apps are back |
| 720 | |
on the home. The terminal's dashboard archives and undoes the same way (below), and so does the MCP `archive` tool given `repos` (at most 50; it answers the `operation` id, which `limb` with `action: "undo"` reverts for a day); each door refreshes an open web home (the `fleet.operated` event). |
| 721 | |
|
| 722 | |
## Where it came from |
| 723 | |
|
| 724 | |
A repository brought here with **Migrate** (`/migrate`) keeps where it came |
| 725 | |
from as links, never as a connection: the source's address, and — when its host |
| 726 | |
says it is a fork — what it forks (GitHub's `parent` and `source`, Forgejo's and |
| 727 | |
Gitea's `parent`; a pasted address's host is asked once, anonymously, after the |
| 728 | |
clone). The repository header then reads "Migrated from |
| 729 | |
github.com/Trevato/SO-ARM100 · fork of TheRobotStudio/SO-ARM100", each part a |
| 730 | |
link up. A fork made here of a migrated repository inherits its parent's |
| 731 | |
trail and reads "forked from … · migrated from … · fork of …" — at every door |
| 732 | |
below, for anyone who may read the parent; only the parent's admin can hide |
| 733 | |
that inherited part, and a fork's own record, if it has one, wins. The |
| 734 | |
terminal's About card (`I`) shows the same |
| 735 | |
as `migrated from` and `fork of` rows, `GET /api/v1/repos/:owner/:name` as |
| 736 | |
`provenance` `{ source, sourceUrl, parents: [{ fullName, url }], migratedAt }`, |
| 737 | |
and the MCP `repo` tool the same field. |
| 738 | |
|
| 739 | |
An admin turns it off with **Settings → Show where this came from**, |
| 740 | |
`PATCH /api/v1/repos/:owner/:name {"showProvenance":false}`, `t` on the About |
| 741 | |
card, or the MCP `trail` tool (dry by default, admin scope). Off hides the line |
| 742 | |
on every page, in the terminal, and `provenance` is `null` in the API and MCP; |
| 743 | |
the record is kept, so on brings it back as it was. A source that was private |
| 744 | |
on its connected origin starts off, whatever it is migrated here as: its name |
| 745 | |
and owner on the other host are not published until an admin turns the line |
| 746 | |
on (a later re-record never overrides that choice). It is a person's call: |
| 747 | |
anyone else, an agent included, is refused (the API answers 404 before any |
| 748 | |
other field in the same request is saved). Repositories migrated from a pasted |
| 749 | |
address before this existed were filled in from their migration's `push` |
| 750 | |
event, without a fork chain; one picked from a connected origin then has none. |
| 751 | |
|
| 752 | |
## Big directories |
| 753 | |
|
| 754 | |
A directory page lists its first 300 entries, directories first; a "Show all N entries" row links to `?all=1`, and the footer says "Showing 300 of N entries". The side Files column keeps 100 rows per directory (its lazy `?fragment=explorer` too) with the same link. The terminal's file explorer is a scrolling, lazily loaded tree rather than a page, so it is unchanged. The limits are `TREE_WINDOW` and `COLUMN_WINDOW` in `src/web/pages/explorer.ts`. |
| 755 | |
|
| 756 | |
## Search |
| 757 | |
|
| 758 | |
`/search` answers repositories and issues in full, and greps code repository by repository. A first search starts no new `git grep` after 30 ms (`codeBudgetMs` in the forge config; a soft stop, since the grep in flight still finishes, one batch of four) and shows what it has with one link, **More code results** (`?code=all`), that greps the rest. The API and the terminal palette always grep everything. |
| 759 | |
|
| 760 | |
## Bug hunters |
| 761 | |
|
| 762 | |
`bun scripts/hunt.ts <origin>` runs the hunters' detectors against one running app (GET only, same-origin, one pass) and prints findings as JSON; it exits 1 when there are any. Lanes and what is not built yet: `docs/HUNT.md`. |
| 763 | |
|
| 764 | |
## Health |
| 765 | |
|
| 766 | |
`GET /api/v1/health` (and the MCP `health` tool) answer a site admin with |
| 767 | |
`degraded`: one entry per thing that needs a person, `{cause, sentence, |
| 768 | |
since, href}`. `cause` is a stable key (`disk`, `errors`, `internal`, |
| 769 | |
`queue:<kind>`, `stuck:<runId>`, `budget:<runId>`); `since` is when it began, by the oldest |
| 770 | |
evidence (oldest queued run, last heartbeat, oldest error or refused call in |
| 771 | |
the hour) or else the first time the forge saw it, and holds until the cause |
| 772 | |
clears; `href` is the page to look at. The board's forge strip links each |
| 773 | |
entry, and the terminal dashboard prints each with its start. When the guard |
| 774 | |
view holds something that needs a person (a held or stuck rollout, an OOM kill, |
| 775 | |
or a platform view gone stale), the board's strip and the terminal dashboard |
| 776 | |
both lead with one line, `needs you: forge <what>`, and do not repeat it as a |
| 777 | |
rollout line. A drain that runs too long is not on either; it is a push, not a |
| 778 | |
door: an admin with a notify URL is paged once when a drain passes five |
| 779 | |
minutes and once, by the draining process as it exits, when it ends. A process |
| 780 | |
that dies mid-drain says nothing more. |
| 781 | |
|
| 782 | |
The same push pages the vitals causes `errors`, `internal`, `queue:*` and `disk` |
| 783 | |
once each has held five minutes, and says Recovered once it has been gone five |
| 784 | |
(a flap in between is no news). A cause present at boot is taken as already |
| 785 | |
paged. Every other cause (`drill`, `mirror`, `stuck:*`, `red:*`, `snapshots`, |
| 786 | |
`budget:*`) is paged elsewhere or is a setting, and is ignored here. |
| 787 | |
|
| 788 | |
## Origins |
| 789 | |
|
| 790 | |
Outbound git refuses a host that resolves to a private address. A site admin can vouch for exact host names (the owner's own Forgejo, reached by its in-cluster address): `FORGE_OUTBOUND_TRUSTED_HOSTS=git.example.test` (comma or space separated), plus the same list editable by an admin on **Settings → Origins**. A trusted host may resolve privately for origin sync, mirror, migrate and write-through; trust is by name, never by address range, and webhooks never read it. The origin row says "trusted internal host". In the terminal, the palette's **Trusted hosts** reads and saves the same list (admins only, same audit). An entry that is not a bare host name (an address, a wildcard, a URL) is refused with its name, on the page and in the terminal, never silently dropped. |
| 791 | |
|
| 792 | |
Your forge is the index of everything you work on, wherever it actually lives. |
| 793 | |
An **origin** is a connected account on another forge — GitHub, Forgejo, Gitea. |
| 794 | |
Mirror a repository in and its issues, pulls, comments and labels come with the |
| 795 | |
git objects; anything you then do here is **written through** to the origin |
| 796 | |
first and reflected locally after it succeeds. For a mirror the origin is |
| 797 | |
truth, exactly as git is truth for a native repo. |
| 798 | |
|
| 799 | |
**Resolving a conflicted pull.** When a pull conflicts with its base, a writer sees **Resolve** in the "Can't merge" box, which opens `/:owner/:name/pulls/:n/resolve`: one file at a time, each conflict block a card with the two sides named by their branch and the taps **Ours**, **Theirs**, **Both** and **Mine** (words typed in the box count as Mine), a running "2 of 3 resolved", and the merged file beneath. The choices ride in the query string until you press **Finish**, which commits them: the resolved file becomes a merge commit on the pull's branch (as you, the base as second parent, built with git plumbing — no clone, no agent), and the pull re-checks as mergeable. Finish is for writers with `rw` scope and refuses, writing nothing, when the branch or base moved since the page was drawn, when a block has no choice, when the pull has more than one conflicted file or a conflict with no text blocks, and for cross-repo or mirrored pulls. In the terminal, `v` on a conflicted pull does the same: `n`/`p` move over the blocks, `o`/`t`/`b` take ours, theirs or both, `e` opens a box for your own text, each file shows "N of M resolved" with its merged file beneath, and `f` finishes with the same refusals in words. On a conflicted pull held by an agent, the same page (and the same view in the terminal) also offers **Have the crew propose** to writers with `rw` scope: it queues one `resolve` run for that agent, on the pull's own branch, with a brief carrying both sides' commits, the conflict blocks and the tests that cover them. The agent merges the base in, commits the resolution and pushes; its report becomes the note on the pull. Until the run settles the card says it is working and refreshes itself on the run's events (in the terminal, `r` looks again and never asks), and a run that fails says why. When it lands the card shows the note, the commit the run left (kept on the run, so a later push is never called the crew's) and the files to edit by hand; once the head moves on, the card goes. The tap is refused, writing nothing, for a reader, a closed pull, a pull with no conflicts, a pull no agent holds, one the crew is already working on, and when the crew's ceiling or breaker has no room. In the terminal `c` in the conflict view is the only key that asks, with the same refusals in the same words. See docs/PULLS.md. Readers and guests get neither. |
| 800 | |
|
| 801 | |
A pull from an outsider's fork on the origin is someone else's code, so its |
| 802 | |
check and preview wait. A person with write access lets that exact head run: |
| 803 | |
on the pull page, **Approve to run here**; in the terminal, `A` on the pull |
| 804 | |
("approve to run here"); over the API, `POST |
| 805 | |
/api/v1/repos/:o/:n/pulls/:n/approve-here`. It is a local record — a review |
| 806 | |
written through to, or imported from, the origin never counts — and a new push |
| 807 | |
waits for a new approval. |
| 808 | |
|
| 809 | |
A pull a child forge proposed upstream waits the same way, and the writer who |
| 810 | |
minted the child's token may let it run: **Let it run here** on the pull page, |
| 811 | |
`A` in the terminal, or the same `POST …/approve-here`, which answers |
| 812 | |
`{granted: true, commitOid}`. That grant is recorded apart from reviews: it |
| 813 | |
never counts toward `require_review` or the merge gate, a new push needs a new |
| 814 | |
grant, and a proposed pull never lands on its own, even on an autonomous repo. |
| 815 | |
|
| 816 | |
Connect one at **Settings → Origins** (or `A` then `o` in the terminal). Which |
| 817 | |
way in you get is a property of the forge, not a preference: |
| 818 | |
|
| 819 | |
- **GitHub** — a device code. The forge shows a short code and a URL; you type |
| 820 | |
the code on whatever device has a browser, and the forge does the polling. |
| 821 | |
This is the flow that works over SSH with no browser on the server. Setting |
| 822 | |
it up is three steps, once, for everyone on the forge: |
| 823 | |
1. Create an OAuth App at <https://github.com/settings/developers>. Name it |
| 824 | |
anything, set the homepage to your forge's URL, set the authorization |
| 825 | |
callback URL to `https://your.forge/settings/origins/callback`, and tick |
| 826 | |
**Enable Device Flow**. |
| 827 | |
2. Start the forge with `FORGE_GITHUB_CLIENT_ID=<client id>`. |
| 828 | |
`FORGE_GITHUB_CLIENT_SECRET` is optional and buys one thing: |
| 829 | |
disconnecting revokes the grant on GitHub too. |
| 830 | |
3. The GitHub button appears. Until it does, both surfaces print these three |
| 831 | |
steps rather than naming an environment variable and leaving you to it. |
| 832 | |
|
| 833 | |
- **Forgejo / Gitea** — authorization code with PKCE. They implement no device |
| 834 | |
grant, and every instance needs its own OAuth application, so you paste a |
| 835 | |
client id once per host — or the operator registers the host once for |
| 836 | |
everyone, `FORGE_ORIGIN_CLIENT_ID=git.example=<client id>` (comma-separated |
| 837 | |
for more), and Connect is one click. The page gives you the exact redirect |
| 838 | |
URI to enter (`https://your.forge/settings/origins/callback`); a public |
| 839 | |
client is fine. |
| 840 | |
This flow needs the forge to know its own public URL, since that URL is |
| 841 | |
registered with the other forge and a `Host` header is not evidence: set |
| 842 | |
**`FORGE_URL`** to the canonical base URL (`FORGE_HOST` and loopback are the |
| 843 | |
fallbacks it trusts). Until one of those is true the card says so, and the |
| 844 | |
token path still works. |
| 845 | |
- **A pasted token** — the fallback that needs no registration anywhere. It is |
| 846 | |
validated against the origin before it is stored, encrypted at rest, and |
| 847 | |
never displayed again. |
| 848 | |
|
| 849 | |
Both connect screens hand the job to whatever device has a browser: a **QR |
| 850 | |
code** to point a camera at, a **short link** (`https://your.forge/go/<10 |
| 851 | |
characters>`, live for ten minutes and gone once used) short enough to type by |
| 852 | |
hand, and — in the terminal — `c` to put the link on _your_ clipboard through |
| 853 | |
your own terminal (OSC 52, so it works over SSH; iTerm2, Ghostty, kitty and |
| 854 | |
WezTerm honour it, Apple Terminal does not). `o` opens a browser only when the |
| 855 | |
forge's terminal is the one in front of you; over SSH it says so rather than |
| 856 | |
appearing to work. |
| 857 | |
|
| 858 | |
Then pick what you want: **Repositories** on an origin lists everything the |
| 859 | |
token can see, and you mirror them one at a time. There is no "mirror |
| 860 | |
everything" — a mirror is a repository you own, and a hundred you did not |
| 861 | |
choose is not a feature. |
| 862 | |
|
| 863 | |
A mirrored repo wears its origin in the header (`mirrored from owner/name @ |
| 864 | |
host`), shows its last fetch and any fetch error in the About rail, and syncs |
| 865 | |
on a worker tick or on demand. Imported issues and comments show the login on |
| 866 | |
the _origin_, with that forge's mark, and link back to the canonical thread. |
| 867 | |
Merging a pull happens on the origin: the merge box says so and links there, |
| 868 | |
and the next sync brings back the merge commit it actually made. Pushes go |
| 869 | |
through too — the pre-receive hook forwards the push upstream first, so a push |
| 870 | |
the origin refuses never lands here either. |
| 871 | |
|
| 872 | |
Mirror fetches are vetted and pinned: the origin's host is resolved once, refused |
| 873 | |
if it is a loopback, LAN or other private address, and git is pinned to the |
| 874 | |
address that passed. Git follows no redirects for a mirror, so give the clone |
| 875 | |
URL in its final form (https, with the `.git` suffix the host expects); a URL |
| 876 | |
that redirects fails with git's redirect error. |
| 877 | |
|
| 878 | |
Only the person who connected an origin can write through it. Another writer |
| 879 | |
gets `origin_owner_only` rather than a comment appearing upstream under |
| 880 | |
someone else's name with someone else's token. |
| 881 | |
|
| 882 | |
**Pins** are the bridge to the next section: the pin toggle on a repository |
| 883 | |
header (`p` on a row in the terminal, `P` on a repo's home) says "keep this on |
| 884 | |
my machine", and the dashboard grows an **On your machine** card listing them. |
| 885 | |
The server remembers only the choice — nothing about the state of a laptop — |
| 886 | |
and `forge sync` below is what acts on it. |
| 887 | |
|
| 888 | |
**What an ask row waits for.** Every ask row carries one word, `waitingFor` (`src/core/waiting_for.ts`, computed once), and who it waits on, e.g. "ready to land · on you". The first that applies wins: `conflicts with main` (the last sweep found files stopping the pull, on its author), `ready to land` (a green change to look at and ship), `a review` (a review or a hand-over), `assigned to you` (the pull or issue is assigned to the viewer), otherwise `a decision`. It shows as a chip on each row of `/asks`, as `waitingFor: {word, on}` in `GET /api/v1/asks` and the MCP `asks` tool, and in the terminal's On you card. A grouped row shares one word (the weightiest of its items), and the terminal's card shows its row's word. |
| 889 | |
|
| 890 | |
**Waiting.** A pull waits on a person when the crew handed it over, or when no person has acted on it for 24 hours (`WAIT_LIMIT_MS`, `src/core/pull_wait.ts`): a person's opening, comment or review restarts the clock, the crew's own words do not. The row is "Waiting" with how long (`waiting 30 hours`); it is on `/asks`, `GET /api/v1/asks` (`kind: "waiting"`, `why`), MCP `asks`, and in the terminal's On you card (`waiting` before the title). A pull you hold never asks for being idle, and a crew pull still at work or younger than a day stays the crew's. It can be picked, shipped or closed with the other rows (`waiting:<id>`). A crew member can advise by ending a comment with one line, `Recommendation: merge|close|change — why`; the newest such line from a non-person is shown as "The crew advises: merge — why" on the page and the terminal row, and as `recommendation: {verdict, reason}` in the API, on handed-over rows too. |
| 891 | |
|
| 892 | |
**Muting a repository from /asks**: each ask row on `/asks` carries a "Mute owner/name" chip. A muted repository's asks leave `/asks`, `/api/v1/asks` and the phone's pushes (the same per-user mute the On you card uses, no new table); `/asks` says "N muted repositories still ask" with a "Listen again" button, and an app's own page still counts them. The home itself renders no asks, no for-you list and no count: the top bar carries a plain Asks link to `/asks`, and the terminal's dashboard keeps its On you card on purpose (a different door). |
| 893 | |
|
| 894 | |
**A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (`/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks on `/asks` (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card. |
| 895 | |
|
| 896 | |
**Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox, and the MCP `feed` and `asks` tools, which also take `hide_kinds` and `hide_people` to replace the saved filter for that one call (`[]` lifts it). `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back. The `feed_chips` tool reads them (no arguments, any token) and switches one (`kind`, `value`, `hidden`; an `rw` token). |
| 897 | |
|
| 898 | |
**Archive many, in the terminal:** on the home's repositories, an admin of a |
| 899 | |
repository marks it with `space`, `a` previews the marked ones by name and |
| 900 | |
archives them through the fleet `archive` verb (`applyFleetIds`), and `u` undoes |
| 901 | |
it (`undoFleetOp`). A non-admin has neither key. |
| 902 | |
|
| 903 | |
**On you, in the terminal**, answers to the same mutes and one-move verbs as |
| 904 | |
the web card. Repositories you muted are hidden and counted ("2 repositories |
| 905 | |
muted"). On the On you card: `space` marks a row, `x` closes the marked rows |
| 906 | |
(or the row under the cursor) with one reason, `m` mutes their repositories, |
| 907 | |
`S` ships them, `u` undoes the latest set within its day, and `L` listens |
| 908 | |
again to every muted repository. Each goes through `applyAsksBulk`, so the |
| 909 | |
per-row access checks and refusals are the web's, shown in the toast. |
| 910 | |
|
| 911 | |
**What the decider did today.** Today's card on the board carries one line, |
| 912 | |
"The decider closed N, deferred N, asked you about N": each count links to |
| 913 | |
its ledger rows (`/board?ledger=closed|deferred|asked`; any other value is |
| 914 | |
ignored, `?apps=` is kept). A deferral is the decider's own `stop`, never the |
| 915 | |
rule's. The ledger view lists up to 200 rows and says when it left some out. |
| 916 | |
In the terminal the same line sits above the dashboard, `C` (or the palette's |
| 917 | |
Decider) opens the rows, and `1`, `2`, `3` switch pile. Both are for the |
| 918 | |
repositories you own only: a reader sees nothing. |
| 919 | |
|
| 920 | |
**Let the decider** answers a hand-over card (an issue the crew parked) when |
| 921 | |
you would rather not: the third tap on the card, `POST /api/v1/asks/decide` |
| 922 | |
(`{"item": "parked:<id>"}`), the MCP tool `asks_decide`, or `D` on the On you |
| 923 | |
card in the terminal. It sees only the direction text, whether you pinned or |
| 924 | |
muted the app, the day's spend against the ceiling, your last ten decisions |
| 925 | |
there and the card's own evidence; the decision ledger row carries exactly |
| 926 | |
that, with a reason and a confidence. A sure answer (70% or more) to set it |
| 927 | |
aside takes the card off your board; an unsure one, a failure, or no decider |
| 928 | |
configured leaves it with you. Undo for a day (`/api/v1/asks/decide/<id>/undo`, |
| 929 | |
`asks_undecide`, `U`). Each tap is a paid call, so one card is handed over |
| 930 | |
once in ten minutes. |
| 931 | |
|
| 932 | |
**The standing rule** says who answers a repository's hand-overs without a |
| 933 | |
tap, three ways: `me` (the default: they wait for you), `decider` (the sweep |
| 934 | |
asks the decider about each parked card and its word stands, whatever its |
| 935 | |
confidence), or `decider_tell` (the decider's sure set-asides, 70% or more, |
| 936 | |
take the card off your board; the unsure ones stay with you). The tap on the |
| 937 | |
card follows the same rule. A shelved repository, one whose autonomy is `off`, |
| 938 | |
has its parked cards closed by rule with no model call whenever the rule is |
| 939 | |
not `me`, so turning the crew off on a ruled repository closes what it had |
| 940 | |
parked. The sweep spends at most five model calls a tick and never re-asks a |
| 941 | |
card already answered or undone. Only a repository admin who is a person sets |
| 942 | |
it, never an agent. Set it in the repository's settings (Autonomy → Decisions |
| 943 | |
here); over the API with `PATCH /api/v1/repos/<owner>/<name>` |
| 944 | |
`{"decisionsBy": "me" | "decider" | "decider_tell"}` (admin token; it is in the |
| 945 | |
repository JSON too); over MCP with `decisions_by` (`repo`; without `by` it |
| 946 | |
says how the rule is set, with `by` it plans unless `dryRun: false`); in the |
| 947 | |
terminal with `R` in the repository's workspace, which cycles me → decider → |
| 948 | |
decider_tell. |
| 949 | |
|
| 950 | |
**The terminal explorer lists every entry of a directory, on purpose.** The |
| 951 | |
web directory page and its side column are windowed because they send every row |
| 952 | |
as HTML; the workspace's explorer sends nothing. It holds one small node per |
| 953 | |
entry and draws only the rows that fit the screen (`drawTree`, |
| 954 | |
`src/tui/screens/workspace.ts`), so a directory of thousands costs a list in |
| 955 | |
memory, not a page. A window would hide files from a person who is searching |
| 956 | |
the tree with `t` or revealing a path, and it would need a "show the rest" key |
| 957 | |
for no saving. Revisit it if a directory's listing, not its drawing, is ever |
| 958 | |
measured slow. |
| 959 | |
|
| 960 | 269 |
## On your machine |
| 961 | 270 |
|
| 962 | 271 |
The forge holds the truth; `forge` keeps a copy of the parts you work on, the |
|
|
@@ -1006,779 +315,6 @@ something in the 60–110 MB range; `dist/` is gitignored and binaries are never |
| 1006 | 315 |
committed). Until you build it, `bun run src/cli/main.ts …` is the same |
| 1007 | 316 |
program. |
| 1008 | 317 |
|
| 1009 | |
## Apps |
| 1010 | |
|
| 1011 | |
A repository that runs. Press **New app**, give it a name and — if you like — |
| 1012 | |
a sentence about what it should do. Seconds later it is serving at |
| 1013 | |
`http://<name>.<your forge's host>` from a small, real app (notes in a SQLite |
| 1014 | |
file), and if you named an agent, the sentence became issue #1, assigned. |
| 1015 | |
|
| 1016 | |
Under the box, four starter sentences are plain links (no script): |
| 1017 | |
`/apps/new?description=…` fills the box with that text (plain text up to |
| 1018 | |
200 characters, anything else is ignored; always escaped), and |
| 1019 | |
`&owner=` keeps the chosen owner. The list is `src/web/pages/apps-starters.ts`. |
| 1020 | |
|
| 1021 | |
- **Private egress is a site admin's yes.** An app reaches a private |
| 1022 | |
destination (a tailnet or LAN `host:port`) only when `forge.json` `egress` |
| 1023 | |
names it exactly and a site admin has granted it: on the |
| 1024 | |
app's settings page, or in the terminal (About `I`, then `e`, `g` |
| 1025 | |
to grant, `x` to revoke). See `docs/APPS.md`. |
| 1026 | |
- **Egress connections are bounded.** The proxy closes the upstream socket |
| 1027 | |
the moment the cell side closes, errors or stops, and the other way round. |
| 1028 | |
An app holds at most 8 open connections to one destination (the ninth |
| 1029 | |
gets a `429`, logged), and a connection that moves no bytes, either way, for 5 min is |
| 1030 | |
cut, so a tunnel quiet for longer than that (a websocket or SSE stream with |
| 1031 | |
very long gaps) needs a keepalive. Open connections per app and |
| 1032 | |
destination, with the bytes moved, are on the Capacity page |
| 1033 | |
(`/settings/capacity`, admins) and in the terminal: About `I`, then `c` |
| 1034 | |
(site admins). |
| 1035 | |
- **Production is the default branch.** The only way code reaches it is that |
| 1036 | |
branch moving, and an agent cannot move it: the crew proposes through |
| 1037 | |
pulls, a person merges. |
| 1038 | |
- **Every open pull gets a preview** at `<name>--<pull number>.<host>`, |
| 1039 | |
redeployed on each push and gone when the pull closes. Its data starts as |
| 1040 | |
a copy of production's — copy-on-write where the filesystem can — and |
| 1041 | |
never writes back. Merging moves code and touches no data. |
| 1042 | |
- **A preview is held to the website specification, report-only.** Once it is |
| 1043 | |
live, `webspec()` runs over the `app.looks` paths of its `forge.json`. The pull |
| 1044 | |
page shows the result under the Checks line as **Website spec: N findings |
| 1045 | |
(report only)**; open it for one row per finding and its |
| 1046 | |
specification.website section. Findings the edge owns are counted apart |
| 1047 | |
(`, M the edge owns`). The terminal's pull screen ends its checks row with the |
| 1048 | |
same words. Nothing here fails a deploy or the check; see |
| 1049 | |
[`docs/WEBSPEC-PREVIEWS.md`](docs/WEBSPEC-PREVIEWS.md). |
| 1050 | |
- **A streamed page gets the pill too.** An HTML answer with no |
| 1051 | |
`Content-Length` is read up to 4 MiB for at most 100 ms, and only while its |
| 1052 | |
first chunk starts like markup; if it ends in that window it gets the pill |
| 1053 | |
like a sized page. Otherwise what was read is sent first and the rest of the |
| 1054 | |
stream follows unchanged. The cost: a streamed page that is still going |
| 1055 | |
waits up to those 100 ms before its first byte reaches the browser, so a slow |
| 1056 | |
server-rendered page paints up to 100 ms later (a page sent with a |
| 1057 | |
`Content-Length`, or one that starts with something other than markup, does |
| 1058 | |
not wait). Event streams and WebSockets are untouched. |
| 1059 | |
- **A signed-in note needs the panel's one-use proof (#5612).** The grant |
| 1060 | |
cookie alone no longer makes a note `@name`: the forge puts a one-use token in |
| 1061 | |
the panel's script tag on a signed-in page load, the panel sends it as |
| 1062 | |
`x-forge-proof` and each answer carries the next. Without it, a reused one or |
| 1063 | |
one for another grant or host, the sender is a visitor and `admin` is false. |
| 1064 | |
This stops an app's code that merely holds the cookie (a `fetch` from its |
| 1065 | |
page). It does **not** stop app code that runs in the same page and watches |
| 1066 | |
for the tag or patches `fetch` before the panel runs: the token reaches the |
| 1067 | |
panel through the page, so that code can read it. Closing that needs the |
| 1068 | |
panel in a cross-origin frame, which is not built. If a call fails the panel |
| 1069 | |
asks for a reload, since the chain is then spent. |
| 1070 | |
- **The forge button is a feedback door (#4758).** One tap opens *Tell the |
| 1071 | |
builders*: a text box and Send, no account. The note is posted to the app's |
| 1072 | |
own host (`POST /__forge/note`, same-origin only) and filed as an issue on |
| 1073 | |
its repository, labelled `from-app`, with the page, the viewport and the time, |
| 1074 | |
in the owner's name and with the sender's words inside one fence. A visitor |
| 1075 | |
is an anonymous `visitor-xxxxxx` (an HttpOnly cookie); a signed-in person is |
| 1076 | |
`@name`. Moderation: 3 notes a minute per sender, 6 per source address, 20 per |
| 1077 | |
app, a word filter, and the owner's hide or ban, which closes the issue and, for |
| 1078 | |
`ban`, refuses that sender. `GET /__forge/notes` gives the sender's own notes with a live |
| 1079 | |
status (queued, being built by a member, shipped on a date), polled every 15 s |
| 1080 | |
while the panel is open; a public app's note links to its issue. Left out: the |
| 1081 | |
optional screenshot and a version name in "shipped". |
| 1082 | |
From the terminal, a repository screen that is an app has two palette rows |
| 1083 | |
(`:`): *Tell the builders* sends a note as you, and *Notes from the app* |
| 1084 | |
lists yours with their status; the repository's admin sees every sender's and |
| 1085 | |
may hide a note, ban its sender, list the banned senders (*Banned senders*) |
| 1086 | |
and unban one. The gating is the page's: a guest or a |
| 1087 | |
non-reader sees nothing, and a repository that is not an app has no rows. |
| 1088 | |
Who may write: anyone on a public app, but a private app, and any sealed |
| 1089 | |
pull's preview, take notes only from a person the app admits (the condition |
| 1090 | |
`/__forge/measure` uses); a stranger is refused with a 404 before anything is |
| 1091 | |
filed. Moderation |
| 1092 | |
happens on the forge, not on the app: the app host answers only |
| 1093 | |
`POST /__forge/note` and `GET /__forge/notes`, and `/__forge/note/moderate` is |
| 1094 | |
a 404 there, because the app's own code (or a preview's) could call it |
| 1095 | |
same-origin with a visiting admin's cookie. The terminal screen above and the |
| 1096 | |
app's studio page are the owner's tools: the page lists the latest notes |
| 1097 | |
(sender, words, page, when) for those who may admin the repository, each with |
| 1098 | |
Hide and Ban sender, and every banned sender once with Unban, posting to |
| 1099 | |
`POST /:owner/:name/app/notes` on the forge's origin (`web/studio/notes.ts`). |
| 1100 | |
Limits count the socket's client (`http/peer.ts`, with its trusted-proxy |
| 1101 | |
rule), never a header the client wrote, and all three are checked before any |
| 1102 | |
is spent. The request body is read only up to 16 KB. The issue says its text |
| 1103 | |
is untrusted data, and the crew's brief says so for any `from-app` issue. |
| 1104 | |
A title always opens with `From the app: ` (`saysFromApp`), the label is made |
| 1105 | |
before the issue, and a failed labelling closes the issue and refuses the note. |
| 1106 | |
Only a note closed as done reads "Shipped"; the other reasons say what they are. |
| 1107 | |
Beyond the per-minute limits an app takes 30 notes an hour and 100 a day. |
| 1108 | |
The script grew to about 15 KB (cached, immutable); the notes are polled only |
| 1109 | |
while the panel is open. |
| 1110 | |
- **The forge pill acts.** The pill the proxy adds to an app's pages |
| 1111 | |
(`"toolbar": false` in `forge.json` turns it off; to write it in, the proxy asks the app for the page uncompressed, so a page that carries the pill goes out without gzip) links to a forge page, |
| 1112 | |
`/:owner/:name/toolbar?env=production|pull-N`, opened in a tab of its own: the |
| 1113 | |
pill posts nothing and reads nothing of the app's page, and every action is a |
| 1114 | |
same-origin form on the forge. On a preview, everyone who may see the pull sees what it changed (files, +added, −removed, linked to its Files tab) and where its latest check stands (linked to the run); the totals are left out when git cannot give them. Signed out, the page is where you are and Home. |
| 1115 | |
Signed in it shows the live agent run on this app or preview, linked to its |
| 1116 | |
run page (read once when the page opens; runs on work you may not see, such as |
| 1117 | |
a sealed report, are left out); *Report this page* / *Comment on this pull*, |
| 1118 | |
previewed first, then a receipt; *Tell the crew* for those who may direct it |
| 1119 | |
(the one `/:owner/:name/tell`); and *Make it yours* for everyone who cannot |
| 1120 | |
write the app. A production comment opens an issue carrying the page address; |
| 1121 | |
a preview comment goes on its pull. From a terminal or an agent the same |
| 1122 | |
actions are, in the SSH terminal, rows of the palette on a repository |
| 1123 | |
screen: *Working now* (while an agent run is live, left out when it is on work |
| 1124 | |
you may not see), *Tell the crew*, *Report a problem* (one text, an issue |
| 1125 | |
titled by its first line, through `openIssueThrough`) and *Make it yours* |
| 1126 | |
(the fork screen, for those who cannot write the repository); a pull is |
| 1127 | |
commented on from its own screen. Agents have the MCP `tell` and |
| 1128 | |
`make_it_yours` tools and the API's `POST …/issues`, |
| 1129 | |
`…/pulls/:n/comments` and `…/make-it-yours`. |
| 1130 | |
- **A run has a budget** (`src/kernel/budgets.ts`): a check past `gate` |
| 1131 | |
(60 min) or a reviewer's run past `review` (10 min) is killed with |
| 1132 | |
`budget: <what> took <n> min, limit <m>`, queued again once, and failed |
| 1133 | |
the second time, which the vitals carry as a need for a day. `gate` is 60 |
| 1134 | |
min because this repository's own gate takes 18–25 min alone and 45 beside |
| 1135 | |
another check; it comes down when the gate does. A check waiting in the lane |
| 1136 | |
past `laneWait` (10 min) or a run silent past `heartbeat` (3 min) turns the |
| 1137 | |
health degraded with one sentence naming the cause ("22 checks wait behind |
| 1138 | |
one lane; the oldest since 04:13; the lane runs #1212 for 31 min (limit |
| 1139 | |
60)"). The sentinel files each such breach at once, not once per tick: |
| 1140 | |
labelled P0 Trust and assigned to the sentinel, whose run the dispatcher |
| 1141 | |
queues on the assignment. |
| 1142 | |
- **Every deploy is a run**, and the run is the check on the pull. Turn on |
| 1143 | |
**require checks** and a pull cannot merge until its head is green. |
| 1144 | |
- **A production deploy records the merged pulls it carries**: one `git |
| 1145 | |
rev-list` of the deployed commit when the run finishes. The run page says |
| 1146 | |
"Carries #3, #5" and `GET /api/v1/repos/:owner/:name/runs/:id` has `carries`. |
| 1147 | |
In the terminal, open a repository and press `d` for its production deploys |
| 1148 | |
(each with what it carries), `enter` for the log, `r` to refresh. |
| 1149 | |
- A **private** app answers only to someone the forge has vouched for |
| 1150 | |
(`canRead` on the repository, then a signed grant); a public one to anyone. |
| 1151 | |
- **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's |
| 1152 | |
`tty` command from `forge.json` on a real pty over the environment's own |
| 1153 | |
data; `<name>--<pull>@<forge>` opens a preview's. The scaffold ships one. |
| 1154 | |
- **A new app starts trustworthy.** The scaffold's door asks the app's |
| 1155 | |
contract before a body is read, every change and refused change is an |
| 1156 | |
audit row, the API describes itself at `/openapi.json`, `/healthz` says |
| 1157 | |
up, and the look is design tokens, light and dark, with no colour spelled |
| 1158 | |
out anywhere else ([docs/APPS.md](docs/APPS.md#what-a-new-app-starts-as)). |
| 1159 | |
|
| 1160 | |
The repository says how it runs in `forge.json` (`run`, and optionally |
| 1161 | |
`install`, `test`, `health`, `ready`); a `package.json` with a `start` script |
| 1162 | |
is enough on its own. The forge hands the process `PORT`, `FORGE_APP_DATA` |
| 1163 | |
(this environment's directory — keep state there), `FORGE_APP_ENV`, |
| 1164 | |
`FORGE_APP_URL` and `FORGE_APP_COMMIT`, and nothing from its own |
| 1165 | |
environment. Browsers resolve `*.localhost` to loopback, so a laptop forge |
| 1166 | |
needs no DNS at all; elsewhere point a wildcard at it and set |
| 1167 | |
`FORGE_APP_DOMAIN`. |
| 1168 | |
|
| 1169 | |
**Move the pill.** If the pill covers something of the app's, drag it (mouse or |
| 1170 | |
touch) and it snaps to the nearest corner on release; with the pill focused, the |
| 1171 | |
arrow keys walk it from corner to corner. The corner is kept in the browser's |
| 1172 | |
`localStorage` for that app's host (bottom-right until then), and the panel opens |
| 1173 | |
toward the inside of the screen, bounded to it at phone width. The script stays |
| 1174 | |
under a 10 KB budget (a test holds it), since it loads on every app page. |
| 1175 | |
|
| 1176 | |
The forge pill on an app's pages says where you are: its accessible name reads |
| 1177 | |
`notes, production` or `notes, preview of pull #7`, and pressing it opens a |
| 1178 | |
panel with the app, the environment, and a link home. The pull's title is shown |
| 1179 | |
only to people who may read the pull. A tap outside the open panel, or Escape, |
| 1180 | |
closes it. When a crew run is working on the app (production) or on the preview's |
| 1181 | |
pull, the panel also says `scout is working on this` and links the run; it asks |
| 1182 | |
`/__forge/crew` once as the pill loads, answers only a signed-in person who may |
| 1183 | |
read the repository (and, for a run on a sealed issue, that issue), and shows |
| 1184 | |
nothing when the crew is idle. |
| 1185 | |
|
| 1186 | |
**When the pill is not shown** (an app whose own policy refuses foreign |
| 1187 | |
scripts), `/__forge/` on the app's host is the way back: a small page from the |
| 1188 | |
forge, never the app, with no script, saying the app and where you are |
| 1189 | |
(`production` or `preview of pull #N`) and linking Home, the repository (to |
| 1190 | |
who may read it) and *Tell the crew* (the same link `/__forge/crew` gives; signed |
| 1191 | |
out it offers sign in). It also says what is serving, read from the deploy |
| 1192 | |
record (no git): production shows the newest release and the three before it |
| 1193 | |
(a redeploy of the same commit is not another release), a preview shows its |
| 1194 | |
pull and the commit it last served, or "not deployed yet". A viewer who cannot |
| 1195 | |
read the repository sees only the time, never a sha. The read is the `serving` |
| 1196 | |
Server-Timing phase. The prefix was already the forge's, so an app's own |
| 1197 | |
`/__forge/` path is not reachable. |
| 1198 | |
|
| 1199 | |
**Tell the crew from the pill.** Signed in as someone who may direct the crew, |
| 1200 | |
the panel also offers *Tell the crew* (`/__forge/crew` adds a `tell` link, only |
| 1201 | |
for them; signed out there is no action). It opens the forge's own |
| 1202 | |
`/:owner/:name/tell?page=<address>&pull=<n>` in a new tab, so the POST is the |
| 1203 | |
forge's same-origin, CSRF-checked form and the app's origin can never send one. |
| 1204 | |
It is the same `tell()` as every door; `page` (an http(s) address) and `pull` |
| 1205 | |
(a number, on a preview) are checked again on send and written into the tell |
| 1206 | |
as the *Where this was told from* block. A receipt links the issue it opened; a |
| 1207 | |
refused tell returns to the box with the words, page and pull kept. The MCP |
| 1208 | |
`tell` takes the same through `context: { page, pull, run, commit, said }`, and |
| 1209 | |
the terminal's palette has *Tell the crew from a page*, which asks for the |
| 1210 | |
page, the pull (optional) and the line, through the same `parseContext` and |
| 1211 | |
`contextNote`. |
| 1212 | |
|
| 1213 | |
With a sentence and **Pick for me**, the sentence becomes a plan before the |
| 1214 | |
page comes back: `src/crew/plan.ts` makes one non-interactive call to |
| 1215 | |
`claude` — the quickest model, a fixed JSON shape, at most 3 seconds — for a |
| 1216 | |
short checklist and a crew pick, so issue #1 opens as a checklist already |
| 1217 | |
assigned to whoever the plan named, instead of a paragraph and a dropdown. |
| 1218 | |
It costs one small model call per app made this way (a few tenths of a |
| 1219 | |
cent), never blocks the repository or the app starting, and a bad or slow |
| 1220 | |
answer is the same as none: no pick, no checklist, exactly what naming an |
| 1221 | |
agent yourself already does. `FORGE_APP_PLAN=0` turns it off; so does having |
| 1222 | |
no `claude` on `PATH` — either way **Pick for me** stops being offered and |
| 1223 | |
`New app` behaves exactly as it did before this existed. |
| 1224 | |
|
| 1225 | |
## The crew |
| 1226 | |
|
| 1227 | |
In a preview (`FORGE_APP_ENV=preview`, which the platform sets) the crew is |
| 1228 | |
off: no autonomy tick, no dispatch, no spend; checks still run and everything |
| 1229 | |
can still be read. The boot log and the studio's crew card say so. Set |
| 1230 | |
`FORGE_CREW_IN_PREVIEW=1` to run it anyway. |
| 1231 | |
|
| 1232 | |
**Per model, per prompt version.** Every agent run records the model it ran on |
| 1233 | |
and a prompt version: a short hash of the agent's brief, its crew role and the |
| 1234 | |
standing system-prompt text (not the issue or branch, so two runs on one brief |
| 1235 | |
share a version and editing the brief starts a new one). Settings → Crew shows |
| 1236 | |
the scorecard cut both ways ("Per model", "Per prompt version": runs, landed, |
| 1237 | |
failed, spent, per run), and the terminal's `Crew scorecard` command shows the |
| 1238 | |
same to an admin. The scorecard of pulls landed, review rounds and red checks |
| 1239 | |
is cut the same way as sub-rows under each agent (see above); a team's pooled |
| 1240 | |
row is not cut by model, and is under the agents on the page and in the |
| 1241 | |
terminal's "Crew scorecard per agent" (see above). Runs from before this was recorded show as *default* / |
| 1242 | |
*not recorded*; they are not backfilled. |
| 1243 | |
|
| 1244 | |
**Tell the crew from any page.** In a repository you may direct, the web palette |
| 1245 | |
(`:` in the browser) has a *Tell the crew* row. With the script it opens the thread's own |
| 1246 | |
comment box over the page you are on, without leaving it: `enter` sends (shift+ |
| 1247 | |
enter is a new line), `esc` closes. The tell remembers that page (`from`), and |
| 1248 | |
the answer comes back to it. Without a script the row is a link to a page of |
| 1249 | |
its own, `/:owner/:name/tell?from=/path`, which is the same box; `from` must be |
| 1250 | |
a path on this forge. Who may not direct the crew gets a 404 there and no row. |
| 1251 | |
The terminal's palette has the row too, on a repository screen, for the |
| 1252 | |
same people: it takes one line, tells with no `from`, and answers in the |
| 1253 | |
same words. |
| 1254 | |
|
| 1255 | |
**The crew's answers.** On an app's page the owner's own tells show under the tell |
| 1256 | |
box, newest first, each with what the crew said back (its last comment, else its |
| 1257 | |
last finished run's summary, else that it is working or waiting). The region |
| 1258 | |
updates live from `GET /:owner/:name/app/answers` (owner only; 404 for anyone |
| 1259 | |
else). In the terminal, `a` in the repository screen opens *Your tells* with the |
| 1260 | |
same rows behind the same gate. |
| 1261 | |
|
| 1262 | |
An **agent** is an account of kind `agent`: no password, no session, and it |
| 1263 | |
acts only through a token the forge mints for a run and destroys after it. |
| 1264 | |
An admin creates one (**Settings → Crew**, or `POST /api/v1/agents`) with a |
| 1265 | |
model, a brief in its own voice and a spend budget; a repository admin puts it |
| 1266 | |
on the repository's **crew**, which is the one grant an agent can hold — it |
| 1267 | |
reads and writes that repository and administers nothing. |
| 1268 | |
|
| 1269 | |
A **planner** on the crew may also close an issue it is re-planning, in |
| 1270 | |
`actions/issues.ts` so the web, the API and the MCP agree, and only these |
| 1271 | |
ways: `split` into open issues opened since it was handed the issue (since the |
| 1272 | |
issue itself when the crew never parked it twice, or into issues of the same |
| 1273 | |
repository that all closed as done since that hand-over), `dropped` for an issue the |
| 1274 | |
crew wrote, and `done` when commits on main already name the issue. A person's |
| 1275 | |
ask is never dropped by the planner; any other close, any reopen and any edit |
| 1276 | |
to a person's issue stay refused. |
| 1277 | |
|
| 1278 | |
Assign an issue to an agent and it starts: the forge clones the repository, |
| 1279 | |
puts the agent on a branch named after the issue, and runs Claude Code |
| 1280 | |
headless with a brief that says who it is, what the task is, how to finish, |
| 1281 | |
and what will be refused. The pull opens at the agent's first push, marked |
| 1282 | |
"In progress" with a link to the run's log, and its preview follows each later |
| 1283 | |
push (`src/crew/early.ts`). When the agent stops, the forge pushes what it |
| 1284 | |
committed and fills in that same pull (the agent's last message is the |
| 1285 | |
description). A run that ends without reporting leaves the pull saying so, with |
| 1286 | |
the log link; a run that is only paused (a limit, an outage) keeps saying "In |
| 1287 | |
progress". A run that declines after its pull opened closes that pull with the |
| 1288 | |
decline sentence and the run link, unless a person has commented on or reviewed |
| 1289 | |
it: then it stays open with a comment. An agent may withdraw (close) a pull it |
| 1290 | |
wrote itself, and nothing else. A body a person has edited is left alone, unless they kept its first |
| 1291 | |
words ("In progress:" or "The run ended"); change those words too. Leave |
| 1292 | |
feedback on the pull — a comment, a request for changes, a line comment — and |
| 1293 | |
the agent picks it up in the background, resuming its session. A failed check re-engages it too, a bounded number of |
| 1294 | |
times before a person must speak. |
| 1295 | |
|
| 1296 | |
**Design review.** A pull that changes anything under `src/web/` (a page, the ui |
| 1297 | |
vocabulary, the style) is sent to a crew reviewer on the Design team when there |
| 1298 | |
is one, and its review brief holds the change to `docs/DESIGN.md`. A security |
| 1299 | |
change still goes to the security reviewer; a change only under `src/core/` is |
| 1300 | |
not a design matter. |
| 1301 | |
|
| 1302 | |
**How a reviewer is chosen.** Reviews go to the least-busy reviewer: the one |
| 1303 | |
with the fewest review runs in flight (queued or running). A tie goes to the |
| 1304 | |
experience reviewer. A pull about security goes only to a security reviewer |
| 1305 | |
(the least busy of them). A `src/web/` pull goes to the design reviewer until |
| 1306 | |
it has 2 review runs in flight, then spills to the least busy reviewer. |
| 1307 | |
|
| 1308 | |
**Three rounds, then a person.** When the crew reviewer has asked for |
| 1309 | |
changes three times on one pull with no approval and no word from a person |
| 1310 | |
who may direct the crew between, no more feedback runs start on it. The |
| 1311 | |
thread keeps a note ("Handed to a person by the crew:") with the three |
| 1312 | |
asks, and the pull becomes a **hand-over** card in asks: *Tell them what to |
| 1313 | |
try*, or *Close it*. Rounds are counted from the reviews, not the runs, so a |
| 1314 | |
restart never adds one. A person resets the count by commenting as a |
| 1315 | |
director (the crew then resumes) or by approving the pull from its page; the |
| 1316 | |
card ends when the pull is approved, closed or merged. Hand-overs can be |
| 1317 | |
closed in bulk like the other pull asks. The terminal's On you card lists |
| 1318 | |
them as "handed over" rows: `enter` opens the pull, `x` closes it. |
| 1319 | |
|
| 1320 | |
**A request for changes always moves.** Every tick, a crew pull whose |
| 1321 | |
reviewer last asked for changes is moved on. If the builder has pushed since |
| 1322 | |
and the new head is green, the reviewer is asked again: a verdict on old |
| 1323 | |
code never blocks. If the verdict is on the current head and no run is in |
| 1324 | |
flight, one feedback run is queued to the pull's builder, and a builder is |
| 1325 | |
assigned when the pull has none. That run takes a builder slot like any |
| 1326 | |
other. The tick log names each pull it moved, or why it did not, and |
| 1327 | |
The flow panel on `/boards`, the terminal's Flow dialog and the MCP `flow` tool list the pulls |
| 1328 | |
waiting on a builder and those waiting on a reviewer, oldest first. |
| 1329 | |
|
| 1330 | |
The same three also say, per reviewer, how many review runs are in flight (queued or running): `3 reviews in flight: quill 2, sage 1.`, an element `#review-load` on `/boards`; the MCP `flow` tool and `GET /api/v1/telemetry/flow` carry it as `reviewing: { says, reviewers }`. It is `reviewLoad` from `src/crew/review.ts`, the count routing reads (`flow/reviewing.ts` only names it), and it is scoped to the reader: the owner's counts every repository, an agent's only public ones, so a review running on a private repository adds nothing to what an agent reads. No dependency was added. |
| 1331 | |
|
| 1332 | |
**Already done.** A builder that finds its issue already built on the base |
| 1333 | |
branch declines with `DECLINED: already done on main in <commit>`, naming the |
| 1334 | |
full or abbreviated id of each commit that did it (at most five). The forge |
| 1335 | |
checks that every commit named is on the base branch; only then is the run |
| 1336 | |
an "already done" outcome, with no strike and nothing parked. The issue stays |
| 1337 | |
open with a note naming the commits, the asked list and the issue page say |
| 1338 | |
"Done on main", and the person who asked closes it (the page's Close button, |
| 1339 | |
or `x` on the issue in the terminal); the close writes "Closed: already done |
| 1340 | |
on main in …" on the thread. A claim with no commit, or with one that is not |
| 1341 | |
on the base, is an ordinary decline and is parked as one. |
| 1342 | |
|
| 1343 | |
**Live triage.** On each autonomy tick, an open issue carrying the crew's own |
| 1344 | |
"already done" note (a person's note counts for nothing) is closed as *done* |
| 1345 | |
when every commit the note names is on the base branch; one missing commit |
| 1346 | |
leaves it open. At most 25 per repository per tick, rules only, no run and no |
| 1347 | |
spend. The close is made in the name of the person who set the app autonomous, |
| 1348 | |
so its comment says "Closed by the crew's live triage". Reopening the issue |
| 1349 | |
undoes it and the reopen stands: an issue that already has a close note is |
| 1350 | |
never closed by triage again. The tick line counts them as `triaged=N`. |
| 1351 | |
|
| 1352 | |
Live triage also folds duplicates: of two open issues whose titles match after |
| 1353 | |
case and punctuation, the newer closes (reason dropped) with the comment |
| 1354 | |
"duplicate of #N", and whatever waited on it waits on the older. A person's |
| 1355 | |
newer issue is never closed into a crew member's; the older closes instead. A |
| 1356 | |
fold a person reopened stands, the plan's own issues are never folded, a |
| 1357 | |
dependency that would make a loop stays where it was, and folds share the |
| 1358 | |
25-per-tick cap with the already-done closes. Exact match only, no fuzzy |
| 1359 | |
matching. |
| 1360 | |
|
| 1361 | |
**Live triage files issues.** On the same tick, an open issue an hour old |
| 1362 | |
that serves no pillar is filed: under the pillar its title, else its body, |
| 1363 | |
names by its short form (`P0`) or whole label (`P0 Trust`) — a bare word like |
| 1364 | |
"trust" does not count — otherwise behind the Later gate. Each gets a comment |
| 1365 | |
marked automatic. Directions, umbrellas and the gate are skipped, as is a |
| 1366 | |
repository with neither pillars nor a gate. To undo it, name a pillar (link |
| 1367 | |
its umbrella to wait for the issue) or unlink the gate; the comment stays, so |
| 1368 | |
triage does not file that issue again. The tick line counts `labelled=N`, and |
| 1369 | |
shows `triageMiss=N` for old issues it could not file (no gate to defer to). |
| 1370 | |
|
| 1371 | |
Live triage also folds questions. An open issue opened by a person who can |
| 1372 | |
write here, whose title starts `Question:`, `Wish:` or `Idea:` or ends in `?`, |
| 1373 | |
becomes one `Folded: #N <title>` comment on the direction issue and closes as |
| 1374 | |
*dropped*, with a comment saying how to get it back: reopen it and retitle it |
| 1375 | |
as the task it asks for; the reopen stands. Issues from outsiders, agents and |
| 1376 | |
imported ones are never folded, and nothing is folded before the repository |
| 1377 | |
has a direction issue. Note that a writer's real task titled `Can we add X?` |
| 1378 | |
is folded too, since a title ending in `?` is the rule; phrase tasks as |
| 1379 | |
statements. The tick line counts folds as `folded=N`. |
| 1380 | |
|
| 1381 | |
**A red pull.** The decider stops a red check only when it is at least 70% |
| 1382 | |
sure; below that the pull gets another feedback run. A stop parks the pull: it |
| 1383 | |
closes as *parked*, not done, with a comment that says why. The pull page's |
| 1384 | |
badge reads "Parked" instead of "Closed", the terminal's merge box says "Parked |
| 1385 | |
by the crew, not merged", and the lists note it as stopped. Reopening it clears |
| 1386 | |
the mark. Only the crew parks: the API and the page refuse `reason: "parked"`. |
| 1387 | |
In the database the pull's `close_reason` stays NULL (its CHECK predates the |
| 1388 | |
word) and `issue.parked = 1` carries it, so raw SQL never reads it as done. |
| 1389 | |
|
| 1390 | |
**The merge queue.** A green, approved pull in an autonomous repository joins |
| 1391 | |
its repository's queue (`src/checks/queue.ts`). The queue builds one |
| 1392 | |
candidate: main, then each queued pull's squash on top of the one before, in |
| 1393 | |
queue order, leaving out any pull that conflicts or has nothing left to land. |
| 1394 | |
A candidate carries at most eight pulls (`MAX_CANDIDATE`), so a gate stays |
| 1395 | |
near one full tier and a red bisects in three; the rest wait their turn. |
| 1396 | |
The log says `[queue] gating N pulls since HH:MM` as a gate starts and |
| 1397 | |
`[queue] gated N pulls: green|red|cut off` as it ends. |
| 1398 | |
The full gate runs once on that candidate. Green lands every pull in order, |
| 1399 | |
and each landing is refused unless main is still where the candidate was |
| 1400 | |
built and the squash writes the tree that was gated, so main ends at the |
| 1401 | |
gated tree. Red is searched: prefixes of the candidate are gated on main |
| 1402 | |
— the front half, plus one more cut per free check slot, all at once — the |
| 1403 | |
longest green prefix lands, the search goes on in the steps up to the first |
| 1404 | |
red prefix, and the red pull is handed back with the failing tests named. |
| 1405 | |
A red batch is never formed again around the same head: with a check slot |
| 1406 | |
free, the head of a red batch of three or more is gated alone beside the |
| 1407 | |
line (`[queue] red batch: its head is gated alone`) while the rest gates |
| 1408 | |
without it, stacked as deep as `queue.depth` goes, so the good pulls land |
| 1409 | |
while the suspect is judged. A pull in two red batches is never in a third: |
| 1410 | |
it waits for a gate of its own. Red alone, it is handed back (`[queue] red |
| 1411 | |
alone`); green alone, it rides at the tail of a later batch, never its head. |
| 1412 | |
While a candidate gates, the next one gates on top of it (`queue.depth` |
| 1413 | |
under Settings → Capacity, `FORGE_QUEUE_DEPTH`, default 2): when the first |
| 1414 | |
lands whole the next lands right after it, on the tree it was gated on; when |
| 1415 | |
it does not, the next is formed again on main. A gate beyond a line's first |
| 1416 | |
starts only on a free check slot and holds it, and two slots are always kept |
| 1417 | |
for pull checks (`PULL_RESERVE`); with no slot spare the queue gates one |
| 1418 | |
candidate at a time and halves a red one, as it always did. A red |
| 1419 | |
waits for a push, not a new main (a pull red on main would otherwise ride a |
| 1420 | |
batch after every landing), except once: after 30 minutes on the same head |
| 1421 | |
it is gated again, and lands if green; a red that reproduces stays red and |
| 1422 | |
is not told twice. The queue remembers what its searches found: a pull |
| 1423 | |
handed back red on top of others of its candidate that landed first is a |
| 1424 | |
clash (the comment names them; the log says `[queue] red on top of its |
| 1425 | |
candidate`, or `red on its own merge`), and the tick line counts them as |
| 1426 | |
`clashes=N`. A remembered pull that joins again at the same head is gated |
| 1427 | |
alone, only once no fresh pull waits, and nothing is stacked on it, so a red |
| 1428 | |
re-check never cuts off a batch of fresh pulls; a push forgets it. A gate cut off by the forge stopping (a |
| 1429 | |
roll, a drain) or by the cluster losing its Job is no red at all: the next |
| 1430 | |
sweep gates it again. So is a gate that has not answered in 15 minutes |
| 1431 | |
(`GATE_DEADLINE_MS`): it is abandoned and its check aborted, the log says |
| 1432 | |
`[queue] candidate abandoned`, and the next sweep forms the line again. Pulls |
| 1433 | |
that turn green while a candidate is gating wait for the next one. Each |
| 1434 | |
queue's numbers — how many pulls are queued, how many are gating, how many |
| 1435 | |
gates are in flight, how many landed in the last hour, and their median |
| 1436 | |
minutes from approval to landing — show in three places: the flow panel on `/boards`, the |
| 1437 | |
terminal's Flow command, and the MCP `flow` tool's `queue` field. The flow |
| 1438 | |
panel and the terminal's Flow command also show the last hour's red batches |
| 1439 | |
(`1 of 3`: candidates gated red, of those gated; search prefixes and |
| 1440 | |
re-checks are not counted) — the panel as a column, the terminal as a |
| 1441 | |
`Red batches, last hour:` line under its table. The MCP `queue` rows do not |
| 1442 | |
carry it: their shape is unchanged. |
| 1443 | |
When a repository requires checks, an approved pull that is green only |
| 1444 | |
against an older main joins the queue as it is: the candidate's full gate |
| 1445 | |
runs on main's tip with that pull merged, which is the proof a re-check of |
| 1446 | |
its own would give, and a red candidate bisects as above. So a landing that |
| 1447 | |
moves main does not send every other approved pull off for a check of its |
| 1448 | |
own first; the batch lands in one gate. A pull's own check runs again only |
| 1449 | |
when its head changes: an approval checks a head that was never checked, |
| 1450 | |
and a green one only when main moved since and the repository is not |
| 1451 | |
autonomous, whose land gates on today's main anyway (`src/checks/approval.ts`). A person's merge, which no candidate gated, still |
| 1452 | |
refuses a stale green. Each sweep reads every open pull, oldest first, not |
| 1453 | |
only the newest page, so an older approved pull is batched with the rest |
| 1454 | |
rather than joining alone on its own event. The autonomy tick line counts the approved pulls the |
| 1455 | |
last sweep saw and why each waits (`approved="3 approved: queued=2 review=1"`, |
| 1456 | |
from `src/crew/waits.ts`), and what the queue is doing now |
| 1457 | |
(`queue="gating 2 pulls since 18:54"`, `queue="1 waiting"` or `queue=idle`, |
| 1458 | |
in UTC). |
| 1459 | |
|
| 1460 | |
An autonomous app's planner is **held** while nothing is landing or the plate |
| 1461 | |
is full: no pull has merged in 48 h while one was closed unmerged, or more than |
| 1462 | |
`slots × 3` ready, unassigned issues are waiting. The tick log says which |
| 1463 | |
(`planner: held`), and the board and the terminal's repository list say it in |
| 1464 | |
words. A comment on the direction issue from someone who may write there lifts |
| 1465 | |
the hold; an issue handed to the planner is still owed its pass. |
| 1466 | |
|
| 1467 | |
Every tick says why each ready issue a builder cannot start is held, counted |
| 1468 | |
under the first filter that holds it (`crew/held.ts`): `held: running=1 |
| 1469 | |
open_pull=10 stalled=12 run_cap=11` — a run on it, an open pull proposing it, |
| 1470 | |
the planner's, found done on main and waiting for a person to close it |
| 1471 | |
(`done_on_main`, never re-planned), stalled, at its run cap, parked twice, covered by a red-main |
| 1472 | |
fix, or a setting's finding. The flow panel on `/boards` draws the same per autonomous app as one |
| 1473 | |
bar, and the terminal's Flow command prints it as a line per app |
| 1474 | |
(`flow/held.ts`, kept 5 s). When nothing is startable while builder slots are free and some of the |
| 1475 | |
held are stalled, capped or parked twice, the planner is owed one pass with |
| 1476 | |
that breakdown in its brief, to re-plan them into smaller, fresh issues; a |
| 1477 | |
full plate and its own unanswered decline do not hold that pass, and the |
| 1478 | |
same breakdown does not ask it twice. A stalled issue does not wait for that |
| 1479 | |
pass: the next tick parks it with who stalled it and why, so another builder |
| 1480 | |
picks it, and a second stall hands it to the planner past its day's cap |
| 1481 | |
(`crew/stalls.ts`, `unstuck=N` on the tick line). |
| 1482 | |
|
| 1483 | |
The forge asks the host for one more binary: `claude`, on `PATH` or at |
| 1484 | |
`FORGE_CLAUDE`, authenticated as the operator (an `ANTHROPIC_API_KEY` in the |
| 1485 | |
forge's environment is handed to runs; the machine's own Claude Code login |
| 1486 | |
works when there is none). `FORGE_CREW_CONCURRENCY` (one per 3 GiB above 2 GiB, 2–16) and |
| 1487 | |
`FORGE_CREW_BUDGET_USD` (5) bound it; `FORGE_CREW_CEILING_USD` caps what the |
| 1488 | |
crew may spend across every repository in a UTC day, whatever each |
| 1489 | |
repository's own ceiling says; `FORGE_APPS=0` turns apps and crew off. A preview whose pull nobody has |
| 1490 | |
pushed to or commented on for `FORGE_PREVIEW_IDLE_DAYS` (default 3; `0` keeps |
| 1491 | |
every preview warm) is stopped and reads as asleep, and asking for it wakes it. |
| 1492 | |
An admin with more than one Claude account keeps them under Settings → |
| 1493 | |
Subscriptions, sealed, and grants each to a repository (Settings → Crew) or |
| 1494 | |
an organization (its page), down to one crew member: a work plan pays for |
| 1495 | |
the work repositories and nothing else. Two on one grant take turns, and |
| 1496 | |
one the provider rests hands its runs to the other until the reset. |
| 1497 | |
**Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks. |
| 1498 | |
|
| 1499 | |
**Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token (with `{"kind": "passkeys"}` it lists your own passkeys instead, `{ items }` of id, name, created, lastUsed, people only; the page is Settings → Passkeys); in the terminal it is the **What I can do** command (read only, agents included). |
| 1500 | |
|
| 1501 | |
**Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent. |
| 1502 | |
|
| 1503 | |
**Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent. |
| 1504 | |
**Sandbox.** An agent's shell is held to a policy — the network is the forge |
| 1505 | |
and the package registry, writes stay in the clone, the forge's data and the |
| 1506 | |
operator's credentials are unreadable — by bubblewrap and socat on Linux |
| 1507 | |
(both are in the image). Inside it a builder can install the declared |
| 1508 | |
packages, type-check, and run the tests that boot a server: no unix socket is |
| 1509 | |
let through, so the run carries `FORGE_TEST_SOCKET=port` and the forge listens |
| 1510 | |
on a loopback port instead (`bun scripts/sandbox-probe.ts` prints each |
| 1511 | |
verdict). A host that forbids unprivileged user namespaces |
| 1512 | |
(many container platforms' seccomp profiles) cannot hold it, and the policy is |
| 1513 | |
then switched off rather than faked. To tell which a deployment is in: the |
| 1514 | |
boot log carries `agent sandbox not available here` with the reason, Settings → |
| 1515 | |
Crew shows **Unsandboxed** with it, and every agent run's page says |
| 1516 | |
"Sandboxed" or "Unsandboxed" from what it was actually started with (runs |
| 1517 | |
from before this record say nothing). Claude Code bridges that sandbox with |
| 1518 | |
two `socat` relays per session, in the session's process group; when a |
| 1519 | |
session ends, however it ends, whatever is left in that group is killed. A |
| 1520 | |
`processes` line once a minute counts what runs beside the forge by name |
| 1521 | |
(`top="socat=… bwrap=…"`), and kills any bridge whose session is gone |
| 1522 | |
(`reapedBridges=N`). Every start is counted in `boots.json` under the data |
| 1523 | |
directory: the boot line and every autonomy tick line say `restarts=N`, plus |
| 1524 | |
`unclean=N` for starts that ended without an orderly exit (a crash or an OOM |
| 1525 | |
kill, which leave nothing in the log themselves). |
| 1526 | |
|
| 1527 | |
`docs/APPS.md` is the contract: the vocabulary, and the rules that are |
| 1528 | |
mechanical rather than advisory. |
| 1529 | |
|
| 1530 | |
`docs/FLOW.md` is where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 1531 | |
Where the whole forge waits is the owner's, on `/boards` (the old `/flow` redirects there, query kept; an agent granted `telemetry:read` reads the same from `/api/v1/telemetry/flow` and the MCP `flow` tool): the flow panel shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. A stalled step is a card on the home (Studio mode) for anyone who can write to the repository: an open pull whose check was never asked for, or is red, past its stage's allowance, with its stage, how long it has been stuck, and one button — Run check or Re-run — that asks for a check at the head and returns to the home. The terminal's **Stalled steps** command (palette) lists the same steps, live, and Enter or `r` does the same re-run; both read `flow/stalled.ts` and act through `flow/unblock.ts`, and neither shows anything to someone who cannot write. A conflicted pull never sits: every tick the crew's open pulls that no longer merge get one bring-up-to-date run each (one in flight per pull), oldest pull first and only as many as the check lane has slots for, since each ends in a push and a check: check slots minus the checks queued and running minus the runs already in flight, with one always allowed (`crew/conflict_room.ts`, #4459); after 2 failed tries in the same conflict, or 24 h since the first, the pull is closed with the conflicting files named and its issue goes back to ready, unassigned, with no strike against the builder. Conflicted pulls are listed oldest first on `/boards`. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h. |
| 1532 | |
|
| 1533 | |
`/resilience` is a forge admin's too (a guest is sent to sign in, anyone else gets 404): each protection in `docs/THREATS.md` as one line, green, red, untested or "no test yet", judged by the test file it cites in the last full gate of the forge's own repository (`FORGE_REPO`; other repositories' gates never touch it). The terminal's **Resilience** command (palette, admins) lists the same lines. |
| 1534 | |
|
| 1535 | |
The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear. |
| 1536 | |
|
| 1537 | |
`/system` is the machine, for the owner (#1789), laid out like btop (#2721): a meter and a 30-reading block-character history per core, memory and swap meters, load, hwmon temperatures, disk use per mount, disk and network rates (`/proc/diskstats`, `/proc/net/dev`), and a table of every process grouped forge / check (`check:<run>`) / app (`app:<slug>/<env>`) / host with CPU, memory and age, sortable by CPU (`c`) or memory (`m`) or the buttons (shown only with a script; without one the order is by CPU). It reads `/proc` and `/sys` and updates in place over one event stream, `/system/stream` (`?pause` holds it still; no reload, no polling); the 2 s push timer runs only while someone is subscribed, so it costs nothing unwatched. Its history is the `machine` board source. A test file running in more than one check at once is counted at the top of `/system` and the `/boards` flow panel, with the checks. Command lines are never kept — only `*.test.ts` arguments. |
| 1538 | |
In the terminal, `:` → System shows the same view (the owner's only): per-core CPU with a block-character history, memory, swap, disk and network rates, and each group's top two processes (no sort; the dialog is read-only), and `:` → Telemetry lists the agents and flips `telemetry:read` for the one you press Enter on. |
| 1539 | |
An agent the owner grants `telemetry:read` (Settings → Telemetry, one switch per agent, revoked the same way) reads the same through `GET /api/v1/telemetry/{system,flow,sources,history}` and the MCP `flow` and `system` tools; private repositories' checks and apps are renamed `check:private-N` and lose their processes and test files, and `flow` (like `system`, an admin agent account needs the grant too: only a person who is an admin reads without it) names only public repositories' conflicted pulls, every read is listed on that page, and an agent without the grant is refused. `/contribute`, `GET /api/v1/contribute` and the MCP `contribute` tool are open to anyone: what the forge is, the bottleneck now, open issues labelled “help wanted” in public repositories, and how to propose (fork, pull, the same gate). The grant is a table, not a token scope, so no token has to be re-issued. |
| 1540 | |
|
| 1541 | |
**Raw files and caches.** `GET /:owner/:name/raw/:ref/*path` at a full 40-hex commit id is `immutable` for a year: `public, max-age=31536000, immutable` for a public repository, `private, max-age=31536000, immutable` for any other, so a shared cache never keeps private bytes. At a branch or tag it is `private, max-age=0` and revalidates. Every raw answer has the blob's oid as its `ETag`, and a matching `If-None-Match` gets a 304 with the same `Cache-Control`, `nosniff` and sandbox CSP. The HTML blob page is not cached this way (it carries the viewer and a nonce). A public repository that later turns private keeps its year-long `public` copies in shared caches already holding them. See docs/CACHING.md. |
| 1542 | |
|
| 1543 | |
`docs/CACHING.md` is what the forge keeps between requests to make `/map` and |
| 1544 | |
`/` fast: each map lane's plan and each viewer's feed candidates, stamped with |
| 1545 | |
SQLite's own write clock so any write is seen on the next read, keyed so one |
| 1546 | |
person's view never reaches another, and the budgets `test/snappy.test.ts` |
| 1547 | |
holds. `bun run bench/snappy.ts` prints p50/p95 and SQL statements per request |
| 1548 | |
for both pages, warm and cold. The content-addressed assets answer a |
| 1549 | |
revalidation with `304`. |
| 1550 | |
|
| 1551 | |
`docs/PLANNING.md` is how issues depend on issues: the edge, the map, and |
| 1552 | |
what "ready now" means to a person and to the crew. The map draws open |
| 1553 | |
issues by default; `?done=1` ("Show done") draws the closed ones on an edge too. The map also shows each |
| 1554 | |
pillar's ask-to-live: the median hours from an issue opening to its change live |
| 1555 | |
in production, over the last 7 days (also in the map and milestones API and MCP). |
| 1556 | |
It reads the `deploy_pull` record a production deploy writes when it finishes, |
| 1557 | |
so a page view runs no git; an issue closed by several pulls counts once, at |
| 1558 | |
its first time live. On boot the forge records what the last 7 days of earlier |
| 1559 | |
production deploys carried, so work that went live before the record is counted. |
| 1560 | |
|
| 1561 | |
`docs/STUDIO.md` is the front door from the top of this page in full: the five |
| 1562 | |
words and where each is decided, the box, the three lists, a tell, a run |
| 1563 | |
and a change each on its own page, and the board. |
| 1564 | |
|
| 1565 | |
`docs/CONTRACT.md` is who may do what, as a table: an app's `contract.json`, |
| 1566 | |
the one evaluator every app decides with, the audit that reads it from git, |
| 1567 | |
the probe that knocks on the running app, and the wall a person alone |
| 1568 | |
crosses. |
| 1569 | |
|
| 1570 | |
`docs/FOLLOWING.md` is a fork that keeps up with its parent: where it |
| 1571 | |
stands, taking updates, and the crew's part when both sides moved. |
| 1572 | |
|
| 1573 | |
`docs/FLEET.md` is one change across many repositories, runs or issues: a |
| 1574 | |
query, a dry-run plan of every row before → after, a receipt, and undo. |
| 1575 | |
`archive` (true or false) archives or wakes many repositories; its undo is kept a day. |
| 1576 | |
|
| 1577 | |
`docs/MCP.md` is the forge as a connector, and `docs/STANDARDS.md` is what |
| 1578 | |
it answers by default: `.well-known`, feeds, cards, the manifest. |
| 1579 | |
|
| 1580 | |
`docs/SNAPSHOT.md` is a forge back from a copy: what a nightly snapshot |
| 1581 | |
holds, where it goes, and the one command that restores it. |
| 1582 | |
|
| 1583 | |
A snapshot uploads eight objects at a time. Run logs older than a day go up |
| 1584 | |
bundled per day (cut at 4 MB, so a long history is several objects, never one |
| 1585 | |
huge one); restore unpacks them. While one runs, progress shows on |
| 1586 | |
**Settings → Backups** and as a line in the log every 500 objects or minute. |
| 1587 | |
The terminal has no backups view, so it has no readout. |
| 1588 | |
|
| 1589 | |
**Run a drill** on the same page (admins only) restores the newest snapshot |
| 1590 | |
into scratch, opens it, counts what is there and unseals a value; the page |
| 1591 | |
waits for it, up to 10 minutes, and the last drill shows there as one line, |
| 1592 | |
ending with the drill's time against its budget and each part's time, longest first ("6 min |
| 1593 | |
12 s of its 14 min budget: clone 4 min, download 58 s, …"), or only the time against the |
| 1594 | |
budget when no part was timed; drills from before this show none. The budget is 10 min |
| 1595 | |
plus 1 s per MB of snapshot, at most 45 min (`docs/RECOVERY.md`). |
| 1596 | |
The parts are download, decompress, clone, open, and start and migrate (the |
| 1597 | |
whole side-boot of the restored forge, process start included). The same words, |
| 1598 | |
slowest named, are in the drill's log line and in a red drill's card |
| 1599 | |
("… (download about 8 min, decompress 40 s, …; download was the slowest)"). |
| 1600 | |
Only one runs at a time. See `docs/SNAPSHOT.md`. |
| 1601 | |
|
| 1602 | |
The forge also runs one itself every Sunday 09:00 UTC (a forge that was down |
| 1603 | |
then runs it five minutes after waking, once). Each snapshot's manifest keeps |
| 1604 | |
the five counts of its database; a restore that counts differently is red at |
| 1605 | |
`count` and, like any red drill, one board line and one push. Snapshots from |
| 1606 | |
before the counts were kept are not compared. |
| 1607 | |
|
| 1608 | |
**Failure domains** (a card at the foot of `/settings/backups`; the old |
| 1609 | |
`/settings/resilience` sends there, and `/resilience` is the THREATS.md page): one row per failure domain from |
| 1610 | |
`docs/DOMAINS.md` — what breaks, the words the forge says, and the domain's |
| 1611 | |
last kept drill (when, green or red with its reason, seconds until back) or |
| 1612 | |
the sentence on why it is not drilled. In the terminal it is the **Failure domains** |
| 1613 | |
command (`ctrl+k`; the **Resilience** command is the THREATS.md one), shown to an admin only and read through the same |
| 1614 | |
`listResilience` action. Only the latest drill per domain is shown. |
| 1615 | |
The **Owner's needs** command (admin only, `ownerView`) is the owner's one screen: the forge's needs as the board strip says them, where snapshots go (and any mirror) with the newest snapshot, and every domain with what the owner does. |
| 1616 | |
|
| 1617 | |
A red drill, or none in eight days, is one line for the forge's admin on the |
| 1618 | |
board's forge strip, in the terminal's dashboard (same `forgeNeeds`), and in |
| 1619 | |
MCP `health` and `asks`; it names the failed step and the next green drill |
| 1620 | |
clears it. An admin with a phone URL gets one push per cause (a new red |
| 1621 | |
drill, or the silence), also when eight days pass with no event: the notifier |
| 1622 | |
wakes every 10 minutes for it. The time the forge first owed a drill is kept |
| 1623 | |
at boot in `drill/first-seen`; if the disk will not take it, a warning is |
| 1624 | |
logged and a forge with no drill ever is not flagged. |
| 1625 | |
|
| 1626 | |
Snapshots written to a directory on the forge's own volume, with no off-box |
| 1627 | |
mirror set, are lost with that volume. That is one line for the forge's admin, |
| 1628 | |
"Snapshots are kept on the forge's own volume and mirrored nowhere: losing the |
| 1629 | |
volume loses them too.", on the board's forge strip (linking Settings → |
| 1630 | |
Backups), in the terminal's dashboard (same `forgeNeeds`), in the asks, in |
| 1631 | |
`GET /api/v1/health` and MCP `health`. Anyone else sees nothing. It clears when |
| 1632 | |
the destination moves to another volume or a bucket, or a mirror is set. Every |
| 1633 | |
one of those reads the vitals through `forgeVitals` in `src/actions/health.ts`. |
| 1634 | |
|
| 1635 | |
`docs/EXTRACT.md` is the forge on a machine of its own, and the platform it |
| 1636 | |
grew up in switched off: what a host owes it, the moves in order, and what is |
| 1637 | |
given up. |
| 1638 | |
|
| 1639 | |
### The sentinel |
| 1640 | |
|
| 1641 | |
The forge hunts its own faults (`docs/CREW.md`): a hunt an hour on the |
| 1642 | |
autonomy clock, sooner when a held slot is new. Each finding is one issue with |
| 1643 | |
its evidence, filed in `FORGE_SENTINEL_REPO` (default `trevato/forge`) as the |
| 1644 | |
`sentinel` agent, else the first admin. Evidence is scrubbed of anything |
| 1645 | |
credential-shaped before it is filed. Other findings (`held`, `ledger`, `red`) |
| 1646 | |
are filed anew when they return. |
| 1647 | |
|
| 1648 | |
A check that has never been green is filed only for the sentinel's own repository. In any other repository it stays on that repository's checks and the degraded list. |
| 1649 | |
|
| 1650 | |
A capacity setting away from its default is not a finding: the sentinel files |
| 1651 | |
nothing for it (#3269). On `/settings/capacity` the row of each such setting |
| 1652 | |
carries one muted line, "changed from X N days ago by who", and its **Put back** |
| 1653 | |
button (in the row's own form, beside Save) restores the default through |
| 1654 | |
`changeCapacity` (same validation, record, event and reversal). The line stays |
| 1655 | |
until the value is back at the default; nothing else reminds anyone. The |
| 1656 | |
JSON API and MCP carry the same fact: a setting away from its default has a |
| 1657 | |
`changed` field (`at`, `by`) in the capacity listing (`listCapacity`). |
| 1658 | |
|
| 1659 | |
The `setting:<key>` findings filed before that are all closed; none raises an |
| 1660 | |
ask, and the capacity page's Put back is the one answer. The `sentinel_kept` |
| 1661 | |
table they once used is unread; a migration drops it once the additive rule |
| 1662 | |
(`src/core/releases.json`) allows a drop. |
| 1663 | |
|
| 1664 | |
- **Board:** a site admin's forge strip shows the open findings and the last |
| 1665 | |
hunt; a guest or member sees nothing. |
| 1666 | |
- **Terminal:** press `ctrl+k`, run **Sentinel** — the same line as a toast. |
| 1667 | |
Offered to site admins only. |
| 1668 | |
|
| 1669 | |
### Passports |
| 1670 | |
|
| 1671 | |
A crew run's environment carries `FORGE_PASSPORT`, a 15-minute credential |
| 1672 | |
scoped to `health:read` for the forge alone (`src/kernel/passport.ts`, no |
| 1673 | |
dependency: Node crypto's HMAC and HKDF). It opens one route and nothing else: |
| 1674 | |
|
| 1675 | |
curl -H "authorization: Passport $FORGE_PASSPORT" "$FORGE_URL/api/v1/passport/health" |
| 1676 | |
|
| 1677 | |
Missing, expired, wrong-audience, wrong-scope or forged passports get a 401. A |
| 1678 | |
passport has exactly one valid encoding (padded or altered variants are refused). |
| 1679 | |
|
| 1680 | |
This route proves the mechanism and is not a privilege: it returns what the |
| 1681 | |
anonymous `GET /api/v1/health` already returns. The next edge should guard |
| 1682 | |
something only a passport may reach. |
| 1683 | |
|
| 1684 | |
## Cloud accounts |
| 1685 | |
|
| 1686 | |
**Settings → Cloud accounts** (admins) holds provider keys — Cloudflare, |
| 1687 | |
DigitalOcean, AWS, Azure, Fly, Backblaze, GitHub, Vercel — sealed and write-only. Pasting one |
| 1688 | |
makes the forge check it with read-only calls (listed in the header of |
| 1689 | |
`src/cloud/providers.ts`) and say what it can see; a key the provider rejects |
| 1690 | |
is not stored. It is sealed at forge scope as `CLOUD_<PROVIDER>_<NAME>` (secret |
| 1691 | |
names are capitals, so `cloudflare/main` is `CLOUD_CLOUDFLARE_MAIN`). The page |
| 1692 | |
lists names, the last check, and which run last opened each key; "Every read" |
| 1693 | |
lists each open, and a run's page lists the secrets it held. A run gets a key |
| 1694 | |
by declaring it like any secret. **Write allowed** is off by default: a run |
| 1695 | |
that adds `writes: yes` to its front-matter and declares a cloud key whose |
| 1696 | |
switch is off fails with that reason. The switch gates the forge's hand-out, |
| 1697 | |
not the key — a provider key that can write still can, so paste read-only keys. |
| 1698 | |
**Audit** reads one account and opens one `[audit]` issue per finding (unattached |
| 1699 | |
volumes, powered-off droplets, unassociated IPs, never-active zones) with a |
| 1700 | |
monthly saving estimate, skipping findings already open. It runs in the forge, |
| 1701 | |
not as a crew run; AWS audits look at us-east-1 only; over-provisioned |
| 1702 | |
droplets and forgotten DNS records need metrics or resolution and are not |
| 1703 | |
attempted; Backblaze, GitHub and Vercel audits find nothing yet. No dependency was added. |
| 1704 | |
|
| 1705 | |
Each account can be **rotated** (the new value is checked, then replaces the |
| 1706 | |
old) and **renamed** (re-sealed under the new name, write switch kept). Each |
| 1707 | |
says in one line what it is used for now — the `cloud.json` declarations on |
| 1708 | |
the default branch of the first 100 repositories the admin can see, cached a minute — and |
| 1709 | |
carries a "bring it home" note (`HOME` in `src/cloud/declared.ts`). Every |
| 1710 | |
vendor request goes through `src/cloud/gate.ts`: an admin's verify or audit is |
| 1711 | |
audited as the operator; a hand-out of a key to a run is allowed only if that |
| 1712 | |
repository's `cloud.json` declares the key's vendor (`VENDOR_OF`: the provider's name, but AWS and Backblaze are `s3`), and an undeclared one is |
| 1713 | |
refused before the key is opened. One `cloud_audit` row per request (account, |
| 1714 | |
purpose, host and path, bytes out, when, allowed or refused — no token, no |
| 1715 | |
query) is listed at **Settings → Cloud accounts → Every vendor call today**. |
| 1716 | |
Rows are pruned past 90 days on every write; `at` is indexed. Left out: one |
| 1717 | |
token per vendor is not enforced (several names per vendor stay allowed), and |
| 1718 | |
an agent's own vendor calls beyond the hand-out are not metered. A forge admin reaches the same accounts in the terminal (`:` → Cloud accounts: each account with what it is used for, verified / last used and today's vendor calls; `a` attach, `v` verify, `r` rotate, `n` rename, `x` remove, `w` flip the write switch, `A` audit into an `owner/repo`, `c` every vendor call today (purpose, host and path, bytes out, allowed or refused), the token typed masked) and over MCP (`cloud_accounts` lists them with today's audit rows; `cloud_account` with `verb` attach, verify, rotate, rename, remove, `allow_writes` (with `allowed`) or `audit` (with `repo`) needs an admin-scope token). Both call `actions/cloud.ts`, neither ever shows a token, and anyone else is refused. A rename is refused when the account or the secret `CLOUD_<PROVIDER>_<NAME>` already exists. |
| 1719 | |
|
| 1720 | |
## Share to the forge |
| 1721 | |
|
| 1722 | |
Install the forge to your home screen, then Share a link or text to it from |
| 1723 | |
any app: the home composer opens with it as a draft, and it is only sent when |
| 1724 | |
you tap Post. Signed out, you sign in (or up) first and land on the same |
| 1725 | |
draft. The share target is the web manifest's `share_target`, which only an |
| 1726 | |
**installed PWA on Android and Chrome** offers; iOS Safari does not. |
| 1727 | |
|
| 1728 | |
The home composer's app picker also has **the forge**: that sends no tell to |
| 1729 | |
an app, it keeps what you typed as a thought, exactly as typed (works with no |
| 1730 | |
apps too). Your thoughts from the last day stand at the top of the feed as |
| 1731 | |
your own posts, marked "Received". The terminal has no feed or composer, so |
| 1732 | |
thoughts are web-only there; `POST /api/v1/thoughts` is the other way in. |
| 1733 | |
Within minutes the planner places each thought on the map and answers under it |
| 1734 | |
in the feed, saying where it went; the MCP `thought` tool returns that |
| 1735 | |
placement to the thought's author. |
| 1736 | |
A thought that says an earlier one again does not open a duplicate: it links to |
| 1737 | |
the earlier thought (`repeats`), comments on the issue it placed and raises it |
| 1738 | |
to the front of the ready order, unless that issue is deferred or closed, which |
| 1739 | |
the reply says. A close restatement counts only when the planner reads it as |
| 1740 | |
plain work and nothing in it negates or flips the earlier one; "don't make X" |
| 1741 | |
after "make X" is a reversal, never a repeat. A reversal links the thoughts it |
| 1742 | |
takes back (`supersedes`). `/asked` shows "said again, first on …", "takes back |
| 1743 | |
…" and "superseded by …" under each thought, and the MCP `thought` tool returns |
| 1744 | |
`repeats`, `supersedes`, `repeatedBy` and `supersededBy` thought ids. |
| 1745 | |
|
| 1746 | |
## Link-preview cards |
| 1747 | |
|
| 1748 | |
`/og/repo/owner~name.svg`, `/og/app/slug.svg`, `/og/issue/owner~name~N.svg`, |
| 1749 | |
`/og/pull/owner~name~N.svg` and `/og/run/owner~name~runid.svg` are 1200×630 |
| 1750 | |
social cards. They show only what an anonymous reader can open; anything |
| 1751 | |
private, sealed or unknown is the same 404. |
| 1752 | |
|
| 1753 | |
An app card carries the app's latest look (the production thumb, or a desktop |
| 1754 | |
or phone shot taken as nobody) beside the words; a pull card carries the |
| 1755 | |
production look as "before" and the pull's preview look as "after" (the |
| 1756 | |
preview alone if production has none, nothing if the pull has no look). Only |
| 1757 | |
a look an anonymous reader may fetch is used, of a public app and never a |
| 1758 | |
sealed pull's. The picture is decoded, reduced to fit 480×300 and embedded as |
| 1759 | |
a data URI (no dependency; the forge's own PNG codec), and the look's id and |
| 1760 | |
taking name the card, so a new look is a new card. A look whose file cannot be |
| 1761 | |
read leaves the card as it was. |
| 1762 | |
|
| 1763 | |
Two of them carry a figure beside the words: |
| 1764 | |
|
| 1765 | |
- **Map** — `/og/map/owner~name.svg`: the repository's dependency graph as a |
| 1766 | |
thumbnail, laid out exactly as `/owner/name/map` lays it out (ready green, |
| 1767 | |
working solid accent, blocked grey, waiting/held purple, parked amber, |
| 1768 | |
deferred and done faint), the critical path in the accent; beside it the |
| 1769 | |
open count, the critical path's length and ends, and the next issue a free |
| 1770 | |
hand would take; along the foot `ready · working · blocked · waiting · done`. |
| 1771 | |
Drawn from what a signed-out reader sees: no sealed issue, no run on one. |
| 1772 | |
- **Audit** — `/og/audit.svg`: the policy score as a ring, the share of apps |
| 1773 | |
that hold (sound `contract.json` and no disagreeing probe; an app with no |
| 1774 | |
contract does not hold — 3 sound, one disobeying, one unread is 2 of 4, |
| 1775 | |
50%), with the top three findings worst first. Public repositories' apps only, |
| 1776 | |
as `/audit` shows them signed out. |
| 1777 | |
|
| 1778 | |
The map page (public repositories) and `/audit` name their `.png` as |
| 1779 | |
`og:image` with `twitter:card=summary_large_image`; every other page keeps the |
| 1780 | |
forge icon. |
| 1781 | |
|
| 1782 | 318 |
## Develop |
| 1783 | 319 |
|
| 1784 | 320 |
```sh |
|
|
@@ -1965,58 +501,6 @@ carries today, each entry under the issue that owns it; an entry that is no |
| 1965 | 501 |
longer hollow turns the check red, so the list only shrinks. |
| 1966 | 502 |
`bun scripts/hollow.ts` prints the same lines on their own. |
| 1967 | 503 |
|
| 1968 | |
## Following an upstream |
| 1969 | |
|
| 1970 | |
A followed repository's home shows admins "N commits behind owner/name" (or "conflicts with owner/name: resolve") from the last stored check, with an **Update** button: a same-origin POST form to the settings page's check action that returns to the home, no JS. The terminal's About card (`I`) has the same words as a `behind` row, and `u` is the same update (`behindWords`, `src/actions/upstream_follow.ts`). |
| 1971 | |
|
| 1972 | |
A repository can follow an outside git branch (`repo_upstream`, https or |
| 1973 | |
http only, no credentials in the address). The clock checks each following |
| 1974 | |
repository every 30 minutes (`checkDue`); the result is the follow row: |
| 1975 | |
upstream tip, commits behind, the pull carrying the last proposal, and |
| 1976 | |
`error`. In the terminal, a repository's About card (`I`) shows an |
| 1977 | |
`upstream` row (address, branch, last check, standing; `off` when following |
| 1978 | |
is off) to an admin of the repository and to nobody else. `u` checks now, |
| 1979 | |
while following is on, through the settings page's own action. `f` turns |
| 1980 | |
following on or off through `setFollowUpstream`; it keeps the saved address |
| 1981 | |
and branch, and when none is saved it refuses with "set the upstream address |
| 1982 | |
in the web settings first" (the address is set on the settings page). A check fetches only when the tip moved, into |
| 1983 | |
`refs/upstream-follow/<branch>` (not a branch), then: |
| 1984 | |
|
| 1985 | |
- **Nothing new:** nothing happens. |
| 1986 | |
- **New commits that merge cleanly into the default branch:** the forge |
| 1987 | |
writes `upstream/<tip7>` at the fetched head and opens one pull from it, |
| 1988 | |
as the person who turned following on. A second check with the same tip |
| 1989 | |
opens nothing, and a proposal someone closed is not reopened. |
| 1990 | |
- **A newer tip while that pull is open:** the pull is moved to |
| 1991 | |
`upstream/<newtip7>` and its title and body rewritten; the old |
| 1992 | |
`upstream/<old7>` branch stays, as a person may have built on it. |
| 1993 | |
- **A conflict:** the row's `error` reads `conflict`; no branch, no pull. With |
| 1994 | |
autonomy on, the check also opens one issue for that head and queues one |
| 1995 | |
converge run for the crew to resolve it; with autonomy off, the note on the |
| 1996 | |
row is all there is. |
| 1997 | |
- **A planted branch:** if `upstream/<tip7>` already exists anywhere but at |
| 1998 | |
the fetched head, the check fails (`squatted`), says so on the row, opens |
| 1999 | |
no pull and leaves the branch as it was. |
| 2000 | |
|
| 2001 | |
## Ranked forks |
| 2002 | |
|
| 2003 | |
`/:owner/:name/forks` and the terminal's forks screen (`f` in a repository) |
| 2004 | |
list a repository's direct forks the same way, so the best of a fork tree |
| 2005 | |
rises. Order: commits ahead of the parent's default branch (most first), |
| 2006 | |
then fewest behind, then the newest tip commit. Each row reads `N ahead · M |
| 2007 | |
behind` and when the fork last moved; a fork that is ahead has a **Compare** |
| 2008 | |
button (on the page) to its `/pulls/new`, which proposes it to the parent. |
| 2009 | |
Each fork's own forks are listed indented beneath it (one more level, on the |
| 2010 | |
page and in the terminal alike); the forks below those are a count, `and N more |
| 2011 | |
forks below`, linking on the page to that fork's forks page. A private fork is |
| 2012 | |
never named or counted to someone who cannot read it. |
| 2013 | |
|
| 2014 | |
Forks with nothing ahead (or whose standing git cannot read) follow under |
| 2015 | |
`N forks with nothing new`. At most 60 forks are ranked per view; past that |
| 2016 | |
the page says `Ranking the first 60 of N forks`. A fork's standing is cached |
| 2017 | |
by the pair of tips (`actions/forks.ts`), so a view of forks that have not |
| 2018 | |
moved costs one `git log` per fork. |
| 2019 | |
|
| 2020 | 504 |
## Shape |
| 2021 | 505 |
|
| 2022 | 506 |
``` |
|
|
@@ -2164,166 +648,58 @@ a cookie — even that works script-free. Still **zero runtime dependencies**. |
| 2164 | 648 |
Not yet: containers around apps, custom domains, and a gate that lands a |
| 2165 | 649 |
pull on its own once review and checks agree. |
| 2166 | 650 |
|
| 2167 | |
## Fuzz and chaos |
| 651 |
## Where the rest is |
|
|
|
| 2168 | 652 |
|
| 2169 | |
Invariants are proven by faults, not by hope. No dependencies were added. |
| 653 |
This page is the front door. Everything else is a file of its own under |
| 654 |
`docs/`, each about one thing: |
| 2170 | 655 |
|
| 2171 | |
- **Fuzz** (`test/fuzz-parsers.test.ts`, helper `test/helpers/fuzz.ts`): the |
| 2172 | |
manifest and check parsers, `contract.json`, hostnames, slugs, markdown, the |
| 2173 | |
diff reader and the API's JSON bodies take a seeded generator's cases. A |
| 2174 | |
failure prints `FUZZ_SEED` and the shrunk case; rerun with |
| 2175 | |
`FUZZ_SEED=<n> FUZZ_RUNS=<n> bun test test/fuzz-parsers.test.ts` and keep the |
| 2176 | |
case as a fixed test in `test/fuzz-found.test.ts`. |
| 2177 | |
- **Chaos** (`src/chaos/`): a side instance on a fresh temporary data directory |
| 2178 | |
(it cannot name the live forge or a customer's app) takes four faults — |
| 2179 | |
SIGKILL mid-run, a dropped SQLite connection, a temp area that refuses writes |
| 2180 | |
(a stand-in for a full disk; nothing is actually filled), a delayed loopback — |
| 2181 | |
and after each asserts: no run vanished and all are in a known state, the |
| 2182 | |
database is whole and every open pull's head is in its repo, a snapshot taken |
| 2183 | |
afterwards restores, no sealed value is in any file in the clear. |
| 2184 | |
`test/chaos.test.ts` runs a small version in the gate (`CHAOS_SEED=<n>`). |
| 2185 | |
- **Nightly**: `bun src/chaos/run.ts [--seed N] [--rounds N] --report` with |
| 2186 | |
`FORGE_URL`, `FORGE_TOKEN`. A broken invariant files an issue (fault, seed, |
| 2187 | |
log) and stands it under the umbrella `FORGE_CHAOS_PILLAR_ISSUE` (the |
| 2188 | |
P0 Trust issue waits for it); a quiet night comments one line on |
| 2189 | |
`FORGE_CHAOS_RECOVERY_ISSUE`. Scheduling it is the operator's: this repo |
| 2190 | |
ships the command, not a timer. |
| 656 |
**Using it** |
| 2191 | 657 |
|
| 2192 | |
## Check tiers |
| 658 |
- [`docs/VISION.md`](docs/VISION.md) — what the forge is for, and the order of work. |
| 659 |
- [`docs/STUDIO.md`](docs/STUDIO.md) — the front door in full: the five words and where each is decided, the box, the three lists, a tell, a run and a change each on its own page, and the board. |
| 660 |
- [`docs/TOUR.md`](docs/TOUR.md) — every other page and screen, with each of its doors: the terminal over SSH, the home and its figures, review, signing in, the page-view beacon, the board's notes, long threads, who can see a repository, archive, where a repository came from, big directories, search, sharing to the forge, link-preview cards and choosing among variants. |
| 661 |
- [`docs/ASKS.md`](docs/ASKS.md) — what waits on you and how to answer it: waiting pulls, mutes, wave proposals, feed chips and the decider. |
| 662 |
- [`docs/FEED.md`](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. |
| 663 |
- [`docs/PULLS.md`](docs/PULLS.md) — resolving a conflicted pull without a clone. |
| 664 |
- [`docs/GIT.md`](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories. |
| 665 |
- [`docs/ROLES.md`](docs/ROLES.md) — guest, member and admin, and what each may do. |
| 666 |
- [`docs/MCP.md`](docs/MCP.md) — the forge as a connector; [`docs/STANDARDS.md`](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest. |
| 2193 | 667 |
|
| 2194 | |
`forge.json`'s `check` may declare `fast` beside `run` (the full gate; `full` names it explicitly). `bun scripts/test-shards.ts --tier fast --diff <base>..<head>` runs the tests that touch the diff (changed, named for a changed source file, or importing one within two hops), every test the times table records under 1 s, and any named by `--learned a,b`. `src/checks/tiers.ts` decides; it adds no dependency. |
| 668 |
**Apps and the crew** |
| 2195 | 669 |
|
| 2196 | |
In the forge itself: a pull's check runs `fast` when the manifest has one, in a clone holding the fork point and the head as `FORGE_CHECK_BASE` and `HEAD` (`origin/main` is not a ref there), with `FORGE_CHECK_LEARNED` naming tests earlier land attempts taught the pull (`src/checks/history.ts`, `learned.ts`). Landing (`landIfGreen`) runs `full` once on the merge with its base before merging (`src/checks/landing.ts`); red comments on the pull with the tests named, is remembered per head and base, and the files ride the pull's next fast run. The pull page's checks line shows the tier words. While a check runs, the line (page and terminal, one reader: `runningProgress`, `src/checks/progress.ts`) adds `61 of 170 files · 812 passed, 0 failed`, taken only from the shard runner's own log lines and left out when not yet known; on the page it is a live region (`GET …/pulls/:number/checks-line`) that re-asks every 5 seconds only while the check is queued or running; the same line is drawn on the studio's pull request page (`/pulls/:number`), and the terminal's pull screen re-reads on a 5-second tick while the check is queued or running. The checks line (page and terminal) also names the base tip the fast check merged with ("checked against main at abc1234"); `GET …/pulls/:number/checks` returns it as `run.baseOid`, empty for a default-branch push and for the `full` land gate. When the base has moved since that green, the page's line reads "Checked against an older main" (a clock, not a green mark) and the terminal's reads `checks: passed against an older main`. `GET /api/v1/repos/:owner/:name/pulls/:number` and the MCP `pull` tool carry `checkStale`, true only for an open, unmerged pull whose last check recorded a base that is no longer the base branch's tip. With `require_checks` on, such a stale green counts as a check still to come (`checksHold` in `src/checks/stale.ts`): the page reads "Waiting on checks" and disables **Merge pull request**, the terminal refuses `m` the same way, and the inbox's `look` ask and an autonomous repository's `review` ask wait for the re-check. The ask side works from the stale-ask sweep's last read of the base tip, so it drops after the next sweep, not the instant the base moves. `mergePull` itself does not yet refuse a stale green (API and MCP merges are not held). |
| 670 |
- [`docs/APPS.md`](docs/APPS.md) — the contract for apps, previews and runs: making an app, the vocabulary, and the rules that are mechanical rather than advisory. |
| 671 |
- [`docs/CONTRACT.md`](docs/CONTRACT.md) — who may do what, as a table: an app's `contract.json`, the one evaluator every app decides with, the audit that reads it from git, the probe that knocks on the running app, and the wall a person alone crosses. |
| 672 |
- [`docs/WEBSPEC-PREVIEWS.md`](docs/WEBSPEC-PREVIEWS.md) — the website specification run over every preview, report-only. |
| 673 |
- [`docs/CREW.md`](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write. |
| 674 |
- [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 675 |
- [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 676 |
- [`docs/PLANNING.md`](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew. |
| 677 |
- [`docs/FLEET.md`](docs/FLEET.md) — one change across many repositories, runs or issues: a query, a dry-run plan of every row before → after, a receipt, and undo. |
| 678 |
- [`docs/HOME-EVAL.md`](docs/HOME-EVAL.md) — scoring the owner's home with a reader who has no context. |
| 679 |
- [`docs/PROFIT.md`](docs/PROFIT.md) — the profit slider: cost against responsiveness. |
| 2197 | 680 |
|
| 2198 | |
A check's run page (`/:owner/:name/runs/:id`) opens with three rows, install, typecheck and tests, each `waiting`, `running`, `passed`, `failed`, `stopped` or `unknown`, always present so nothing shifts (`checkSteps`, `src/checks/summary.ts`). The log has no step boundaries, so they are inferred from it: the echoed `$ command` lines and the shard runner's banner (typecheck precedes it). A log longer than the 512 KB window whose tests cannot be seen reads `unknown` rather than wrong; readers who may not see the log get no rows; while the run is open the page fetches the rows again when such a line lands and when the run ends. No per-step duration is shown: the log carries no timestamps. The terminal's pull screen appends the same rows to its checks line (`install passed · typecheck passed · tests running`, `stepsLine`, `src/checks/progress.ts`), read through the same `checkSteps` and withheld from a reader who may not read the log. |
| 681 |
**Other forges, and the forges this one spawns** |
| 2199 | 682 |
|
| 2200 | |
Under the rows, once the tests have begun, the run page shows a progress bar of files started out of the banner's count, the passes and failures so far, the file(s) of the live shard running now, and each failure pinned with its first three lines of output (`parseTestsRow`, `src/checks/tests-row.ts`). It reads only the shard runner's lines and `bun test`'s `(fail)` markers; a figure the log does not give is left out. Batched shards (run side by side) report whole when they finish, so they name no running file. The words line and a slot for the pins are always rendered (the slot reserves height only while the run is open, and scrolls past 9em) so the log never shifts; while the run is open the bar is named "Test files started" and is indeterminate (no value) when the count is unknown, and once the run has ended an unknown count shows no bar; only the rows, the bar and the words are a live region, the pins are outside it, and a slot with pins is a focusable labelled region the refetch patches in place. The terminal's checks line appends the same: `tests 2 of 9 files started · 812 passed, 1 failed · failing: <names>` (`testsRowLine`, read in `stepsLine`, withheld from a reader who may not read the log). |
| 683 |
- [`docs/ORIGINS.md`](docs/ORIGINS.md) — one workbench over GitHub, Forgejo and Gitea: connecting, mirroring, writing through, migrating, and following an outside branch. |
| 684 |
- [`docs/SPAWN.md`](docs/SPAWN.md) — a forge births a forge: the claim link, the family, sizes and the child quota. |
| 685 |
- [`docs/FOLLOWING.md`](docs/FOLLOWING.md) — a fork that keeps up with its parent: where it stands, taking updates, the crew's part when both sides moved, and ranked forks. |
| 2201 | 686 |
|
| 2202 | |
Below them the run page has a per-look web-vitals table: LCP, CLS and TBT (`webvitals()`, `src/release/webvitals.ts`) each beside its good/poor thresholds and grade, a vital with no recorded entry as `n/a` (never zero), and a look run without a browser as one `skipped: no browser` line (`vitalsRows`, `src/checks/summary.ts`, as plain words; `vitalsTable`, `src/web/studio/vitals.ts`, escapes and renders them as a dense `.table` in a `.table-scroll` wrapper, so a phone scrolls it sideways). It is report-only: it has no verdict, so nothing in it can turn a check red. The terminal's checks line appends the same as `vitals home: LCP 3100 ms needs-improvement, …` (`vitalsLine`, `src/checks/progress.ts`, via `stepsLine`, withheld with the log). Both read one file beside the run's log, `runs/<id>.vitals.json` under `FORGE_DATA` (`src/checks/looks.ts`): an array of `{look, entries}` or `{look, skipped}`, validated and bounded on read (bad JSON, a file over 1 MB, or a malformed look is dropped). The looks run writes it (`startLooks`, `src/looks/service.ts`): the browser it already launches for a look (`src/looks/capture.ts`) opens on `about:blank` with a loopback DevTools port, installs a `PerformanceObserver` for `largest-contentful-paint`, `layout-shift` and `longtask` before the page's first script, navigates through the grant door, and after load reads the entries back and takes the picture over the same socket (no new process, no dependency, no Lighthouse; a type the browser lacks is an empty list). The phone and desktop looks are filed under the commit they picture (`runs/commit-<repo>-<oid>.vitals.json`, `writeCommitLooks`), so they become rows of that commit's check whenever it exists, even if it was made after the look (a run's own `runs/<id>.vitals.json` wins); with no browser they are `skipped: no browser` and the check is untouched. Until a check has a file, the table and the clause are absent. |
| 687 |
**Running it** |
| 2203 | 688 |
|
| 2204 | |
While the default branch's own last check is red, the forge holds its pulls' checks queued rather than fail each one on the same test (`src/checks/red_main.ts`): the vitals carry one card naming the failing test and how many checks wait, and main going green releases them. A check a person asks for again runs at once, and no check is held more than 20 minutes, so the pull that fixes main can always show green. |
| 689 |
- [`docs/OPERATING.md`](docs/OPERATING.md) — configuration, health, how a release proves itself, flags, limits, check tiers, fuzz and chaos, and writing a database guard. |
| 690 |
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — the principles and the reasoning behind the shape above. |
| 691 |
- [`docs/CACHING.md`](docs/CACHING.md) — what the forge keeps between requests to make `/map` and `/` fast: each map lane's plan and each viewer's feed candidates, stamped with SQLite's own write clock so any write is seen on the next read, keyed so one person's view never reaches another, and the budgets `test/snappy.test.ts` holds. `bun run bench/snappy.ts` prints p50/p95 and SQL statements per request for both pages, warm and cold. The content-addressed assets answer a revalidation with `304`. |
| 692 |
- [`docs/METRICS.md`](docs/METRICS.md) — `GET /metrics` for Prometheus. |
| 693 |
- [`docs/THREATS.md`](docs/THREATS.md) — who would attack the forge, what each wants, and the test behind every protection (`/resilience` reads it). |
| 694 |
- [`docs/DOMAINS.md`](docs/DOMAINS.md) — the ways the forge stops, and what it says when each does. |
| 695 |
- [`docs/SNAPSHOT.md`](docs/SNAPSHOT.md) — a forge back from a copy: what a nightly snapshot holds, where it goes, the drill, and the one command that restores it. |
| 696 |
- [`docs/RECOVERY.md`](docs/RECOVERY.md) and [`docs/RESTORE.md`](docs/RESTORE.md) — the forge back at 3 a.m., and on a fresh machine; print them. |
| 697 |
- [`docs/CLOUD.md`](docs/CLOUD.md) — clouds are capacity, never dependency, and the cloud accounts an admin keeps. |
| 698 |
- [`docs/CLUSTER.md`](docs/CLUSTER.md) — the check lane as Kubernetes Jobs. |
| 699 |
- [`docs/EXTRACT.md`](docs/EXTRACT.md) — the forge on a machine of its own, and the platform it grew up in switched off: what a host owes it, the moves in order, and what is given up. |
| 700 |
- [`docs/ops/ROLL.md`](docs/ops/ROLL.md) — rolling a release. |
| 2205 | 701 |
|
| 2206 | |
`GET /api/v1/repos/:owner/:name/commits/:oid/check` answers the default branch's check of one full commit id, `{commit, checkedCommit, state, runId, summary, finishedAt}` with `state` `ok`, `failed`, `pending`, `superseded` (every check of it was cancelled, as when main moved on before it ran, and none will follow) or `none`, to whoever may read the repository's runs (`src/checks/commit_check.ts`). When the commit's own check is `superseded` or `none`, the newest green (or else running) check of a later main commit that contains it answers instead, and `checkedCommit` names that commit: main moving on never holds a release (#3966). It is half of the forge's release gate: the release workflow builds only once it reads `ok` for the tagged commit, and the build boots the image it made (`docs/ops/ROLL.md`, "The gate of record"). |
| 702 |
**Design and records** |
| 2207 | 703 |
|
| 2208 | |
A test file the shard gate clears because it passed alone is still a failure: when the default branch's own check log says `fails only beside others: <files> (shard-mates: <files>)`, the runner opens one issue per file (at most 5 per run, the rest named in the log as not filed) or comments on the one already open. The issue is authored as the repository owner and its body says it was filed by the check runner. A pull or other branch's check files nothing. |
| 2209 | |
|
| 2210 | |
## Flags |
| 2211 | |
|
| 2212 | |
A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. |
| 2213 | |
|
| 2214 | |
Settings → Usage is one page for a forge admin, opening at `/settings/spend`, with four tabs: Spend, Routes (`/settings/usage`), Capacity (`/settings/capacity`) and Telemetry (`/settings/telemetry`); each tab keeps its own URL and permission. Spend is admin-only (anyone else gets 404) and holds what the crew's runs spent, by credential, app, agent, issue and run kind; it used to sit on Subscriptions, which now links to it. Routes lists every route the router knows: its requests over the last 30 days, by role and by surface, and the hour of its last use. Routes with no request in the window are listed first, never-used ones before the longest-unused, as candidates to drop; the rest follow by count. Counts are per pattern, not per method. `usage` is also a boards source, for an admin only (an agent is refused); its `hour` column is text like `2026-10-16T13`, so group by a column such as `pattern` or `role`, not by a time bucket. In the terminal the same admin opens the command palette (`ctrl+k`), picks Usage, and reads the Routes list only — the spend breakdown, capacity and telemetry tabs are web-only, and the terminal has never shown spend; an agent is never an admin there either. |
| 2215 | |
|
| 2216 | |
## Limits |
| 2217 | |
|
| 2218 | |
Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it. |
| 2219 | |
|
| 2220 | |
Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own, how many `previews`, `runs` and `checks` it may have going at once — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. The `apps` limit is enforced (`src/actions/app_limit.ts`): with one set, an account owning that many apps is refused one more, unless the actor is a forge admin; with none set nothing is asked. The check and the insert are separate statements, so two concurrent creates can pass by one. The refusal reads "This account is at its limit: 3 of 3 apps. Remove one to make room." and each door answers it its own way: the API (`POST /api/v1/apps` and `POST /api/v1/repos/:owner/:name/app`) a 403 `{"error": "app_limit", "message", "limit", "count"}`; the new-app page and the app settings form a 422 with the sentence on the form; make-it-yours the same sentence as an invalid error, before the fork, so no repository is left behind. The `repositories` limit is enforced too (`src/actions/repos.ts`): with one set, an account owning that many repositories (sealed forks are not counted) is refused one more by create, fork, make-it-yours, mirror, migrate and new app, before anything is made on disk. Forge admins and agents are exempt, and creating under an organisation is not counted: the quota is per account, so only a repository the actor owns itself is asked. The refusal reads "2 of 2 repositories"; the API answers 403 `{"error": "repository_limit", "message"}`, the pages say "Not made: 2 of 2 repositories." on the form (a 422), and the terminal shows it as a toast. The check and the insert are separate statements here as well, so two concurrent creates can pass by one. The `storage` limit (megabytes, `src/core/storage.ts`) bounds the bare repositories an account owns plus its apps' data: a push, create, fork, make-it-yours, mirror, migrate or new app by a non-admin, non-agent account at the limit is refused before anything is written (the account asked is the owner the repository would belong to, an organisation included); a push is refused before git runs, as a git error over HTTP and SSH ("storage limit reached: 1.9 GB of 2 GB"), and a push that would pass it is capped inside git (`receive.maxInputSize` = the room left) so it is stopped in quarantine and the repository is as it was. Every other door answers a refusal as `storage_limit`, not `repository_limit`: the API a 403 `{"error": "storage_limit", "message"}` with the message "…storage limit: 1.9 GB of 2 GB", the pages "Not made: …" on the form (a 422), the terminal a toast. A mirror's periodic sync fetch and a migrate import mark the figure stale without walking the disk, so the next check measures again and no account can mirror past the limit unseen; the one fetch that crosses the limit is let in, the next create is refused. The figure is cached per account in memory and re-measured in the background after each push, walked on the request path only when it must be: the first look at an account waits for one walk, and so does the first check after a push, create, fork, mirror or delete changed the disk (a walk already under way is joined, not repeated). An account found at its limit is measured again before it is refused, so a deletion frees it with no restart, but at most once every 5 seconds, so retries by a locked-out client do not scan the disk. Two pushes at once can each pass. Three more limits bound what an account has going at once, each unset by default and never asked of a forge admin: `previews` (live previews), `runs` (concurrent agent runs) and `checks` (concurrent checks), counted across the account's repositories and asked before the thing starts. A preview past its limit does not start: its deploy run — the check the pull shows — fails with "not started: 1 of 1 live previews", and a sleeping preview stays asleep when woken at the limit. A run or check past its limit stays queued, with "queued, not started: 1 of 1 concurrent runs" (or "concurrent checks") in its log, and starts when one of the account's own ends; none is dropped. An admin sets the three running limits exactly like the other two: the `previews`, `runs` and `checks` fields on `/admin/limits`, or `{"quotas": {"previews": 3, "runs": 2, "checks": 1}}` to `PATCH /api/v1/admin/settings`. In the terminal the same admin opens the command palette (`ctrl+k`), picks Account quotas, and sees every key with its limit and last change; enter sets one (empty for no limit), `x` clears it, both through the same validation. |
| 2221 | |
|
| 2222 | |
Crew teams (`src/core/teams.ts`, `src/actions/teams.ts`) — a named group of agents with a charter (what it owns, what it reviews) and a dial (a whole number from 0 to 100, its share of builder slots and budget) — start as four: design, data science, security and platform, dials at 0. A forge admin who is a person sets each team's charter, dial and members at `/settings/teams` (linked from the Crew page), with `GET /api/v1/admin/teams` and `PUT /api/v1/admin/teams/:slug` (`{"charter", "dial", "members"}`, each optional, a left-out field is kept; members are agent ids), the `teams` MCP tool (dry by default, admin scope), or **Crew teams** in the terminal palette (enter sets the dial, `c` the charter, `m` the members). The dials of all teams together may not pass 100%; an agent may be on several teams, whatever its model. A refusal carries the code `bad_dial`, `bad_charter`, `over_100` or `bad_member` and the same sentence on every door: the API a 422, the page a message on the form, the MCP tool an error (its dry run answers the refusal the real call would, not a plan), the terminal the sentence in the prompt. Who is let in differs by door: the page and its stream answer a non-admin or an agent the 404; the API answers a non-admin a 403 and an admin agent a 404; the MCP tool says `not_found` to both. Each change is kept with who made it (listed on the page) and appended as a `team.changed` event for the audit; an open `/settings/teams` follows it over `GET /settings/teams/stream` (a save from any door updates the total, the cards and the change list in place, leaving a card alone while one of its fields has focus). **Dials at work** (`src/crew/team_share.ts`): an issue belongs to the team whose slug is one of its labels (`design`, `security`, …). Dispatch orders queued agent runs so each team's share of the last day's starts follows its dial; unlabelled work takes the share the dials leave, and with every dial at 0 the queue runs oldest first as before. A planning run is told each team above zero, its dial, its charter and how many labelled issues are open, and is asked to plan and label work for every one of them. A zero-dial team's label owns nothing extra: its issues count as unlabelled, so they are not starved. Not yet: no page, terminal or MCP view shows the effective share; fairness counts every agent run started (planner and reviewer runs too), not builders only; the dial does not limit budget, and nothing but the planner's brief enforces that each team has planned work each day. |
| 2223 | |
|
| 2224 | |
Cross-team work (`src/core/issue_teams.ts`, `src/actions/issue_teams.ts`) — an issue names up to two teams, and the pull whose body closes it (`Closes #N`) is sent to review once per named team. A writer names them on the issue page (Details → Teams), with `PUT /api/v1/repos/:owner/:name/issues/:number/teams` (`{"teams": ["design", "data-science"]}`; `GET` reads them back; a non-list is a 400, more than two or an unknown slug a 422, a mirror a 409), with the `issue_teams` MCP tool, or with `t` on an issue in the terminal — all through `nameTeams`, so the refusals and their sentences are the same. For each named team without a current verdict, a crew reviewer who is a member of it gets its own run (one reviewer on both teams gets one run, and their verdict answers both). The pull page and its Files step show each team's verdict, or that it is awaited; the terminal's pull screen shows a row per team in the Merge box (`Design: approved by quill`, `Data science: awaited`, with `(on an older head)` when the verdict is stale). A team nobody on the crew can ask is not a reason to skip review: when no named team has a reviewer here (teams start empty) the ordinary single review is sent, and when only some do, the rest wait and lapse to a person with the hour like any review. Not done: a team's approval is shown but not yet required by the merge gate. |
| 2225 | |
|
| 2226 | |
The forge setting `signup` (`src/settings/signup.ts`) is `open`, `invite` or `closed`, and `open` is what a forge with nothing stored reads as. A new forge — a fresh data directory or a spawned child — stores `invite` when it first boots; a forge that already has a stored value, or has run before without one, keeps what it had (no migration rewrites it). The seeding is done by `createApp` right after it makes the database, so a crash between those two steps would leave that forge `open`. **A new forge therefore refuses its first signup too**: make the first admin with `--first-admin` (a spawned forge's claim does it for the child), or set `FORGE_SIGNUP=open` before the first boot. A forge admin who is a person changes it live — **Signup** on `/admin/limits`, `PATCH /api/v1/admin/settings` (`{"signup":"closed"}`) or the `signup` MCP tool (dry by default, admin scope, never an agent) — with no restart; any other value is a 422. `FORGE_SIGNUP` sets it at boot, and a wrong value stops the boot with a plain message; while it stays set, every restart or deploy writes it again over a change made on the page (kept as a change by `boot`), so unset it to let the page decide. Every change is kept with who made it and when, and listed beside the form. While signup is `closed` (and `invite`, for anyone without an open invite link), `/signup` is one sentence with no form, the API and passkey signup answer 403 `signup_closed`, the terminal offers no Create account and refuses (with `invite` it asks for the token instead), and a first edge sign-in for a new name is 401. Existing accounts still sign in. A fresh forge's first account is held to the setting too; the operator's `--first-admin` link and a spawned forge's claim make the admin whatever it says. |
| 2227 | |
|
| 2228 | |
### A cell's memory ceiling |
| 2229 | |
|
| 2230 | |
A check, test or app cell asks for memory in `forge.json` (`check.limits.memory`, `app.limits.memory`, MiB) and gets it up to the machine's ceiling. The ceiling is `FORGE_CELL_MEMORY_MAX_MIB` when set; otherwise 75% of the pod's `/sys/fs/cgroup/memory.max`; otherwise 4096 where the host states no limit. A declaration above the ceiling is held to it, and a check's log shows (before its command runs) `warning: forge.json declares memory 8192 MiB; this machine grants at most 4096 (…)`. A cell stopped for memory says whether its limit was the ceiling, `forge.json` or the default. The warning is for checks only: an app declaring `app.limits.memory` above the ceiling is held to it without a line saying so, and the settings page does not show the cut either (#3134). |
| 2231 | |
|
| 2232 | |
## Choosing among variants |
| 2233 | |
|
| 2234 | |
A tell with `variants: n` starts n builders whose pulls share a variant group. |
| 2235 | |
`/:owner/:name/variants/:group` is the deck: one card per open sibling with its |
| 2236 | |
look and preview, linked from each sibling's change page. A writer likes or |
| 2237 | |
dislikes each (plain form posts; on touch a mostly-horizontal swipe, with a |
| 2238 | |
confirm before a dislike), through `POST /api/v1/repos/:owner/:name/pulls/:number/variant` |
| 2239 | |
(`{"answer","reason"}`), the `variant` MCP tool, or `+`/`-` on a pull in the |
| 2240 | |
terminal. Each person has one vote per pull, kept as a `lean` row in the |
| 2241 | |
decision ledger. A dislike closes the pull and leaves its one-line reason as a |
| 2242 | |
comment the builder reads; the last open sibling shows Ship. Readers see the |
| 2243 | |
cards and no buttons. |
| 2244 | |
|
| 2245 | |
While two or more siblings are open and none has shipped, a writer's feed holds |
| 2246 | |
one **Choose** item for the group ("choose among N open variants of …") that |
| 2247 | |
links to the deck, whatever the number of siblings; it is never muted away. |
| 2248 | |
Once a sibling ships or all but one close, the item leaves the feed. The |
| 2249 | |
terminal has no feed; the item is in `GET /api/v1/feed` and the MCP feed tool |
| 2250 | |
as kind `choose`. |
| 2251 | |
|
| 2252 | |
### OG cards as PNG |
| 2253 | |
|
| 2254 | |
`/og/{repo,app,issue,pull,run,map}/<ref>.png` and `/og/audit.png` are the same public cards as the `.svg`, |
| 2255 | |
rasterized (1200×630) for the link previews that refuse SVG. Same refs, same |
| 2256 | |
404s, same ETag-per-inputs (suffixed `-png`). Each PNG is rendered once and kept |
| 2257 | |
under `$FORGE_APP_DATA/og-png/<sha256 of the inputs>.png`; the cache holds at |
| 2258 | |
most 512 files (`OG_PNG_CAP` in `src/web/og/png-cache.ts`), least recently used |
| 2259 | |
out first. Cold is about 120 ms, warm a file read. If the rasterizer will not |
| 2260 | |
load or render, the route answers 503 with `retry-after: 30` and logs; nothing |
| 2261 | |
else notices, since the rasterizer is only loaded on the first PNG request. |
| 2262 | |
|
| 2263 | |
**Dependencies (one, vendored, not installed):** `package.json` `dependencies` |
| 2264 | |
stays empty — the preview has no install step, so the files are committed under |
| 2265 | |
`src/web/og/vendor/`, unmodified. |
| 2266 | |
|
| 2267 | |
- *resvg* (`@resvg/resvg-wasm` 2.6.2, `resvg.mjs` + `resvg.wasm`): an SVG |
| 2268 | |
renderer compiled to WebAssembly, so no native binary or platform build. Why: |
| 2269 | |
rasterizing text and gradients correctly is not a 20-line file. 2.4 MB wasm + |
| 2270 | |
17 KB glue. Licence MPL-2.0. |
| 2271 | |
- *DejaVu Sans, Sans Bold, Sans Mono, Sans Mono Bold* (2.37): the fonts the PNG |
| 2272 | |
is drawn in, so its text is the same on every host (the SVG names system |
| 2273 | |
fonts; the card clamps text with a deliberately wide estimate, which these |
| 2274 | |
fit). 2.0 MB. Free licence, `vendor/DEJAVU-LICENSE`. |
| 2275 | |
|
| 2276 | |
Not done: the SVG route is unchanged and still the card's source of truth; the |
| 2277 | |
rasterizer runs on the main thread (about 100 ms once per distinct card). |
| 2278 | |
|
| 2279 | |
Every read of a flag is counted in memory by UTC day and variant (on or off) and written in one batch every ten seconds and at exit, so a request runs no extra query. Beside each flag, Settings → Flags shows its reads per variant for the last 7 and 30 days. When one variant leads, "Make on/off the default" sets the flag to that value for everyone (user, role and percentage rules are cleared), and opens an issue on the forge's repository asking the crew to remove the losing path and the flag; if the issue cannot be opened, the flag is left as it was. The button shows only when the 30-day leader has at least 50 reads in all, the variants are not tied, and the flag's forge value is not already the leader. In the terminal the Flags screen shows the same `Uses, 7 days / 30 days` line for the flag under the cursor, and `w` does the same as the button (same admin, non-agent gate). Counts are of reads, not of people. |
| 2280 | |
|
| 2281 | |
## Writing a database guard |
| 2282 | |
|
| 2283 | |
A trigger's `RAISE(ABORT, '…')` is answered as a 403 to every caller, |
| 2284 | |
anonymous ones included. Start the message with `forge: ` and write it for |
| 2285 | |
anyone: that is what is shown, verbatim. Any other message is answered with |
| 2286 | |
`refused` and logged in full (`refused by the database`, `reason=…`) for the |
| 2287 | |
operator. (The three guards of migrations 62, 70 and 81 predate the prefix and |
| 2288 | |
are known by name in `src/http/refusal.ts`.) The terminal's toasts follow the |
| 2289 | |
same rule. A guard installed by hand without the prefix answers `refused` |
| 2290 | |
until it is re-created with `forge: ` in front of its message. |
| 2291 | |
|
| 2292 | |
`reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added. |
| 2293 | |
|
| 2294 | |
A client's first screen offers "Ask for something" on each app they may direct |
| 2295 | |
(`mayDirectCrew`): on the web a link to `/:owner/:name/tell?from=/&ask=1` (no |
| 2296 | |
script), in the terminal the `t` key on the app under the cursor. Both go |
| 2297 | |
through the same `tell()` and answer "Sent. The agents will pick it up."; the |
| 2298 | |
web returns to `/` with that line. |
| 2299 | |
|
| 2300 | |
An app that is running also shows "Open" (web: a link to |
| 2301 | |
`/:owner/:name/app/production/open`, the apps page's own) and, in the terminal, |
| 2302 | |
a "(running, o opens it)" marker with `o` showing its live address to open or |
| 2303 | |
copy. Both read `live` on the row from `clientAppRows`, which is set only while |
| 2304 | |
production runs; an app that is not running offers neither and says nothing |
| 2305 | |
about why. |
| 2306 | |
|
| 2307 | |
`GET /metrics` (site admin only; repository names are in the labels) serves |
| 2308 | |
`forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out` |
| 2309 | |
per `repo` and `destination` as Prometheus text, so a leaked upstream socket |
| 2310 | |
shows as a gauge that never falls (#3719). |
| 2311 | |
|
| 2312 | |
The here-manifest (`GET /api/v1/here?path=…`) reads the same for guests and |
| 2313 | |
members. On `/explore` and `/search` it names the page's own read, `?q=` carried |
| 2314 | |
over and encoded: `GET /api/v1/explore?q=…` (MCP tool `explore`) or |
| 2315 | |
`GET /api/v1/search?q=…` (MCP tool `search`), listed once. |
| 2316 | |
|
| 2317 | |
### Child quota |
| 2318 | |
|
| 2319 | |
`FORGE_CHILD_CPUS`, `FORGE_CHILD_MEMORY_MIB` and `FORGE_CHILD_DISK_MIB` declare |
| 2320 | |
what all children together may hold; each is the sum of the sizes in the spawn |
| 2321 | |
ledger (`spawn/sizes.ts`). **Unset means no quota.** A spawn that would pass a |
| 2322 | |
declared limit is a 409 `refused` with one sentence naming it, and the New |
| 2323 | |
forge page shows that sentence above the form when even a small forge would |
| 2324 | |
not fit (`spawn/quota.ts`). |
| 2325 | |
|
| 2326 | |
### Seeing the direction a vision pull will write |
| 2327 | |
An open vision pull's page shows, to an admin of the repository, "Direction after the merge": today's direction beside the one an admin's merge will write, computed by the same `nextDirection` the merge uses (`src/crew/vision.ts`), so the two cannot differ. Repository settings (Crew) marks the direction's fenced block `follows docs/VISION.md` and the words around it `the owner's`. Today and After the merge stack vertically at every width. |
| 2328 | |
|
| 2329 | |
The same read is a second door: the MCP `vision` tool takes an optional `number`, and for an admin returns `preview` (`{ today, after }`, or `{ why }` the direction would stay) for that open vision pull, plus `directionParts` (the direction cut into `vision` and `owner` parts). The terminal (TUI) has no such view; it is not touched by this change. |
| 704 |
- [`docs/DESIGN.md`](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`. |
| 705 |
- [`docs/SPIKE-CLOUDFLARE.md`](docs/SPIKE-CLOUDFLARE.md), [`docs/AUDIT-MANY-HANDS.md`](docs/AUDIT-MANY-HANDS.md), [`docs/BACKLOG.md`](docs/BACKLOG.md) and [`docs/research/`](docs/research/) — a spike, an audit, a backlog and research notes, kept as written. |
This index and the 60 KB limit are the point of the pull, and no test protects them. Add a test asserting README.md < 60 KB, every
docs/link here resolves, and every top-level docs/*.md is listed here. Without it the next feature pull that appends a paragraph quietly undoes this one.