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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7643-build-your-team-the-crew-card-shows-auto → main 5 commits fork 3 threads · 3 unresolved
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. | ||
| 42 | 42 | Each agent's scorecard — pulls landed of opened, review rounds, red checks, |
| 43 | 43 | reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the |
| 44 | 44 | 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). | |
| 45 | 46 | The terminal's scorecard also ends each agent's row with `spend $ 5h · today · 7d`, |
| 46 | 47 | its cost over the last 5 hours, since 00:00 UTC and the last 7 days, as |
| 47 | 48 | `/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. | ||
| 70 | 70 | | `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 | |
| 71 | 71 | | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | | |
| 72 | 72 | | `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 | | |
| 74 | 74 | | `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 | |
| 75 | 75 | | `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 | |
| 76 | 76 | | `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 { | ||
| 19 | 19 | listCrew as queryCrew, |
| 20 | 20 | listCrewRoles, |
| 21 | 21 | removeCrew as deleteCrew, |
| 22 | setCrewRole, | |
| 22 | 23 | updateAgent as patchAgent, |
| 23 | 24 | type Agent, |
| 24 | 25 | type AgentPatch, |
| @@ -242,6 +243,7 @@ export function addToCrew( | ||
| 242 | 243 | actor: User | undefined, |
| 243 | 244 | repo: Repo, |
| 244 | 245 | username: string, |
| 246 | role?: CrewRole, | |
| 245 | 247 | ): Result<Agent, "not_found" | "no_such_agent" | "not_an_agent" | "unverified"> { |
| 246 | 248 | const admin = requireRepoAdmin(app, actor, repo); |
| 247 | 249 | if (!admin.ok) return admin; |
| @@ -252,6 +254,8 @@ export function addToCrew( | ||
| 252 | 254 | if (!agent) |
| 253 | 255 | return err(getUserByName(app.db, username) ? "not_an_agent" : "no_such_agent"); |
| 254 | 256 | 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); | |
| 255 | 259 | app.bus.append({ |
| 256 | 260 | kind: "crew.added", |
| 257 | 261 | 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"; | ||
| 5 | 5 | import { crewAsSeen, addToCrew, removeFromCrew } from "../../../actions/crew.ts"; |
| 6 | 6 | import { emailVerified } from "../../../core/verify.ts"; |
| 7 | 7 | import { governorSeen } from "../../../crew/governed.ts"; |
| 8 | import { crewOffInPreview } from "../../../crew/preview.ts"; | |
| 9 | import { seatsOf } from "../../../crew/seats.ts"; | |
| 8 | 10 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 9 | 11 | |
| 10 | 12 | export const tool: Tool = { |
| 11 | 13 | 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`", | |
| 13 | 15 | description: "List a repository's crew, or add or remove an agent. Plans first; admin token.", |
| 14 | 16 | inputSchema: { |
| 15 | 17 | type: "object", |
| @@ -31,7 +33,7 @@ export const tool: Tool = { | ||
| 31 | 33 | if (add === undefined && remove === undefined) { |
| 32 | 34 | const seen = crewAsSeen(app, user, repo); |
| 33 | 35 | 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()) }); | |
| 35 | 37 | } |
| 36 | 38 | if ((add !== undefined && typeof add !== "string") || (remove !== undefined && typeof remove !== "string")) |
| 37 | 39 | return bad("invalid", "`add` and `remove` are an agent's username"); |
src/tui/screens/about.ts +39
| @@ -33,6 +33,10 @@ import { | ||
| 33 | 33 | import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts"; |
| 34 | 34 | import { listRefs } from "../../git/read.ts"; |
| 35 | 35 | 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"; | |
| 36 | 40 | import { whyRepo } from "../../flow/why.ts"; |
| 37 | 41 | import { setCrewOutsiders, updateRepoSettings } from "../../actions/repos.ts"; |
| 38 | 42 | import { showProvenance } from "../../actions/provenance.ts"; |
| @@ -114,6 +118,10 @@ export function aboutScreen( | ||
| 114 | 118 | // Danger zone card's gate (#7452). |
| 115 | 119 | const mayDetach = (): boolean => |
| 116 | 120 | 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; | |
| 117 | 125 | const checking = (): boolean => |
| 118 | 126 | repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true; |
| 119 | 127 | const factRows = (): [string, string][] => [ |
| @@ -121,6 +129,7 @@ export function aboutScreen( | ||
| 121 | 129 | ...flowRow(repo), |
| 122 | 130 | ...movingRow(f, repo), |
| 123 | 131 | ...outsidersRow(repo), |
| 132 | ...seatRows(f, repo), | |
| 124 | 133 | ...aboutFacts(f, src, facts, ref), |
| 125 | 134 | ...paceRows(f, repo), |
| 126 | 135 | ...trailRows(f, repo), |
| @@ -234,6 +243,7 @@ export function aboutScreen( | ||
| 234 | 243 | : `see #${offered.openNumber} upstream`, |
| 235 | 244 | ), |
| 236 | 245 | ]), |
| 246 | ...(mayFill() ? [binding(["p"], `fill ${emptySeat()!.role}`)] : []), | |
| 237 | 247 | binding(["esc"], "close"), |
| 238 | 248 | ]; |
| 239 | 249 | }, |
| @@ -329,6 +339,22 @@ export function aboutScreen( | ||
| 329 | 339 | ); |
| 330 | 340 | return true; |
| 331 | 341 | } |
| 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 | } | |
| 332 | 358 | if (e.key === "d" && repo !== undefined && mayOutsiders()) { |
| 333 | 359 | const at = repo; |
| 334 | 360 | const on = !at.crewOutsiders; |
| @@ -589,6 +615,19 @@ export function movingRow(f: Session, repo: Repo | undefined): [string, string][ | ||
| 589 | 615 | return [["Moving", cause === null ? "yes" : `no: ${cause.says} ${cause.clears}`]]; |
| 590 | 616 | } |
| 591 | 617 | |
| 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 | ||
| 592 | 631 | /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */ |
| 593 | 632 | export function outsidersRow(repo: Repo | undefined): [string, string][] { |
| 594 | 633 | if (repo === undefined) return []; |
src/web/pages/crew-settings.ts +3 −1
| @@ -39,7 +39,9 @@ export async function crewAdd(ctx: Ctx): Promise<Response> { | ||
| 39 | 39 | const gate = await adminForm(ctx); |
| 40 | 40 | if (gate instanceof Response) return gate; |
| 41 | 41 | 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); | |
| 43 | 45 | return flashTo( |
| 44 | 46 | ctx, |
| 45 | 47 | `${repoBase(repo)}/settings#crew`, |
src/web/studio/crew.ts +77 −11
| @@ -29,6 +29,7 @@ import { listAllAgents, listCrew } from "../../actions/crew.ts"; | ||
| 29 | 29 | import { CREW_ROLES, listCrewRoles, type CrewRole } from "../../core/agents.ts"; |
| 30 | 30 | import { getAppByRepo } from "../../core/apps.ts"; |
| 31 | 31 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 32 | import { seatsOf, type Seat } from "../../crew/seats.ts"; | |
| 32 | 33 | import { crewHasCredential } from "../../core/subscriptions.ts"; |
| 33 | 34 | import { autonomyStatus, smallShareWords } from "../../crew/autonomy.ts"; |
| 34 | 35 | import { governorSeen } from "../../crew/governed.ts"; |
| @@ -43,7 +44,9 @@ import { directionMarked } from "./direction.ts"; | ||
| 43 | 44 | import { fileHoldsCard } from "./file_holds.ts"; |
| 44 | 45 | import { markOf } from "../../core/marks.ts"; |
| 45 | 46 | import { |
| 47 | badge, | |
| 46 | 48 | btn, |
| 49 | type Tone, | |
| 47 | 50 | card, |
| 48 | 51 | empty, |
| 49 | 52 | field, |
| @@ -108,6 +111,78 @@ function aboutCard( | ||
| 108 | 111 | ); |
| 109 | 112 | } |
| 110 | 113 | |
| 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 | ||
| 111 | 186 | function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { |
| 112 | 187 | const db = ctx.app.db; |
| 113 | 188 | const crew = listCrewRoles(db, repo.id); |
| @@ -241,6 +316,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 241 | 316 | })() |
| 242 | 317 | : null; |
| 243 | 318 | return html`<div id="crew"> |
| 319 | ${teamCard(ctx, repo, base, admin, available[0]?.user.username)} | |
| 244 | 320 | ${admin ? crewReadiness(ctx) : null} |
| 245 | 321 | ${card(html`${rows}`, { |
| 246 | 322 | head: html`${icon("bot")} |
| @@ -371,16 +447,6 @@ ${repo.autonomyDirection}</textarea | ||
| 371 | 447 | })} |
| 372 | 448 | <div class="form-actions">${btn("Save", { variant: "primary" })}</div> |
| 373 | 449 | </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; | |
| 384 | 450 | const flowOnly = !admin |
| 385 | 451 | ? null |
| 386 | 452 | : html`<form method="post" action="${base}/settings/flow-only" id="flow-only"> |
| @@ -391,7 +457,7 @@ ${repo.autonomyDirection}</textarea | ||
| 391 | 457 | ${btn(repo.flowOnly ? "Turn off" : "Turn on", { size: "sm" })} |
| 392 | 458 | </p> |
| 393 | 459 | </form>`; |
| 394 | return card(html`${state}${noReviewer}${form}${flowOnly}`, { | |
| 460 | return card(html`${state}${form}${flowOnly}`, { | |
| 395 | 461 | id: "autonomy", |
| 396 | 462 | head: html`${icon("play")} |
| 397 | 463 | <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", () => { | ||
| 229 | 229 | expect(res.status).toBe(404); |
| 230 | 230 | expect(listGrants(app.db, carolRepo.id)).toEqual([]); |
| 231 | 231 | }); |
| 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 | }); | |
| 232 | 249 | }); |
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 | ||
| 138 | 138 | app.db.query("UPDATE repo SET autonomy = 'off' WHERE name = 'game'").run(); |
| 139 | 139 | } |
| 140 | 140 | }); |
| 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", () => { | ||
| 202 | 202 | expect(board).toContain("<b>1</b> waiting since"); |
| 203 | 203 | const settings = await page(aliceCookie, "/alice/engine/settings"); |
| 204 | 204 | 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); | |
| 205 | 217 | }); |
| 206 | 218 | |
| 207 | 219 | test("the MCP's now lists it with its age", async () => { |
new above
offline
Overwrites the role even when the agent was already on the crew. Only set it when newly added.