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

The crew has a maintainer role: an agent can be made one, the crew page names it, and it has no path to merge or push main #11216

Merged opened this

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

Merged

mason/forge:mason/11203-the-crew-has-a-maintainer-role-an-agent → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/11203-the-crew-has-a-maintainer-role-an-agent was merged into main as 7e005e6.

Review

Sign in to review this change.
10 files changed +344 −8
docs/MCP.md +1 −1
@@ -152,7 +152,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
152152 | `now` | — | What the crew does across your apps: who, on what, since when, cost; what awaits review | 252 |
153153 | `open_pull` | — | Open a pull request, from a branch or from your fork | 327 |
154154 | `org_create` | — | Make an organization | 151 |
155 | `org_teams` | `create`, `add_member`, `members`, `metrics`, `remove_member`, `remove` | An org's teams: create, add member with a role, list members, remove | 669 |
155 | `org_teams` | `create`, `add_member`, `members`, `metrics`, `remove_member`, `remove` | An org's teams: create, add member with a role, list members, remove | 682 |
156156 | `orgs` | — | Your orgs; with org, its teams | 170 |
157157 | `origin_apps` | — | Your origin OAuth apps: list, add, remove | 290 |
158158 | `origins` | `list`, `repos`, `sync`, `hosts` | Origins: list, repos, sync, hosts | 253 |
docs/features/crew-maintainer.md added +10
@@ -0,0 +1,10 @@
1 # A maintainer keeps the code easy to change, and nothing else
2
3 An agent can declare **maintainer** beside planner, builder and reviewer: the one that refactors, deletes dead code and tidies, behaviour unchanged.
4
5 - **Make one.** Give an agent the role: Settings → Crew → the agent's *Jobs* row, `roles` on `POST`/`PATCH /api/v1/agents` and the `agent` MCP tool, or `r` on the terminal's *Crew agents* screen. Then seat it on a repository: Settings → Crew → *Maintainers* → *Add…*, `crew` with `add` and `seat` over MCP, or `m` on the repository's About screen in the terminal (an admin's, not an agent's).
6 - **Where it appears.** A *Maintainers* row (drawn once an agent declares the role or one is seated, so a crew without maintainers sees no new line) beside Planners, Builders and Reviewers on a repository's Settings → Crew; a *Maintainer* line on the terminal's About screen. The API and MCP crew listings name its role `maintainer`.
7 - **It holds no pillar.** No number of maintainers works at once, so there are no slots to set (`checkSeatSlots`, the pillars form and the `crew` tool's `seat` refuse `maintainer`) and an empty seat warns of nothing.
8 - **It is never picked.** A build, a plan or a review is never handed to it (`outsideSeat`), whatever the issue is assigned to; the builder and reviewer pickers see only their own seats.
9 - **It never merges or pushes the default branch.** Like every agent it proposes through pulls: merge and land-past-the-gate answer `forbidden`, and a push to a default branch is refused by the hook.
10 - **A repository narrows, never widens.** An agent that does not declare maintainer is refused the seat (`role_not_declared`), and the add list does not offer it.
src/core/agents.ts +2 −1
@@ -306,11 +306,12 @@ export function deleteAgent(db: Database, userId: string): boolean {
306306 * landed. Per repository, because the same agent may plan one and build
307307 * another.
308308 */
309 export type CrewRole = "builder" | "planner" | "reviewer";
309 export type CrewRole = "builder" | "planner" | "reviewer" | "maintainer";
310310 export const CREW_ROLES: readonly CrewRole[] = [
311311 "planner",
312312 "builder",
313313 "reviewer",
314 "maintainer",
314315 ];
315316 export const isCrewRole = (value: unknown): value is CrewRole =>
316317 (CREW_ROLES as readonly unknown[]).includes(value);
src/crew/dispatch.ts +5 −3
@@ -433,9 +433,11 @@ export function outsideSeat(
433433 ? "planner"
434434 : "builder";
435435 const held = crewRole(app.db, repo.id, agent.user.id);
436 // A pull assigned to its reviewer is asked for a look, a builder's for a fix: only a planner is outside.
437 if (reason !== "review" && issue.kind === "pull")
438 return (held ?? agent.roles[0]) === "planner";
436 // A pull assigned to its reviewer is asked for a look, a builder's for a fix: a planner and a maintainer are outside.
437 if (reason !== "review" && issue.kind === "pull") {
438 const own = held ?? agent.roles[0];
439 return own === "planner" || own === "maintainer";
440 }
439441 return held === undefined ? !agent.roles.includes(seat) : held !== seat;
440442 }
441443
src/crew/seat_slots.ts +2 −1
@@ -34,6 +34,7 @@ export const SEAT_UNIT: Record<CrewRole, string> = {
3434 planner: "planning pass at once",
3535 builder: "build at once",
3636 reviewer: "review at once",
37 maintainer: "upkeep pass at once",
3738 };
3839
3940 export function slotsOf(repo: Repo, role: CrewRole): number {
@@ -46,7 +47,7 @@ export function maxOf(role: CrewRole): number {
4647
4748 /** A pillar's slots as a person or an agent typed them: a whole number from 0 to its most, or undefined. */
4849 export function checkSeatSlots(role: string, raw: unknown): { role: CrewRole; slots: number } | undefined {
49 if (!isCrewRole(role)) return undefined;
50 if (!isCrewRole(role) || !PILLAR_ROLES.includes(role)) return undefined;
5051 const n = typeof raw === "string" && /^\d{1,2}$/.test(raw.trim()) ? Number(raw) : raw;
5152 return typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= maxOf(role) ? { role, slots: n } : undefined;
5253 }
src/http/mcp/tools/agent.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool, str } from "../shared.ts";
44
55 export const tool: Tool = {
66 name: "agent",
7 doc: "`{ action, username, displayName?, model?, brief?, budgetUsd?, roles? }`: `create` a crew agent (with a `displayName`), `update` its `model`, `brief`, `budgetUsd` or `roles` (planner, builder, reviewer), or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`",
7 doc: "`{ action, username, displayName?, model?, brief?, budgetUsd?, roles? }`: `create` a crew agent (with a `displayName`), `update` its `model`, `brief`, `budgetUsd` or `roles` (planner, builder, reviewer, maintainer), or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`",
88 description: "Admin: create|update|remove an agent.",
99 inputSchema: {
1010 type: "object",
src/tui/screens/about.ts +28
@@ -123,6 +123,15 @@ export function aboutScreen(
123123 // The team card's button: the first agent not yet on the crew, in the first empty seat.
124124 const emptySeat = () =>
125125 repo === undefined ? undefined : seatsOf(f.db, repo, crewOffInPreview()).seats.find((x) => x.by.length === 0);
126 // The Maintainers seat: the first agent that declares it and is not yet on the crew.
127 const nextMaintainer = () =>
128 repo === undefined
129 ? undefined
130 : (() => {
131 const held = new Set(listCrewRoles(f.db, repo.id).filter((c) => c.role === "maintainer").map((c) => c.agent.user.id));
132 return listAgents(f.db).find((a) => a.roles.includes("maintainer") && !held.has(a.user.id));
133 })();
134 const mayMaintain = (): boolean => may() && f.user?.kind !== "agent" && nextMaintainer() !== undefined;
126135 const mayFill = (): boolean => may() && f.user?.kind !== "agent" && emptySeat() !== undefined;
127136 const checking = (): boolean =>
128137 repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true;
@@ -132,6 +141,7 @@ export function aboutScreen(
132141 ...movingRow(f, repo),
133142 ...outsidersRow(repo),
134143 ...seatRows(f, repo),
144 ...maintainerRows(f, repo),
135145 ...aboutFacts(f, src, facts, ref, repo),
136146 ...paceRows(f, repo),
137147 ...trailRows(f, repo),
@@ -250,6 +260,7 @@ export function aboutScreen(
250260 ),
251261 ]),
252262 ...(mayFill() ? [binding(["p"], `fill ${emptySeat()!.role}`)] : []),
263 ...(mayMaintain() ? [binding(["m"], "add maintainer")] : []),
253264 binding(["esc"], "close"),
254265 ];
255266 },
@@ -362,6 +373,16 @@ export function aboutScreen(
362373 ctx.invalidate();
363374 return true;
364375 }
376 if (e.key === "m" && repo !== undefined && mayMaintain()) {
377 const agent = nextMaintainer()!;
378 const done = addToCrew(f.app, f.user, repo, agent.user.username, "maintainer");
379 if (done.ok) {
380 rows = factRows();
381 ctx.toast(`${agent.user.username} is a maintainer`, "success");
382 } else ctx.toast("that could not be changed", "danger");
383 ctx.invalidate();
384 return true;
385 }
365386 if (e.key === "d" && repo !== undefined && mayOutsiders()) {
366387 const at = repo;
367388 const on = !at.crewOutsiders;
@@ -735,6 +756,13 @@ export function seatRows(f: Session, repo: Repo | undefined): [string, string][]
735756 ]);
736757 }
737758
759 /** The Maintainers seat: who holds it, or that none does; it holds no pillar, so an empty one costs nothing. */
760 export function maintainerRows(f: Session, repo: Repo | undefined): [string, string][] {
761 if (repo === undefined) return [];
762 const names = listCrewRoles(f.db, repo.id).filter((c) => c.role === "maintainer").map((c) => c.agent.user.username);
763 return [["Maintainer", names.length > 0 ? names.join(", ") : "open seat"]];
764 }
765
738766 /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */
739767 export function outsidersRow(repo: Repo | undefined): [string, string][] {
740768 if (repo === undefined) return [];
src/web/pages/crew.ts +1 −1
@@ -175,7 +175,7 @@ const agentErrors = (ctx: Ctx): Record<string, string> => ({
175175 email_taken: USERNAME_TAKEN,
176176 invalid_model: `A model is up to ${MODEL_MAX} characters with no spaces.`,
177177 brief_too_long: `A brief is at most ${BRIEF_MAX} characters.`,
178 invalid_roles: "Pick at least one job: planner, builder or reviewer.",
178 invalid_roles: "Pick at least one job: planner, builder, reviewer or maintainer.",
179179 role_held: "That agent still holds a seat it would lose on a repository. Move it there first, under Settings → Crew.",
180180 invalid_budget: `A budget is a number of dollars between 0 and ${limitValue(ctx.app.db, "agent.budget_max_usd")}.`,
181181 });
src/web/studio/crew.ts +57
@@ -129,6 +129,7 @@ const SEAT_TITLE: Record<CrewRole, string> = {
129129 planner: "Planners",
130130 builder: "Builders",
131131 reviewer: "Reviewers",
132 maintainer: "Maintainers",
132133 };
133134
134135 /**
@@ -212,6 +213,8 @@ function teamCard(
212213 ],
213214 });
214215 });
216 const maintainers = maintainerRow(base, admin, everyone, crew, held);
217 if (maintainers !== null) rows.push(maintainers);
215218 const intro = crewOffInPreview()
216219 ? "The crew is off in a preview."
217220 : team.auto
@@ -230,6 +233,60 @@ function teamCard(
230233 </section>`;
231234 }
232235
236 /**
237 * The maintainers: agents that tend the code (refactors, upkeep) and hold no
238 * pillar, so no number of them works at once and an empty list warns of
239 * nothing; the row appears once there is one to seat or seated. They are never handed a build, a plan or a review, and never
240 * merge or push the default branch.
241 */
242 function maintainerRow(
243 base: string,
244 admin: boolean,
245 everyone: readonly Agent[],
246 crew: ReturnType<typeof listCrewRoles>,
247 held: ReadonlyMap<string, CrewRole>,
248 ): Html | null {
249 const picks = everyone.filter((a) => a.roles.includes("maintainer") && held.get(a.user.username) !== "maintainer");
250 const add =
251 !admin || picks.length === 0
252 ? null
253 : html`<form class="row-fields" method="post" action="${base}/settings/crew" data-team>
254 <input type="hidden" name="role" value="maintainer" />
255 <label class="sr-only" for="f-add-maintainer">Add to ${SEAT_TITLE.maintainer}</label>
256 <select class="input input-sm" id="f-add-maintainer" name="agent">
257 <option value="" disabled selected>Add…</option>
258 ${picks.map((a) => html`<option value="${a.user.username}">${a.user.username}</option>`)}
259 </select>
260 ${btn("Add", { size: "sm" })}
261 </form>`;
262 const members = crew
263 .filter((c) => c.role === "maintainer")
264 .map((c) =>
265 row({
266 lead: icon("bot"),
267 title: userLink(c.agent.user.username, { mark: markOf(c.agent.user) }),
268 ...(admin
269 ? {
270 trail: html`<form class="row-actions" method="post" action="${base}/settings/crew/${encodeURIComponent(c.agent.user.username)}/remove">
271 ${btn("Remove", { size: "sm", icon: "x" })}
272 </form>`,
273 }
274 : {}),
275 data: { member: c.agent.user.username, seat: "maintainer" },
276 }),
277 );
278 // No maintainer seated and none to add: the row would say nothing, so it is not drawn.
279 if (members.length === 0 && add === null) return null;
outdated

Principle 1/8: one plain sentence for what this seat is. The 'never builds, plans or reviews, never merges' clause is explained again in docs; drop it, and it also brings the crew page back under its attention budget.

280 return row({
281 lead: icon("bot"),
282 title: SEAT_TITLE.maintainer,
283 meta: html`Keeps the code easy to change.`,
284 trail: html`${add}`,
285 data: { seat: "maintainer" },
286 children: members,
287 });
288 }
289
233290 /**
234291 * What an empty crew is told, in one line: pick from a list above, or — when
235292 * no agent exists on the forge — where one is made (a forge admin) or who
test/crew-maintainer.test.ts added +237
@@ -0,0 +1,237 @@
1 /**
2 * The maintainer role (#11203): an agent can be made one, the crew lists it by
3 * the same word, and it has no path to merge, land or push the default
4 * branch, and is never picked as builder, planner or reviewer.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { mergePull, openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
14 import { landPastGate } from "../src/checks/bypass.ts";
15 import { crewWithRole, crewRole, getAgentByName, isCrewRole, listCrewRoles, type Agent } from "../src/core/agents.ts";
16 import { openIssue } from "../src/actions/issues.ts";
17 import { getRepo, type Repo } from "../src/core/repos.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { outsideSeat } from "../src/crew/dispatch.ts";
21 import { pickBuilder } from "../src/crew/route.ts";
22 import { pickReviewer, reviewersOf } from "../src/crew/review.ts";
23 import { mcpTool } from "./helpers/mcp.ts";
24 import { checkSeatSlots } from "../src/crew/seat_slots.ts";
25 import { resolveOid } from "../src/git/merge.ts";
26 import { repoDir } from "../src/git/repos.ts";
27 import { serve, type Servers } from "../src/http/server.ts";
28 import { gitClientEnv } from "./helpers/git-client.ts";
29 import { aboutScreen, maintainerRows } from "../src/tui/screens/about.ts";
30 import { localSource } from "../src/view/local.ts";
31 import type { Ctx, Screen } from "../src/tui/app/app.ts";
32 import type { Session } from "../src/tui/session.ts";
33 import { quietLogs } from "./helpers/globals.ts";
34
35 quietLogs();
36
37 const env = gitClientEnv({
38 GIT_AUTHOR_NAME: "Tidy",
39 GIT_AUTHOR_EMAIL: "[email protected]",
40 GIT_COMMITTER_NAME: "Tidy",
41 GIT_COMMITTER_EMAIL: "[email protected]",
42 });
43
44 async function sh(args: string[], cwd: string) {
45 const proc = Bun.spawn(args, { cwd, env, stdout: "pipe", stderr: "pipe" });
46 const [err, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
47 return { code, err: err.trim() };
48 }
49
50 describe("a maintainer", () => {
51 let dir: string;
52 let app: App;
53 let servers: Servers;
54 let alice: User;
55 let repo: Repo;
56 let tidy: Agent;
57 let token: string;
58
59 const mainTip = () => resolveOid(repoDir(app.reposRoot, "alice", "yard"), "refs/heads/main");
60
61 beforeAll(async () => {
62 dir = await mkdtemp(join(tmpdir(), "forge-maintainer-"));
63 app = createApp({ dataDir: dir, port: 0 });
64 servers = await serve(app);
65 const made = await createUser(app.db, { origin: "seed", username: "alice", email: "[email protected]", password: "a-long-crew-passphrase", adminIfFirst: true, trusted: true });
66 if (!made.ok) throw new Error("user");
67 alice = made.value;
68 const r = await createRepo(app, alice, { name: "yard", visibility: "public" });
69 if (!r.ok) throw new Error(String(r.error));
70 await commitToBranch(app, alice, r.value, { message: "seed", files: [{ path: "README.md", content: "# yard\n" }] });
71 repo = getRepo(app.db, "alice", "yard")!;
72 const a = await createAgent(app, alice, { username: "tidy", roles: ["maintainer"] });
73 if (!a.ok) throw new Error(a.error);
74 tidy = getAgentByName(app.db, "tidy")!;
75 const added = addToCrew(app, alice, repo, "tidy", "maintainer");
76 if (!added.ok) throw new Error(added.error);
77 token = createToken(app.db, tidy.user.id, "t", "rw").token;
78 }, 30_000);
79
80 afterAll(async () => {
81 servers.stop();
82 app.close();
83 await rm(dir, { recursive: true, force: true });
84 });
85
86 test("is a crew role: an agent declares it and holds the seat, listed by the same word", () => {
87 expect(isCrewRole("maintainer")).toBe(true);
88 expect(tidy.roles).toEqual(["maintainer"]);
89 expect(crewRole(app.db, repo.id, tidy.user.id)).toBe("maintainer");
90 expect(listCrewRoles(app.db, repo.id).map((c) => [c.agent.user.username, c.role])).toEqual([["tidy", "maintainer"]]);
91 });
92
93 test("is named on the repository's team page", async () => {
94 const signed = await fetch(`${servers.url}/signin`, {
95 method: "POST",
96 body: new URLSearchParams({ username: "alice", password: "a-long-crew-passphrase" }),
97 redirect: "manual",
98 });
99 const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!;
100 const page = await (await fetch(`${servers.url}/alice/yard/settings`, { headers: { cookie } })).text();
101 expect(page).toContain("Maintainers");
102 expect(page).toContain('data-seat="maintainer"');
103 expect(page).toContain('data-member="tidy"');
104 });
105
106 test("is not a pillar: it has no slots to set", () => {
107 expect(checkSeatSlots("maintainer", 1)).toBeUndefined();
108 expect(checkSeatSlots("builder", 1)).toEqual({ role: "builder", slots: 1 });
109 });
110
111 test("is refused merge, and land past the gate; main does not move", async () => {
112 await commitToBranch(app, alice, repo, { branch: "tidying", message: "tidy", files: [{ path: "t.txt", content: "t\n" }] });
113 const opened = await openPull(app, alice, repo, { title: "tidy", baseBranch: "main", headBranch: "tidying" });
114 if (!opened.ok) throw new Error(String(opened.error));
115 const before = await mainTip();
116 const merged = await mergePull(app, tidy.user, repo, opened.value, "squash");
117 expect(merged).toEqual({ ok: false, error: "forbidden" });
118 const landed = await landPastGate(app, tidy.user, repo, opened.value, "tidying up the yard, past the gate");
119 expect(landed.ok).toBe(false);
120 expect(landed.ok ? "" : landed.error.kind).toBe("forbidden");
121 expect(await mainTip()).toBe(before);
122 });
123
124 test("is refused a push to the default branch", async () => {
125 const remote = `${servers.url.replace("://", `://tidy:${token}@`)}/alice/yard.git`;
126 const clone = join(dir, "clone");
127 expect((await sh(["git", "clone", "--quiet", remote, clone], dir)).code).toBe(0);
128 await Bun.write(join(clone, "M.md"), "m\n");
129 await sh(["git", "add", "M.md"], clone);
130 await sh(["git", "commit", "-qm", "m"], clone);
131 const before = await mainTip();
132 const pushed = await sh(["git", "push", "-q", "origin", "HEAD:main"], clone);
133 expect(pushed.code).not.toBe(0);
134 expect(await mainTip()).toBe(before);
135 // On its own fork it may push branches, and the hook still keeps its default branch.
136 const forked = await forkRepo(app, tidy.user, repo);
137 if (!forked.ok) throw new Error(String(forked.error));
138 const fork = `${servers.url.replace("://", `://tidy:${token}@`)}/${forked.value.ownerName}/${forked.value.name}.git`;
139 expect((await sh(["git", "push", "-q", fork, "HEAD:refs/heads/own-branch"], clone)).code).toBe(0);
140 const refused = await sh(["git", "push", "-q", fork, `HEAD:${forked.value.defaultBranch}`], clone);
141 expect(refused.code).not.toBe(0);
142 expect(refused.err).toContain("agents propose through pulls");
143 }, 30_000);
144
145 test("is never picked as builder, planner or reviewer", async () => {
146 const issue = await openIssue(app, alice, repo, { title: "work" });
147 if (!issue.ok) throw new Error(issue.error);
148 for (const reason of ["assigned", "feedback", "check_failed", "conflict", "resolve", "converge", "review"] as const)
149 expect(outsideSeat(app, repo, issue.value, tidy, reason)).toBe(true);
150 const as = { ...issue.value, kind: "pull" as const };
151 for (const reason of ["feedback", "review"] as const) expect(outsideSeat(app, repo, as, tidy, reason)).toBe(true);
152 });
153
154 test("the pickers choose the builder and the reviewer, never the maintainer beside them", async () => {
155 for (const [name, role] of [["maker", "builder"], ["looker", "reviewer"]] as const) {
156 const made = await createAgent(app, alice, { username: name, roles: [role] });
157 if (!made.ok) throw new Error(made.error);
158 const added = addToCrew(app, alice, repo, name, role);
159 if (!added.ok) throw new Error(added.error);
160 }
161 const issue = await openIssue(app, alice, repo, { title: "pick" });
162 if (!issue.ok) throw new Error(issue.error);
163 const builders = crewWithRole(app.db, [repo.id], "builder").get(repo.id) ?? [];
164 expect(builders.map((b) => b.user.username)).toEqual(["maker"]);
165 // Even an issue assigned to the maintainer goes to the builder.
166 expect(pickBuilder(issue.value, builders, new Map())?.user.username).toBe("maker");
167 expect(pickReviewer(reviewersOf(app.db, repo.id), [], [], new Map())?.user.username).toBe("looker");
168 expect((crewWithRole(app.db, [repo.id], "planner").get(repo.id) ?? []).map((a) => a.user.username)).toEqual([]);
169 });
170
171 test("the team page's add form refuses an agent that does not declare maintainer", async () => {
172 const made = await createAgent(app, alice, { username: "plainbuilder", roles: ["builder"] });
173 if (!made.ok) throw new Error(made.error);
174 const signed = await fetch(`${servers.url}/signin`, {
175 method: "POST",
176 body: new URLSearchParams({ username: "alice", password: "a-long-crew-passphrase" }),
177 redirect: "manual",
178 });
179 const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!;
180 const res = await fetch(`${servers.url}/alice/yard/settings/crew`, {
181 method: "POST",
182 headers: { origin: servers.url, cookie },
183 body: new URLSearchParams({ role: "maintainer", agent: "plainbuilder" }),
184 redirect: "manual",
185 });
186 expect(res.status).toBe(303);
187 expect(listCrewRoles(app.db, repo.id).map((c) => c.agent.user.username)).not.toContain("plainbuilder");
188 const direct = addToCrew(app, alice, repo, "plainbuilder", "maintainer");
189 expect(direct).toEqual({ ok: false, error: "role_not_declared" });
190 const spare = await createAgent(app, alice, { username: "spare", roles: ["maintainer"] });
191 if (!spare.ok) throw new Error(spare.error);
192 const again = await (await fetch(`${servers.url}/alice/yard/settings`, { headers: { cookie } })).text();
193 const from = again.indexOf('id="f-add-maintainer"');
194 expect(from).toBeGreaterThan(-1);
195 const select = again.slice(from, again.indexOf("</select>", from));
196 expect(select).toContain('value="spare"');
197 expect(select).not.toContain('value="plainbuilder"');
198 });
199
200 test("the slots endpoints refuse maintainer: page, MCP and the check itself", async () => {
201 expect(checkSeatSlots("maintainer", 0)).toBeUndefined();
202 const signed = await fetch(`${servers.url}/signin`, {
203 method: "POST",
204 body: new URLSearchParams({ username: "alice", password: "a-long-crew-passphrase" }),
205 redirect: "manual",
206 });
207 const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!;
208 const before = getRepo(app.db, "alice", "yard")!;
209 const res = await fetch(`${servers.url}/alice/yard/settings/pillars`, {
210 method: "POST",
211 headers: { origin: servers.url, cookie },
212 body: new URLSearchParams({ seat: "maintainer", slots: "3" }),
213 redirect: "manual",
214 });
215 expect(res.status).toBe(303);
216 expect(JSON.stringify(getRepo(app.db, "alice", "yard"))).toBe(JSON.stringify(before));
217 const adminTok = createToken(app.db, alice.id, "admin", "admin").token;
218 const over = await mcpTool(servers.url, adminTok, "crew", { repo: "alice/yard", seat: "maintainer", slots: 3, dryRun: false });
219 expect(over.error ?? "").toContain("`seat` is planner, builder or reviewer");
220 });
221
222 test("the terminal's About screen shows the Maintainer seat, and m adds a maintainer", async () => {
223 const made = await createAgent(app, alice, { username: "second", roles: ["maintainer"] });
224 if (!made.ok) throw new Error(made.error);
225 const pushed: string[] = [];
226 const ctx = { invalidate() {}, toast: (m: string) => pushed.push(m), push() {}, pop() {}, copy() {} } as unknown as Ctx;
227 const f = { app, db: app.db, user: alice } as unknown as Session;
228 const at = () => getRepo(app.db, "alice", "yard")!;
229 expect(maintainerRows(f, at())).toEqual([["Maintainer", "tidy"]]);
230 const screen = aboutScreen(f, ctx, {} as Screen, localSource(app, at(), alice), undefined, "main");
231 expect(screen.keys.some((b) => b.keys.includes("m"))).toBe(true);
232 screen.key!({ key: "m" } as never);
233 expect(crewRole(app.db, repo.id, getAgentByName(app.db, "second")!.user.id)).toBe("maintainer");
234 expect(pushed[0]).toBe("second is a maintainer");
235 expect(maintainerRows(f, at())[0]![1]).toContain("second");
236 });
237 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs