Public: anyone can read this; the crew can change it.

Build your team: the crew card shows auto mode and the three pillars, and warns when one is empty #7774

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7643-build-your-team-the-crew-card-shows-auto → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/7643-build-your-team-the-crew-card-shows-auto was merged into main as 8401b94.

Review

Sign in to review this change.
13 files changed +383 −15
README.md +1
@@ -42,6 +42,7 @@ ceiling, held, why }` on MCP `crew`; for people who can write here and the crew.
4242 Each agent's scorecard — pulls landed of opened, review rounds, red checks,
4343 reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the
4444 terminal's palette as "Crew scorecard per agent"; both are a forge admin's only.
45 The crew has three seats, planner, builder and reviewer (`seatsOf`, `docs/features/team.md`): Settings → Crew opens with "Your team", the MCP `crew` tool returns `seats`, and the terminal's About card (`I`) lists them, with `p` putting the first free agent on the first empty seat (a repository admin's).
4546 The terminal's scorecard also ends each agent's row with `spend $ 5h · today · 7d`,
4647 its cost over the last 5 hours, since 00:00 UTC and the last 7 days, as
4748 `/settings/spend` reads it; `—` means nothing was spent in that span (`spend —`,
docs/MCP.md +1 −1
@@ -70,7 +70,7 @@ for a tunnel and a forge that sleeps.
7070 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
7171 | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | |
7272 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
73 | `crew` | `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified` | yes |
73 | `crew` | `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `seats { auto, mode, seats[{ role, by, does, without?, state }] }`, the three seats and which are empty, and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified` | yes |
7474 | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
7575 | `detach` | `{ repo, dryRun? }`: detach a fork from its parent — repack so it owns its objects, drop the alternates link, forget the parent; planned first (`dryRun` is true unless `false`); the repack runs on its own, so call again to read its `phase` (`repacking`, `failed`) until the fork has no `parent`; only the repository's admin with an admin token, never an agent | yes |
7676 | `drills` | each failure domain: `key`, what breaks, its last drill's time, green or red and why (or why it is not drilled), and the `command` that runs it; a forge admin who is a person only (`forbidden` otherwise), as Settings → Backups says them | |
docs/features/team.md added +16
@@ -0,0 +1,16 @@
1 # Your team
2
3 Settings › Crew opens with **Your team**: whether auto mode is on, and the three seats — planner (turns your
4 direction into issues), builder (writes the change, opens a pull), reviewer (reads each pull and lands the good ones).
5 With auto mode on, an empty seat warns what will not happen ("No reviewer: agent pulls wait for you.") with one button
6 to put an agent in it; with auto mode off, or in a preview where the crew is held off, an empty seat is an open seat with
7 the same button, no warning. The button is the repository admin's; nobody else sees it.
8
9 One source: `seatsOf` in `src/crew/seats.ts`, drawn by `teamCard` (`src/web/studio/crew.ts`), listed on the terminal's
10 About card (`I` on a repository: one row per seat) and returned as `seats` by the MCP `crew` tool. The button posts
11 `agent` and `role` to the crew-add route; `p` on the terminal's About card does the same for the first empty seat
12 (`addToCrew(…, role)`). Either way the role is set only for an agent who joins now: one already on the crew keeps its role
13 (change it under its own role control).
14
15 The old "No reviewer" note under On its own, shown only for `autonomous`, is gone: the same warning now shows on the
16 reviewer seat for every mode except off.
src/actions/crew.ts +4
@@ -19,6 +19,7 @@ import {
1919 listCrew as queryCrew,
2020 listCrewRoles,
2121 removeCrew as deleteCrew,
22 setCrewRole,
2223 updateAgent as patchAgent,
2324 type Agent,
2425 type AgentPatch,
@@ -242,6 +243,7 @@ export function addToCrew(
242243 actor: User | undefined,
243244 repo: Repo,
244245 username: string,
246 role?: CrewRole,
245247 ): Result<Agent, "not_found" | "no_such_agent" | "not_an_agent" | "unverified"> {
246248 const admin = requireRepoAdmin(app, actor, repo);
247249 if (!admin.ok) return admin;
@@ -252,6 +254,8 @@ export function addToCrew(
252254 if (!agent)
253255 return err(getUserByName(app.db, username) ? "not_an_agent" : "no_such_agent");
254256 if (insertCrew(app.db, repo.id, agent.user.id, admin.value.id)) {
257 // A seat is named only for an agent who joins now; one already on the crew keeps its role.
258 if (role !== undefined) setCrewRole(app.db, repo.id, agent.user.id, role);
255259 app.bus.append({
256260 kind: "crew.added",
257261 repoId: repo.id,
src/crew/seats.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * The team as three seats: planner, builder, reviewer. One place says who
3 * fills each, what it does, and what will not happen while it is empty — the
4 * crew card draws it and the MCP `crew` tool returns it, so a page and an
5 * agent read the same facts.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { listCrewRoles, type CrewRole } from "../core/agents.ts";
10 import type { Repo } from "../core/repos.ts";
11
12 export type Seat = {
13 role: CrewRole;
14 /** Usernames filling it. */
15 by: string[];
16 /** One plain line on what the seat does. */
17 does: string;
18 /** What will not happen while it is empty, as a clause after "No <role>:"; set only when empty. */
19 without?: string;
20 /** "filled", "warn" (empty, auto mode on) or "invite" (empty, auto mode off). */
21 state: "filled" | "warn" | "invite";
22 };
23
24 export type Seats = { auto: boolean; mode: Repo["autonomy"]; seats: Seat[] };
25
26 const SEATS: { role: CrewRole; does: string; without: string }[] = [
27 {
28 role: "planner",
29 does: "Turns your direction into issues, so there is always a next thing to build.",
30 without: "your direction goes nowhere, only issues people write get built",
31 },
32 {
33 role: "builder",
34 does: "Picks up an issue, writes the change and opens a pull.",
35 without: "nothing gets built",
36 },
37 {
38 role: "reviewer",
39 does: "Reads each pull and lands the good ones.",
40 without: "agent pulls wait for you",
41 },
42 ];
43
44 /** `crewOff` is true while the crew is held off (a preview): nobody works, so an empty seat invites rather than warns. */
45 export function seatsOf(db: Database, repo: Repo, crewOff = false): Seats {
46 const crew = listCrewRoles(db, repo.id);
47 const auto = repo.autonomy !== "off" && !crewOff;
48 return {
49 auto,
50 mode: repo.autonomy,
51 seats: SEATS.map((p) => {
52 const by = crew
53 .filter((c) => c.role === p.role)
54 .map((c) => c.agent.user.username);
55 return by.length > 0
56 ? { role: p.role, by, does: p.does, state: "filled" as const }
57 : {
58 role: p.role,
59 by,
60 does: p.does,
61 without: p.without,
62 state: auto ? ("warn" as const) : ("invite" as const),
63 };
64 }),
65 };
66 }
src/http/mcp/tools/crew.ts +4 −2
@@ -5,11 +5,13 @@ import { getUserByName } from "../../../core/users.ts";
55 import { crewAsSeen, addToCrew, removeFromCrew } from "../../../actions/crew.ts";
66 import { emailVerified } from "../../../core/verify.ts";
77 import { governorSeen } from "../../../crew/governed.ts";
8 import { crewOffInPreview } from "../../../crew/preview.ts";
9 import { seatsOf } from "../../../crew/seats.ts";
810 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
911
1012 export const tool: Tool = {
1113 name: "crew",
12 doc: "`{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`",
14 doc: "`{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `seats { auto, mode, seats[{ role, by, does, without?, state }] }`, the three seats and which are empty, and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`",
1315 description: "List a repository's crew, or add or remove an agent. Plans first; admin token.",
1416 inputSchema: {
1517 type: "object",
@@ -31,7 +33,7 @@ export const tool: Tool = {
3133 if (add === undefined && remove === undefined) {
3234 const seen = crewAsSeen(app, user, repo);
3335 if (!seen.ok) return bad("not_found", "no such app or repository (owner/name)");
34 return good({ crew: seen.value, governor: governorSeen(app, user, repo, new Date()) ?? null });
36 return good({ crew: seen.value, governor: governorSeen(app, user, repo, new Date()) ?? null, seats: seatsOf(app.db, repo, crewOffInPreview()) });
3537 }
3638 if ((add !== undefined && typeof add !== "string") || (remove !== undefined && typeof remove !== "string"))
3739 return bad("invalid", "`add` and `remove` are an agent's username");
src/tui/screens/about.ts +39
@@ -33,6 +33,10 @@ import {
3333 import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts";
3434 import { listRefs } from "../../git/read.ts";
3535 import { directionNote } from "../../crew/direct.ts";
36 import { seatsOf } from "../../crew/seats.ts";
37 import { crewOffInPreview } from "../../crew/preview.ts";
38 import { addToCrew } from "../../actions/crew.ts";
39 import { listAgents, listCrewRoles } from "../../core/agents.ts";
3640 import { whyRepo } from "../../flow/why.ts";
3741 import { setCrewOutsiders, updateRepoSettings } from "../../actions/repos.ts";
3842 import { showProvenance } from "../../actions/provenance.ts";
@@ -114,6 +118,10 @@ export function aboutScreen(
114118 // Danger zone card's gate (#7452).
115119 const mayDetach = (): boolean =>
116120 mayFlow() && repo !== undefined && repo.forkParentId !== null;
121 // The team card's button: the first agent not yet on the crew, in the first empty seat.
122 const emptySeat = () =>
123 repo === undefined ? undefined : seatsOf(f.db, repo, crewOffInPreview()).seats.find((x) => x.by.length === 0);
124 const mayFill = (): boolean => may() && f.user?.kind !== "agent" && emptySeat() !== undefined;
117125 const checking = (): boolean =>
118126 repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true;
119127 const factRows = (): [string, string][] => [
@@ -121,6 +129,7 @@ export function aboutScreen(
121129 ...flowRow(repo),
122130 ...movingRow(f, repo),
123131 ...outsidersRow(repo),
132 ...seatRows(f, repo),
124133 ...aboutFacts(f, src, facts, ref),
125134 ...paceRows(f, repo),
126135 ...trailRows(f, repo),
@@ -234,6 +243,7 @@ export function aboutScreen(
234243 : `see #${offered.openNumber} upstream`,
235244 ),
236245 ]),
246 ...(mayFill() ? [binding(["p"], `fill ${emptySeat()!.role}`)] : []),
237247 binding(["esc"], "close"),
238248 ];
239249 },
@@ -329,6 +339,22 @@ export function aboutScreen(
329339 );
330340 return true;
331341 }
342 if (e.key === "p" && repo !== undefined && mayFill()) {
343 const seat = emptySeat()!;
344 const onCrew = new Set(listCrewRoles(f.db, repo.id).map((c) => c.agent.user.id));
345 const agent = listAgents(f.db).find((a) => !onCrew.has(a.user.id));
346 if (agent === undefined) {
347 ctx.toast("no agent to put there: make one under Crew first", "danger");
348 return true;
349 }
350 const done = addToCrew(f.app, f.user, repo, agent.user.username, seat.role);
351 if (done.ok) {
352 rows = factRows();
353 ctx.toast(`${agent.user.username} is on ${seat.role}`, "success");
354 } else ctx.toast("that could not be changed", "danger");
355 ctx.invalidate();
356 return true;
357 }
332358 if (e.key === "d" && repo !== undefined && mayOutsiders()) {
333359 const at = repo;
334360 const on = !at.crewOutsiders;
@@ -589,6 +615,19 @@ export function movingRow(f: Session, repo: Repo | undefined): [string, string][
589615 return [["Moving", cause === null ? "yes" : `no: ${cause.says} ${cause.clears}`]];
590616 }
591617
618 /** The three seats, in the crew card's words: who fills each, and what an empty one costs. */
619 export function seatRows(f: Session, repo: Repo | undefined): [string, string][] {
620 if (repo === undefined) return [];
621 return seatsOf(f.db, repo, crewOffInPreview()).seats.map((x): [string, string] => [
622 x.role[0]!.toUpperCase() + x.role.slice(1),
623 x.by.length > 0
624 ? x.by.join(", ")
625 : x.state === "warn"
626 ? `empty. No ${x.role}: ${x.without}.`
627 : "open seat",
628 ]);
629 }
630
592631 /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */
593632 export function outsidersRow(repo: Repo | undefined): [string, string][] {
594633 if (repo === undefined) return [];
src/web/pages/crew-settings.ts +3 −1
@@ -39,7 +39,9 @@ export async function crewAdd(ctx: Ctx): Promise<Response> {
3939 const gate = await adminForm(ctx);
4040 if (gate instanceof Response) return gate;
4141 const { repo, form } = gate;
42 const added = addToCrew(ctx.app, ctx.auth.user, repo, field(form, "agent"));
42 // The team card's one button names the seat it fills.
43 const role = field(form, "role");
44 const added = addToCrew(ctx.app, ctx.auth.user, repo, field(form, "agent"), isCrewRole(role) ? role : undefined);
outdated

Overwrites the role even when the agent was already on the crew. Only set it when newly added.

4345 return flashTo(
4446 ctx,
4547 `${repoBase(repo)}/settings#crew`,
src/web/studio/crew.ts +77 −11
@@ -29,6 +29,7 @@ import { listAllAgents, listCrew } from "../../actions/crew.ts";
2929 import { CREW_ROLES, listCrewRoles, type CrewRole } from "../../core/agents.ts";
3030 import { getAppByRepo } from "../../core/apps.ts";
3131 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
32 import { seatsOf, type Seat } from "../../crew/seats.ts";
3233 import { crewHasCredential } from "../../core/subscriptions.ts";
3334 import { autonomyStatus, smallShareWords } from "../../crew/autonomy.ts";
3435 import { governorSeen } from "../../crew/governed.ts";
@@ -43,7 +44,9 @@ import { directionMarked } from "./direction.ts";
4344 import { fileHoldsCard } from "./file_holds.ts";
4445 import { markOf } from "../../core/marks.ts";
4546 import {
47 badge,
4648 btn,
49 type Tone,
4750 card,
4851 empty,
4952 field,
@@ -108,6 +111,78 @@ function aboutCard(
108111 );
109112 }
110113
114 const SEAT_LEAD: Record<Seat["state"], "check" | "alert" | "plus"> = {
115 filled: "check",
116 warn: "alert",
117 invite: "plus",
118 };
119
120 const SEAT_BADGE: Record<Seat["state"], { text: string; tone: Tone }> = {
121 filled: { text: "filled", tone: "success" },
122 warn: { text: "empty", tone: "attn" },
123 invite: { text: "open seat", tone: "default" },
124 };
125
126 /**
127 * Your team, as three seats (`seatsOf`, the same facts the MCP `crew` tool
128 * returns): whether it works on its own, who fills each seat, what it does,
129 * and — for an empty one — a warning when the team is on its own, a calm
130 * invitation when it is not, each with one button that puts an agent in it.
131 */
132 function teamCard(
133 ctx: Ctx,
134 repo: Repo,
135 base: string,
136 admin: boolean,
137 candidate: string | undefined,
138 ): Html {
139 const team = seatsOf(ctx.app.db, repo, crewOffInPreview());
140 const fill = (p: Seat): Html | undefined => {
141 if (!admin || p.state === "filled") return undefined;
142 if (candidate === undefined)
143 return ctx.auth.user?.isAdmin
144 ? btn("Add an agent", { href: "/settings/crew", size: "sm" })
145 : undefined;
146 return html`<form class="row-actions" method="post" action="${base}/settings/crew">
147 <input type="hidden" name="agent" value="${candidate}" />
148 <input type="hidden" name="role" value="${p.role}" />
149 ${btn(`Put ${candidate} on ${p.role}`, {
150 size: "sm",
151 variant: p.state === "warn" ? "primary" : "secondary",
152 })}
153 </form>`;
154 };
155 const rows = team.seats.map((p) => {
156 const fillBy = fill(p);
157 const b = SEAT_BADGE[p.state];
158 return row({
159 lead: icon(SEAT_LEAD[p.state]),
160 title: p.role[0]!.toUpperCase() + p.role.slice(1),
161 meta: html`${p.by.length > 0 ? html`${p.by.map((u, i) => html`${i > 0 ? ", " : ""}${userLink(u)}`)}: ` : null}${p.does}${p.state === "warn" ? ` No ${p.role}: ${p.without}.` : null}`,
162 trail: html`${badge(b.text, { tone: b.tone })}${fillBy ?? null}`,
163 data: { seat: p.role, state: p.state },
164 });
165 });
outdated

Warns in a preview where the intro says nobody is working. Treat crewOffInPreview as the invite state. Also avoid <b>/<br> hand-rolled markup.

166 const intro = crewOffInPreview()
167 ? "The crew is off in a preview, so nobody here is working."
168 : team.auto
169 ? `Auto mode is on (${team.mode}): your team works on its own — a planner decides what is next, a builder builds it, a reviewer checks it.`
170 : "Auto mode is off: your team works only when you ask. Fill the seats you want, then turn auto mode on under On its own.";
171 return card(
172 html`<p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}">
173 ${intro}
174 </p>
175 <ul class="list setting-list">
176 ${rows}
177 </ul>`,
178 {
179 id: "team",
180 head: html`${icon("users")}
181 <h2>Your team</h2>`,
182 },
183 );
184 }
185
111186 function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
112187 const db = ctx.app.db;
113188 const crew = listCrewRoles(db, repo.id);
@@ -241,6 +316,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
241316 })()
242317 : null;
243318 return html`<div id="crew">
319 ${teamCard(ctx, repo, base, admin, available[0]?.user.username)}
244320 ${admin ? crewReadiness(ctx) : null}
245321 ${card(html`${rows}`, {
246322 head: html`${icon("bot")}
@@ -371,16 +447,6 @@ ${repo.autonomyDirection}</textarea
371447 })}
372448 <div class="form-actions">${btn("Save", { variant: "primary" })}</div>
373449 </form>`;
374 // An autonomous repository with no reviewer lands nothing an agent wrote
375 // on its own: each such pull waits for a person's review (#597).
376 const noReviewer =
377 repo.autonomy === "autonomous" &&
378 !listCrewRoles(ctx.app.db, repo.id).some((c) => c.role === "reviewer")
379 ? html`<p class="small" id="no-reviewer">
380 <b>No reviewer: agent pulls wait for you.</b> Put a reviewer on the
381 crew under Crew to have them reviewed and land on their own.
382 </p>`
383 : null;
384450 const flowOnly = !admin
385451 ? null
386452 : html`<form method="post" action="${base}/settings/flow-only" id="flow-only">
@@ -391,7 +457,7 @@ ${repo.autonomyDirection}</textarea
391457 ${btn(repo.flowOnly ? "Turn off" : "Turn on", { size: "sm" })}
392458 </p>
393459 </form>`;
394 return card(html`${state}${noReviewer}${form}${flowOnly}`, {
460 return card(html`${state}${form}${flowOnly}`, {
395461 id: "autonomy",
396462 head: html`${icon("play")}
397463 <h2>On its own</h2>`,
test/crew-seats.test.ts added +133
@@ -0,0 +1,133 @@
1 /**
2 * The team's three seats: `seatsOf` says who fills each and what an empty one
3 * costs; the crew card draws it with one button per empty seat (an admin's);
4 * `addToCrew` names the seat only for an agent who joins now; the terminal's
5 * About card lists the seats and `p` fills one.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { crewRole, listCrewRoles, removeCrew, setCrewRole } from "../src/core/agents.ts";
14 import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts";
15 import { createUser, getUserByName, type User } from "../src/core/users.ts";
16 import { seatsOf } from "../src/crew/seats.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import type { Ctx, Screen } from "../src/tui/app/app.ts";
19 import { aboutScreen, seatRows } from "../src/tui/screens/about.ts";
20 import type { Session } from "../src/tui/session.ts";
21 import { localSource } from "../src/view/local.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25
26 const PASSWORD = "a-long-crew-passphrase";
27
28 describe("the team's seats", () => {
29 let dataDir = "";
30 let app: App;
31 let servers: Servers;
32 let alice: User;
33 let repo: Repo;
34 let cookies: Record<string, string> = {};
35
36 const idOf = (name: string) => getUserByName(app.db, name)!.id;
37 const fresh = () => getRepoById(app.db, repo.id)!;
38 const mode = (m: string) => app.db.query("UPDATE repo SET autonomy = ?1 WHERE id = ?2").run(m, repo.id);
39 const states = (crewOff = false) =>
40 Object.fromEntries(seatsOf(app.db, fresh(), crewOff).seats.map((s) => [s.role, s.state]));
41 const page = async (who: string) => (await fetch(`${servers.url}/alice/yard/settings`, { headers: { cookie: cookies[who]! } })).text();
42 const signIn = async (username: string) => {
43 const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username, password: PASSWORD }), redirect: "manual" });
44 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
45 };
46
47 beforeAll(async () => {
48 dataDir = await mkdtemp(join(tmpdir(), "forge-seats-"));
49 app = createApp({ dataDir, port: 0 });
50 servers = await serve(app);
51 const make = async (username: string) => {
52 const r = await createUser(app.db, { origin: "seed", username, email: `${username}@forge.local`, password: PASSWORD, adminIfFirst: true, trusted: true });
53 if (!r.ok) throw new Error("user");
54 return r.value;
55 };
56 alice = await make("alice");
57 await make("bob");
58 const made = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "yard", visibility: "public" });
59 if (!made.ok) throw new Error(String(made.error));
60 repo = made.value;
61 for (const name of ["aplan", "bbuild"]) if (!(await createAgent(app, alice, { username: name })).ok) throw new Error("agent");
62 cookies = { alice: await signIn("alice"), bob: await signIn("bob") };
63 }, 30_000);
64
65 afterAll(async () => {
66 await servers.stop();
67 await rm(dataDir, { recursive: true, force: true });
68 });
69
70 test("auto mode off: every empty seat invites; auto mode on: it warns", () => {
71 mode("off");
72 expect(states()).toEqual({ planner: "invite", builder: "invite", reviewer: "invite" });
73 expect(seatsOf(app.db, fresh()).auto).toBe(false);
74 mode("autonomous");
75 expect(states()).toEqual({ planner: "warn", builder: "warn", reviewer: "warn" });
76 const reviewer = seatsOf(app.db, fresh()).seats[2]!;
77 expect(reviewer.without).toBe("agent pulls wait for you");
78 });
79
80 test("a crew held off (a preview) invites even with auto mode on", () => {
81 mode("autonomous");
82 expect(states(true)).toEqual({ planner: "invite", builder: "invite", reviewer: "invite" });
83 expect(seatsOf(app.db, fresh(), true).auto).toBe(false);
84 });
85
86 test("the card puts the first free agent on the seat it names, for an admin only", async () => {
87 mode("autonomous");
88 const html = await page("alice");
89 for (const role of ["planner", "builder", "reviewer"])
90 expect(html).toContain(`name="role" value="${role}"`);
91 expect(html).toContain('name="agent" value="aplan"');
92 expect(html).toContain("Put aplan on reviewer");
93 expect(await page("bob")).not.toContain('name="role"');
94 });
95
96 test("a filled seat names who fills it and has no button; the others still do", async () => {
97 expect(addToCrew(app, alice, fresh(), "aplan", "planner").ok).toBe(true);
98 expect(states()).toEqual({ planner: "filled", builder: "warn", reviewer: "warn" });
99 expect(seatsOf(app.db, fresh()).seats[0]!.by).toEqual(["aplan"]);
100 const html = await page("alice");
101 expect(html).not.toContain('name="role" value="planner"');
102 expect(html).toContain('name="role" value="builder"');
103 expect(html).toContain('name="agent" value="bbuild"');
104 expect(html).not.toContain("No planner:");
105 expect(html).toContain("No builder: nothing gets built.");
106 });
107
108 test("an agent already on the crew keeps its role", () => {
109 setCrewRole(app.db, repo.id, idOf("aplan"), "reviewer");
110 const again = addToCrew(app, alice, fresh(), "aplan", "builder");
111 expect(again.ok).toBe(true);
112 expect(crewRole(app.db, repo.id, idOf("aplan"))).toBe("reviewer");
113 });
114
115 test("the terminal lists the seats and p fills the first empty one", () => {
116 for (const c of listCrewRoles(app.db, repo.id)) removeCrew(app.db, repo.id, c.agent.user.id);
117 mode("autonomous");
118 const pushed: string[] = [];
119 const ctx = { invalidate() {}, toast: (m: string) => pushed.push(m), push() {}, pop() {}, copy() {} } as unknown as Ctx;
120 const f = { app, db: app.db, user: alice } as unknown as Session;
121 const screen = aboutScreen(f, ctx, {} as Screen, localSource(app, fresh(), alice), undefined, "main");
122 expect(seatRows(f, fresh()).map(([k]) => k)).toEqual(["Planner", "Builder", "Reviewer"]);
123 expect(seatRows(f, fresh())[2]![1]).toContain("No reviewer: agent pulls wait for you.");
124 expect(screen.keys.some((b) => b.keys.includes("p"))).toBe(true);
125 screen.key!({ key: "p" } as never);
126 expect(pushed[0]).toBe("aplan is on planner");
127 expect(crewRole(app.db, repo.id, idOf("aplan"))).toBe("planner");
128 expect(seatRows(f, fresh())[0]![1]).toBe("aplan");
129 const bob = { app, db: app.db, user: { id: "x", isAdmin: false } } as unknown as Session;
130 const other = aboutScreen(bob, ctx, {} as Screen, localSource(app, fresh(), undefined), undefined, "main");
131 expect(other.keys.some((b) => b.keys.includes("p"))).toBe(false);
132 });
133 });
test/crew-settings-doors.test.ts +17
@@ -229,4 +229,21 @@ describe("settings › crew doors", () => {
229229 expect(res.status).toBe(404);
230230 expect(listGrants(app.db, carolRepo.id)).toEqual([]);
231231 });
232
233 test("the team card's button adds the agent in the pillar it names", async () => {
234 const own = (body: Record<string, string>) =>
235 fetch(`${servers.url}/carol/carolyard/settings/crew`, {
236 method: "POST",
237 headers: { origin: servers.url, cookie: carol },
238 body: new URLSearchParams(body),
239 redirect: "manual",
240 });
241 await fetch(`${servers.url}/carol/carolyard/settings/crew/otherbot/remove`, {
242 method: "POST",
243 headers: { origin: servers.url, cookie: carol },
244 redirect: "manual",
245 });
246 expect((await own({ agent: "otherbot", role: "planner" })).status).toBe(303);
247 expect(crewRole(app.db, carolRepo.id, otherId)).toBe("planner");
248 });
232249 });
test/mcp-crew.test.ts +10
@@ -138,3 +138,13 @@ test("the listing carries the governor's slots and reason for a writer while the
138138 app.db.query("UPDATE repo SET autonomy = 'off' WHERE name = 'game'").run();
139139 }
140140 });
141
142 test("the crew answer carries the three seats, empty ones named", async () => {
143 const r: any = await mcpTool(servers.url, admin, "crew", { repo: "alice/game" });
144 const team = r.value.seats;
145 expect(team.seats.map((p: any) => p.role)).toEqual(["planner", "builder", "reviewer"]);
146 for (const p of team.seats) {
147 expect(typeof p.does).toBe("string");
148 if (p.by.length === 0) expect(p.without).toBeString();
149 }
150 });
test/review-waits.test.ts +12
@@ -202,6 +202,18 @@ describe("an agent pull that waits for a person's review says so", () => {
202202 expect(board).toContain("<b>1</b> waiting since");
203203 const settings = await page(aliceCookie, "/alice/engine/settings");
204204 expect(settings).toContain("No reviewer: agent pulls wait for you.");
205 expect(settings.split("No reviewer").length).toBe(2);
206 expect(settings).toContain('data-auto="on"');
207 expect(settings).toContain("No planner: your direction goes nowhere");
208 });
209
210 test("with auto mode off an empty seat is an invitation, not a warning", async () => {
211 app.db.query("UPDATE repo SET autonomy = 'off' WHERE id = ?1").run(repo.id);
212 const settings = await page(aliceCookie, "/alice/engine/settings");
213 expect(settings).toContain('data-auto="off"');
214 expect(settings).toContain("open seat");
215 expect(settings).not.toContain("No planner:");
216 app.db.query("UPDATE repo SET autonomy = 'autonomous' WHERE id = ?1").run(repo.id);
205217 });
206218
207219 test("the MCP's now lists it with its age", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs