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

Crew roles are config: an agent says what it does, migrate never picks a planner as builder, and a repo's team is three lists to choose from #8922

Merged opened this in answer to

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

Merged

scout/forge:scout/8907-crew-roles-are-config-an-agent-says-what → main 3 commits fork

  • Merged. scout/8907-crew-roles-are-config-an-agent-says-what was merged into main as a005203.

Review

Sign in to review this change.
90 files changed +800 −332
docs/PLANNING.md +10 −2
@@ -470,8 +470,16 @@ Everything it does, it does as that person, through the same actions they
470470 would take by hand — an assignment, a merge — so every run and every
471471 landing is credited and bounded exactly as theirs would be.
472472
473 **The crew's roles.** Each crew member has a role on the repository, set
474 under **Settings → Crew**: a _builder_ takes what is ready; a _planner_
473 **The crew's roles.** An agent declares its roles in its own config, beside
474 its model and brief (Settings → Crew → the agent; `roles` on `/api/v1/agents`
475 and the `agent` MCP tool): any of planner, builder, reviewer, one or more.
476 A repository's team is three lists, Planners, Builders and Reviewers, each
477 picking only from agents that declare the role, with how many may work at
478 once beside it; a repository narrows what an agent declares and never widens
479 it, and there is no default to builder. `/migrate` and a new app offer
480 builders only, and a dispatch never hands an agent a job outside its seat:
481 an issue assigned to a planner gets one comment saying so. Each crew member
482 then holds one of its roles on the repository: a _builder_ takes what is ready; a _planner_
475483 turns the direction into a map; a _reviewer_ is for the look-back. Which
476484 builder takes an issue is one fixed-choice function: the issue's own
477485 assignee when it is a builder here, else the builder with the least in
docs/features/crew-roles.md added +9
@@ -0,0 +1,9 @@
1 # An agent says what it does; a repo's team is three lists
2
3 An agent declares its **roles** in its own config: any of planner, builder, reviewer (at least one; none given reads as builder).
4
5 - **Where to set them.** Settings → Crew → the agent has a *Jobs* row of checkboxes. The same is `roles` (a list of strings) on `POST`/`PATCH /api/v1/agents`, on the `agent` MCP tool, and `r` on the terminal's *Crew agents* screen (comma separated, plan and confirm first). Anything but a list of the three names is `invalid_roles` (422).
6 - **A repository narrows, never widens.** A repo's *Settings → Crew* shows three lists, Planners, Builders and Reviewers, each with how many may work at once. An *Add…* list offers only agents that declare that role; the API refuses a role the agent does not declare (`role_not_declared`). There is no default to builder: an agent added with no seat takes the first it declares.
7 - **No silent moves.** Taking a role off an agent that still holds that seat on a repository is refused (`role_held`); move it off that repository's team first.
8 - **A dispatch keeps to the seat.** An assigned issue, feedback, a red check, a conflict, a converge and a resolve run only for a builder (the direction issue only for a planner); a review only for a reviewer. An issue assigned to a planner or reviewer gets one comment saying so.
9 - **`/migrate`** offers builders only; `crew=<name>` naming anyone else is refused in a sentence and the first builder stays the default.
src/actions/apps.ts +5 −2
@@ -284,6 +284,7 @@ export type NewAppError =
284284 | CreateAppError
285285 | "scaffold_failed"
286286 | "no_such_agent"
287 | "not_a_builder"
287288 | "app_limit";
288289
289290 /**
@@ -307,6 +308,7 @@ export async function newApp(
307308 ? undefined
308309 : getAgentByName(app.db, input.agent);
309310 if (input.agent && !agent) return err("no_such_agent");
311 if (agent && !agent.roles.includes("builder")) return err("not_a_builder");
310312
311313 // Refuse at the limit before a repository exists to take back.
312314 // The owner is resolved with the actor's rights, as createRepo does, so
@@ -371,7 +373,7 @@ export async function newApp(
371373 const binary = process.env["FORGE_CLAUDE"] ?? "claude";
372374 if (planningAvailable(binary)) {
373375 const roster = listAllAgents(app, user.value);
374 const agents = roster.ok ? roster.value : [];
376 const agents = roster.ok ? roster.value.filter((a) => a.roles.includes("builder")) : [];
375377 if (agents.length > 0) {
376378 const result = await plan(
377379 {
@@ -401,7 +403,7 @@ export async function newApp(
401403
402404 let issue: Issue | undefined;
403405 if (chosen) {
404 addCrew(app.db, repo.value.id, chosen.user.id, user.value.id);
406 addCrew(app.db, repo.value.id, chosen.user.id, user.value.id, "builder");
405407 app.bus.append({
406408 kind: "crew.added",
407409 repoId: repo.value.id,
@@ -459,6 +461,7 @@ export const NEW_APP_ERRORS: Record<string, string> = {
459461 already_app: "That repository is already an app.",
460462 scaffold_failed: "The starter app could not be committed.",
461463 no_such_agent: NO_SUCH_AGENT,
464 not_a_builder: "That agent is not a builder, so it cannot be handed a new app. Pick a builder.",
462465 };
463466
464467 export interface NewAppFromSentenceInput extends Omit<NewAppInput, "name"> {
src/actions/crew.ts +49 −10
@@ -17,9 +17,10 @@ import {
1717 isCrew,
1818 listAgents,
1919 listCrew as queryCrew,
20 isCrewRole,
2021 listCrewRoles,
2122 removeCrew as deleteCrew,
22 setCrewRole,
23 seatsOutside,
2324 updateAgent as patchAgent,
2425 type Agent,
2526 type AgentPatch,
@@ -45,6 +46,25 @@ export interface CreateAgentInput {
4546 model?: string;
4647 brief?: string;
4748 budgetUsd?: number;
49 /** The seats it can hold: any of planner, builder, reviewer. */
50 roles?: readonly string[];
51 }
52
53 /** An agent change as asked: roles still unchecked strings. */
54 export type AgentChange = Omit<AgentPatch, "roles"> & { roles?: readonly string[] };
55
56 /**
57 * The `roles` a request named, as a patch field: nothing when it named none;
58 * anything but a list of strings is carried as a value checkPatch refuses.
59 */
60 export function askedRoles(value: unknown): { roles?: readonly string[] } {
61 if (value === undefined) return {};
62 return {
63 roles:
64 Array.isArray(value) && value.every((r) => typeof r === "string")
65 ? value
66 : [""],
67 };
4868 }
4969
5070 export type CreateAgentError =
@@ -53,7 +73,9 @@ export type CreateAgentError =
5373 | CreateUserError
5474 | "invalid_model"
5575 | "brief_too_long"
56 | "invalid_budget";
76 | "invalid_budget"
77 | "invalid_roles"
78 | "role_held";
5779
5880 function requireForgeAdmin(
5981 actor: User | undefined,
@@ -66,8 +88,13 @@ function requireForgeAdmin(
6688 /** The bounds an agent change must meet; the screens check them before they ask. */
6789 export function checkPatch(
6890 app: App,
69 patch: AgentPatch,
70 ): Result<AgentPatch, "invalid_model" | "brief_too_long" | "invalid_budget"> {
91 patch: AgentChange,
92 ): Result<AgentPatch, "invalid_model" | "brief_too_long" | "invalid_budget" | "invalid_roles"> {
93 let roles: CrewRole[] | undefined;
94 if (patch.roles !== undefined) {
95 if (patch.roles.length === 0 || !patch.roles.every(isCrewRole)) return err("invalid_roles");
96 roles = patch.roles as CrewRole[];
97 }
7198 if (patch.model !== undefined) {
7299 if (patch.model.length > MODEL_MAX || /[\s\0]/.test(patch.model))
73100 return err("invalid_model");
@@ -81,7 +108,8 @@ export function checkPatch(
81108 patch.budgetUsd > limitValue(app.db, "agent.budget_max_usd"))
82109 )
83110 return err("invalid_budget");
84 return ok(patch);
111 const { roles: _asked, ...rest } = patch;
112 return ok({ ...rest, ...(roles === undefined ? {} : { roles }) });
85113 }
86114
87115 /** A new crew member. Admin only: this is an account that will act. */
@@ -96,6 +124,7 @@ export async function createAgent(
96124 ...(input.model === undefined ? {} : { model: input.model }),
97125 ...(input.brief === undefined ? {} : { brief: input.brief }),
98126 ...(input.budgetUsd === undefined ? {} : { budgetUsd: input.budgetUsd }),
127 ...(input.roles === undefined ? {} : { roles: input.roles }),
99128 });
100129 if (!patch.ok) return patch;
101130 const user = await createUser(app.db, {
@@ -112,6 +141,7 @@ export async function createAgent(
112141 model: patch.value.model,
113142 brief: patch.value.brief,
114143 budgetUsd: patch.value.budgetUsd,
144 roles: patch.value.roles,
115145 createdBy: admin.value.id,
116146 });
117147 app.bus.append({
@@ -126,7 +156,7 @@ export function updateAgent(
126156 app: App,
127157 actor: User | undefined,
128158 username: string,
129 patch: AgentPatch,
159 patch: AgentChange,
130160 ): Result<Agent, CreateAgentError | "not_found"> {
131161 const admin = requireForgeAdmin(actor);
132162 if (!admin.ok) return admin;
@@ -134,6 +164,11 @@ export function updateAgent(
134164 if (!agent) return err("not_found");
135165 const checked = checkPatch(app, patch);
136166 if (!checked.ok) return checked;
167 // No silent move: a seat the agent still holds on a repository is moved there first.
168 if (checked.value.roles) {
169 const held = seatsOutside(app.db, agent.user.id, checked.value.roles);
170 if (held.length > 0) return err("role_held");
171 }
137172 const updated = patchAgent(app.db, agent.user.id, checked.value);
138173 if (!updated) return err("not_found");
139174 app.bus.append({
@@ -206,6 +241,8 @@ export interface CrewMember {
206241 * or a stranger sees the crew's names and roles.
207242 */
208243 readonly config?: { model: string; brief: string; budgetUsd: number };
244 /** The seats the agent declares; the repository's `role` is one of them. */
245 readonly roles: readonly CrewRole[];
209246 }
210247
211248 /** The crew of a repository, shown as `actor` may see it. */
@@ -225,6 +262,7 @@ export function crewAsSeen(
225262 username: agent.user.username,
226263 displayName: agent.user.displayName,
227264 role,
265 roles: agent.roles,
228266 ...(configured
229267 ? {
230268 config: {
@@ -245,7 +283,7 @@ export function addToCrew(
245283 repo: Repo,
246284 username: string,
247285 role?: CrewRole,
248 ): Result<Agent, "not_found" | "no_such_agent" | "not_an_agent" | "unverified"> {
286 ): Result<Agent, "not_found" | "no_such_agent" | "not_an_agent" | "unverified" | "role_not_declared"> {
249287 const admin = requireRepoAdmin(app, actor, repo);
250288 if (!admin.ok) return admin;
251289 const proven = requireVerified(app, admin.value);
@@ -254,9 +292,10 @@ export function addToCrew(
254292 // A person is never on a crew: said plainly, before the database would.
255293 if (!agent)
256294 return err(getUserByName(app.db, username) ? "not_an_agent" : "no_such_agent");
257 if (insertCrew(app.db, repo.id, agent.user.id, admin.value.id)) {
258 // A seat is named only for an agent who joins now; one already on the crew keeps its role.
259 if (role !== undefined) setCrewRole(app.db, repo.id, agent.user.id, role);
295 // A repository narrows what an agent declares, never widens it.
296 if (role !== undefined && !agent.roles.includes(role)) return err("role_not_declared");
297 // A seat is named only for an agent who joins now; one already on the crew keeps its role.
298 if (insertCrew(app.db, repo.id, agent.user.id, admin.value.id, role)) {
260299 app.bus.append({
261300 kind: "crew.added",
262301 repoId: repo.id,
src/actions/migrate.ts +10 −4
@@ -133,7 +133,8 @@ export type MigrateError =
133133 | "no_such_origin"
134134 | "unreachable"
135135 | "invalid_branch"
136 | "no_such_agent";
136 | "no_such_agent"
137 | "not_a_builder";
137138
138139 /** What the clone reads from, resolved: where, and how to authenticate. */
139140 interface Resolved {
@@ -191,8 +192,11 @@ export async function migrateRepo(
191192
192193 const source = await resolveSource(app, user.value, input.source, input.resolve);
193194 if (!source.ok) return source;
194 if (input.crew !== undefined && !getAgentByName(app.db, input.crew.agent))
195 return err("no_such_agent");
195 if (input.crew !== undefined) {
196 const asked = getAgentByName(app.db, input.crew.agent);
197 if (!asked) return err("no_such_agent");
198 if (!asked.roles.includes("builder")) return err("not_a_builder");
199 }
196200 const branch = (input.branch ?? "").trim();
197201 if (branch !== "" && !isSafeRef(branch)) return err("invalid_branch");
198202
@@ -268,6 +272,8 @@ export function migrateMessage(error: MigrateError, limit = AT_REPOSITORY_LIMIT)
268272 return "The origin could not be reached. Try again, or reconnect it under Settings → Origins.";
269273 case "no_such_agent":
270274 return NO_SUCH_AGENT;
275 case "not_a_builder":
276 return "That agent is not a builder, so it cannot take a migration. Pick a builder, or change its roles under Settings → Crew.";
271277 case "invalid_branch":
272278 return "That is not a branch name git would take.";
273279 case "name_taken":
@@ -836,7 +842,7 @@ async function putCrewOn(
836842 work: WorkBranch | undefined,
837843 ): Promise<string> {
838844 const said: string[] = [];
839 const added = addToCrew(app, user, repo, crew.agent);
845 const added = addToCrew(app, user, repo, crew.agent, "builder");
840846 if (!added.ok) return `${crew.agent} could not join the crew.`;
841847 const made = enableApp(app, user, repo, {});
842848 if (!made.ok)
src/actions/spawn.ts +4 −2
@@ -402,9 +402,11 @@ async function copyInto(
402402 if (!forgeCode.ok) return err(forgeCode.error);
403403
404404 for (const b of plan.briefs) {
405 // The jobs it declares travel with it (#8907).
406 const roles = getAgentByName(app.db, b.agent)?.roles;
405407 const existing = getAgentByName(child.db, b.agent);
406408 if (existing) {
407 updateAgent(child.db, existing.user.id, { brief: b.text });
409 updateAgent(child.db, existing.user.id, { brief: b.text, ...(roles ? { roles } : {}) });
408410 continue;
409411 }
410412 const made = await createUser(child.db, {
@@ -414,7 +416,7 @@ async function copyInto(
414416 origin: "spawn",
415417 });
416418 if (!made.ok) return err(`could not make crew member ${b.agent}: ${made.error}`);
417 createAgentRow(child.db, { userId: made.value.id, brief: b.text, createdBy: founder.id });
419 createAgentRow(child.db, { userId: made.value.id, brief: b.text, roles, createdBy: founder.id });
418420 }
419421
420422 // A carried crew member keeps its place on each app: the role it had on
src/core/agents.ts +68 −14
@@ -19,6 +19,8 @@ export interface Agent {
1919 readonly brief: string;
2020 /** Spend per run in USD; 0 means the forge's default. */
2121 readonly budgetUsd: number;
22 /** The seats it can hold, in planner, builder, reviewer order; never empty. */
23 readonly roles: readonly CrewRole[];
2224 readonly createdBy: string | null;
2325 readonly createdAt: string;
2426 }
@@ -28,6 +30,7 @@ interface AgentRow {
2830 model: string;
2931 brief: string;
3032 budget_usd: number;
33 roles: string;
3134 created_by: string | null;
3235 created_at: string;
3336 }
@@ -46,6 +49,7 @@ function agentOf(user: User, row: AgentRow): Agent {
4649 model: row.model,
4750 brief: row.brief,
4851 budgetUsd: row.budget_usd,
52 roles: parseRoles(row.roles),
4953 createdBy: row.created_by,
5054 createdAt: row.created_at,
5155 };
@@ -58,17 +62,19 @@ export function createAgentRow(
5862 model?: string | undefined;
5963 brief?: string | undefined;
6064 budgetUsd?: number | undefined;
65 roles?: readonly CrewRole[] | undefined;
6166 createdBy?: string | undefined;
6267 },
6368 ): Agent {
6469 db.query(
65 "INSERT INTO agent (user_id, model, brief, budget_usd, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
70 "INSERT INTO agent (user_id, model, brief, budget_usd, created_by, roles) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
6671 ).run(
6772 input.userId,
6873 input.model ?? "",
6974 input.brief ?? "",
7075 input.budgetUsd ?? 0,
7176 input.createdBy ?? null,
77 joinRoles(input.roles),
7278 );
7379 return getAgent(db, input.userId)!;
7480 }
@@ -113,6 +119,8 @@ export interface AgentPatch {
113119 model?: string;
114120 brief?: string;
115121 budgetUsd?: number;
122 /** Replaces the agent's roles; empty or unknown ones are ignored. */
123 roles?: readonly CrewRole[];
116124 }
117125
118126 export function updateAgent(
@@ -134,13 +142,23 @@ export function updateAgent(
134142 params.push(Math.min(Math.max(patch.budgetUsd, 0), limitValue(db, "agent.budget_max_usd")));
135143 sets.push(`budget_usd = ?${params.length}`);
136144 }
145 if (patch.roles !== undefined && patch.roles.length > 0) {
146 params.push(joinRoles(patch.roles));
147 sets.push(`roles = ?${params.length}`);
148 }
137149 if (sets.length > 0) {
138150 params.push(userId);
139151 db.query(
140152 `UPDATE agent SET ${sets.join(", ")} WHERE user_id = ?${params.length}`,
141153 ).run(...params);
142154 }
143 return getAgent(db, userId);
155 const agent = getAgent(db, userId);
156 // A seat the agent no longer declares moves to one it does: a repo narrows, never widens.
157 if (agent && patch.roles !== undefined)
158 db.query(
159 "UPDATE repo_crew SET role = ?2 WHERE user_id = ?1 AND instr(',' || ?3 || ',', ',' || role || ',') = 0",
160 ).run(userId, agent.roles[0]!, agent.roles.join(","));
161 return agent;
144162 }
145163
146164 // ---- the crew of a repository ----
@@ -155,13 +173,22 @@ export function addCrew(
155173 repoId: string,
156174 userId: string,
157175 addedBy?: string,
176 role?: CrewRole,
158177 ): boolean {
178 const agent = getAgent(db, userId);
179 const declared = agent?.roles ?? [];
180 // No default to builder: the seat is the one asked for, else the first the agent declares.
181 // A user with no agent row goes on to the insert, where the database refuses a person.
182 const seat = agent
183 ? role !== undefined && declared.includes(role) ? role : declared[0]
184 : (role ?? "builder");
185 if (seat === undefined) return false;
159186 return (
160187 db
161188 .query(
162 "INSERT OR IGNORE INTO repo_crew (repo_id, user_id, added_by) VALUES (?1, ?2, ?3)",
189 "INSERT OR IGNORE INTO repo_crew (repo_id, user_id, added_by, role) VALUES (?1, ?2, ?3, ?4)",
163190 )
164 .run(repoId, userId, addedBy ?? null).changes > 0
191 .run(repoId, userId, addedBy ?? null, seat).changes > 0
165192 );
166193 }
167194
@@ -281,8 +308,8 @@ export function deleteAgent(db: Database, userId: string): boolean {
281308 */
282309 export type CrewRole = "builder" | "planner" | "reviewer";
283310 export const CREW_ROLES: readonly CrewRole[] = [
284 "builder",
285311 "planner",
312 "builder",
286313 "reviewer",
287314 ];
288315 export const isCrewRole = (value: unknown): value is CrewRole =>
@@ -295,11 +322,37 @@ export function crewRole(
295322 ): CrewRole | undefined {
296323 const row = db
297324 .query<
298 { role: string },
325 { role: string; roles: string },
299326 [string, string]
300 >("SELECT role FROM repo_crew WHERE repo_id = ?1 AND user_id = ?2")
327 >("SELECT repo_crew.role, agent.roles FROM repo_crew JOIN agent ON agent.user_id = repo_crew.user_id WHERE repo_id = ?1 AND repo_crew.user_id = ?2")
301328 .get(repoId, userId);
302 return row && isCrewRole(row.role) ? row.role : row ? "builder" : undefined;
329 return row ? seatOf(row.role, parseRoles(row.roles)) : undefined;
330 }
331
332 /** The roles an agent declares, in seat order; a bad or empty value reads as builder. */
333 export function parseRoles(value: string): CrewRole[] {
334 const out = CREW_ROLES.filter((r) => value.split(",").includes(r));
335 return out.length > 0 ? out : ["builder"];
336 }
337
338 function joinRoles(roles: readonly CrewRole[] | undefined): string {
339 return parseRoles((roles ?? []).join(",")).join(",");
340 }
341
342 /** The seat a repo row holds: its own when the agent declares it, else the agent's first. */
343 function seatOf(role: string, declared: readonly CrewRole[]): CrewRole {
344 return isCrewRole(role) && declared.includes(role) ? role : declared[0]!;
345 }
346
347 /** The repositories where an agent holds a seat that `roles` would not declare. */
348 export function seatsOutside(db: Database, userId: string, roles: readonly CrewRole[]): string[] {
349 return db
350 .query<{ name: string }, [string, string]>(
351 `SELECT repo.name FROM repo_crew JOIN repo ON repo.id = repo_crew.repo_id
352 WHERE repo_crew.user_id = ?1 AND instr(',' || ?2 || ',', ',' || repo_crew.role || ',') = 0`,
353 )
354 .all(userId, roles.join(","))
355 .map((r) => r.name);
303356 }
304357
305358 export function setCrewRole(
@@ -308,6 +361,8 @@ export function setCrewRole(
308361 userId: string,
309362 role: CrewRole,
310363 ): boolean {
364 // A repository narrows what an agent declares and never widens it.
365 if (!getAgent(db, userId)?.roles.includes(role)) return false;
311366 return (
312367 db
313368 .query(
@@ -328,13 +383,11 @@ export function listCrewRoles(
328383 "SELECT user_id, role FROM repo_crew WHERE repo_id = ?1",
329384 )
330385 .all(repoId)
331 .map(
332 (r) => [r.user_id, isCrewRole(r.role) ? r.role : "builder"] as const,
333 ),
386 .map((r) => [r.user_id, r.role] as const),
334387 );
335388 return listCrew(db, repoId).map((agent) => ({
336389 agent,
337 role: roles.get(agent.user.id) ?? "builder",
390 role: seatOf(roles.get(agent.user.id) ?? "", agent.roles),
338391 }));
339392 }
340393
@@ -360,6 +413,7 @@ export function crewWithRole(
360413 JOIN agent ON agent.user_id = repo_crew.user_id
361414 JOIN user ON user.id = agent.user_id
362415 WHERE repo_crew.role = ?1
416 AND instr(',' || agent.roles || ',', ',' || ?1 || ',') > 0
363417 AND repo_crew.repo_id IN (${batch.map((_, n) => `?${n + 2}`).join(",")})
364418 ORDER BY user.username COLLATE NOCASE`,
365419 )
@@ -396,8 +450,8 @@ export function reposWithBuilder(
396450 const batch = unique.slice(i, i + CHUNK);
397451 const rows = db
398452 .query<{ repo_id: string }, string[]>(
399 `SELECT DISTINCT repo_id FROM repo_crew
400 WHERE role = 'builder' AND repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`,
453 `SELECT DISTINCT repo_id FROM repo_crew JOIN agent ON agent.user_id = repo_crew.user_id
454 WHERE role = 'builder' AND instr(',' || agent.roles || ',', ',builder,') > 0 AND repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`,
401455 )
402456 .all(...batch);
403457 for (const row of rows) out.add(row.repo_id);
src/core/migration-pins/20261103140000-agent-roles.pin added +1
@@ -0,0 +1,1 @@
1 c7b646072706c89877014e53aa0c50485cd617b72d77c1c02ab62843e8118689
src/core/migrations/20261103140000-agent-roles.ts added +12
@@ -0,0 +1,12 @@
1 // An agent's roles are its own config (#8907): the seats it can hold, planner,
2 // builder, reviewer, one or more, comma-joined in that order. Existing agents
3 // take the roles they hold today on any repository, so nothing changes under
4 // them; an agent on no crew keeps `builder`, which is what it read as. Additive.
5 export default `
6 ALTER TABLE agent ADD COLUMN roles TEXT NOT NULL DEFAULT 'builder';
7 UPDATE agent SET roles = rtrim(
8 (CASE WHEN EXISTS (SELECT 1 FROM repo_crew WHERE user_id = agent.user_id AND role = 'planner') THEN 'planner,' ELSE '' END) ||
9 (CASE WHEN EXISTS (SELECT 1 FROM repo_crew WHERE user_id = agent.user_id AND role = 'builder') THEN 'builder,' ELSE '' END) ||
10 (CASE WHEN EXISTS (SELECT 1 FROM repo_crew WHERE user_id = agent.user_id AND role = 'reviewer') THEN 'reviewer,' ELSE '' END), ',')
11 WHERE EXISTS (SELECT 1 FROM repo_crew WHERE user_id = agent.user_id);
12 `;
src/crew/dispatch.ts +38
@@ -374,6 +374,36 @@ export function atCeiling(
374374 return spentUsd >= repo.autonomyCeilingUsd;
375375 }
376376
377 /**
378 * Whether this run is a job outside the agent's seat here: a build for one
379 * that is not a builder (the direction issue is the planner's), a review for
380 * one that is not a reviewer. On the crew the seat is the repository's; off
381 * it, what the agent declares.
382 */
383 export function outsideSeat(
384 app: App,
385 repo: Repo,
386 issue: Issue,
387 agent: Agent,
388 reason: RunReason,
389 ): boolean {
390 // Every reason but a review is building work: a first go, feedback, a red check, a conflict, a converge.
391 const seat =
392 reason === "review"
393 ? "reviewer"
394 : issue.id === repo.directionIssueId
395 ? "planner"
396 : "builder";
397 const held = crewRole(app.db, repo.id, agent.user.id);
398 // A pull assigned to its reviewer is asked for a look, a builder's for a fix: only a planner is outside.
399 if (reason !== "review" && issue.kind === "pull")
400 return (held ?? agent.roles[0]) === "planner";
401 return held === undefined ? !agent.roles.includes(seat) : held !== seat;
402 }
403
404 export const NOT_A_BUILDER_NOTE =
405 "I am not a builder here, so I have not started on this. Assign it to a builder on the crew, or change my roles in Settings.";
406
377407 /** Queue a run about an issue for an agent, unless one is already waiting. */
378408 export function queueAgentRun(
379409 app: App,
@@ -390,6 +420,8 @@ export function queueAgentRun(
390420 ): Run | undefined {
391421 // A suspended or removed owner's repositories run nothing, whoever asks.
392422 if (!accountActive(app.db, repo.ownerId)) return undefined;
423 // Never a job outside the agent's seat: building is a builder's, reviewing a reviewer's.
424 if (outsideSeat(app, repo, issue, agent, reason)) return undefined;
393425 // An archived repository starts nothing new; a run carrying on (`again`)
394426 // is one already going, and finishes. A first run
395427 // (`attempt` 0, as a conflict or converge run is queued) is new.
@@ -582,6 +614,12 @@ export function route(app: App, event: ForgeEvent): void {
582614 ? getAgent(app.db, issue.assigneeId)
583615 : undefined;
584616 if (!agent || issue.state !== "open") return;
617 // Assigned to a planner or a reviewer: said once, never silently run.
618 if (outsideSeat(app, repo, issue, agent, "assigned")) {
619 if (!listComments(app.db, issue.id).some((c) => c.body === NOT_A_BUILDER_NOTE))
620 addComment(app, agent.user, repo, issue, NOT_A_BUILDER_NOTE);
621 return;
622 }
585623 queueAgentRun(
586624 app,
587625 repo,
src/crew/planner.ts +2 −3
@@ -7,7 +7,7 @@
77
88 import type { App } from "../app.ts";
99 import { addToCrew } from "../actions/crew.ts";
10 import { getAgent, listCrewRoles, setCrewRole, type Agent } from "../core/agents.ts";
10 import { getAgent, listCrewRoles, type Agent } from "../core/agents.ts";
1111 import type { Repo } from "../core/repos.ts";
1212 import type { User } from "../core/users.ts";
1313
@@ -42,8 +42,7 @@ export function ensurePlanner(app: App, repo: Repo, by: User): Agent | undefined
4242 if (have !== undefined) return have.agent;
4343 const chosen = ownersPlanner(app, repo);
4444 if (chosen === undefined) return undefined;
45 if (!addToCrew(app, by, repo, chosen.user.username).ok) return undefined;
46 setCrewRole(app.db, repo.id, chosen.user.id, "planner");
45 if (!addToCrew(app, by, repo, chosen.user.username, "planner").ok) return undefined;
4746 return chosen;
4847 }
4948
src/http/api.ts +1
@@ -988,6 +988,7 @@ export function agentJson(agent: Agent) {
988988 model: agent.model,
989989 brief: agent.brief,
990990 budgetUsd: agent.budgetUsd,
991 roles: agent.roles,
991992 createdAt: agent.createdAt,
992993 };
993994 }
src/http/api/agents.ts +3
@@ -5,6 +5,7 @@
55 */
66
77 import {
8 askedRoles,
89 createAgent,
910 deleteAgent,
1011 getAgentView,
@@ -45,6 +46,7 @@ export function agentRoutes(router: Router<Handler>): void {
4546 ...(typeof input.budgetUsd === "number"
4647 ? { budgetUsd: input.budgetUsd }
4748 : {}),
49 ...askedRoles(input.roles),
4850 });
4951 if (!made.ok) return respondAccess(made.error, 422);
5052 return json(agentJson(made.value), 201);
@@ -76,6 +78,7 @@ export function agentRoutes(router: Router<Handler>): void {
7678 ...(typeof input.budgetUsd === "number"
7779 ? { budgetUsd: input.budgetUsd }
7880 : {}),
81 ...askedRoles(input.roles),
7982 },
8083 );
8184 if (!updated.ok) return respondAccess(updated.error, 422);
src/http/mcp/tools/agent.ts +5 −2
@@ -1,10 +1,10 @@
1 import { createAgent, deleteAgent, updateAgent } from "../../../actions/crew.ts";
1 import { askedRoles, createAgent, deleteAgent, updateAgent } from "../../../actions/crew.ts";
22 import type { Agent } from "../../../core/agents.ts";
33 import { good, bad, type Tool, str } from "../shared.ts";
44
55 export const tool: Tool = {
66 name: "agent",
7 doc: "`{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`",
7 doc: "`{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief`, `budgetUsd` or `roles` (planner, builder, reviewer), 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",
@@ -15,6 +15,7 @@ export const tool: Tool = {
1515 model: { type: "string" },
1616 brief: { type: "string" },
1717 budgetUsd: { type: "number" },
18 roles: { type: "array", items: { type: "string" } },
1819 },
1920 required: ["action", "username"],
2021 },
@@ -30,6 +31,7 @@ export const tool: Tool = {
3031 ...(typeof model === "string" ? { model } : {}),
3132 ...(typeof brief === "string" ? { brief } : {}),
3233 ...(typeof budgetUsd === "number" ? { budgetUsd } : {}),
34 ...askedRoles(args["roles"]),
3335 };
3436 const shown = (a: Agent) => ({
3537 username: a.user.username,
@@ -37,6 +39,7 @@ export const tool: Tool = {
3739 model: a.model,
3840 brief: a.brief,
3941 budgetUsd: a.budgetUsd,
42 roles: a.roles,
4043 createdAt: a.createdAt,
4144 });
4245 const action = args["action"];
src/tui/screens/agents.ts +12 −3
@@ -1,14 +1,15 @@
11 /**
22 * Crew agents in the terminal, a forge admin's: each agent's model, budget and
33 * the first line of its brief, as MCP `agent` and /api/v1/agents have them.
4 * `m`, `b` and `B` change model, budget and brief, `n` creates one and `d`
4 * `m`, `b` and `B` change model, budget and brief, `r` its roles (planner,
5 * builder, reviewer: what a repository's team lists offer it for), `n` creates one and `d`
56 * removes one. Every change shows its plan and applies only after a confirm,
67 * through `updateAgent`, `createAgent` and `deleteAgent`, so the same bounds
78 * apply and a refusal is said in MCP's words (the error code).
89 */
910
1011 import { checkPatch, createAgent, deleteAgent, listAllAgents, updateAgent } from "../../actions/crew.ts";
11 import type { AgentPatch } from "../../core/agents.ts";
12 import { CREW_ROLES, type AgentPatch } from "../../core/agents.ts";
1213 import type { Ctx, Screen } from "../app/app.ts";
1314 import { binding } from "../app/keys.ts";
1415 import { style } from "../term/style.ts";
@@ -33,7 +34,7 @@ export function agentsScreen(f: Session, ctx: Ctx): Screen {
3334 const rows = (): ListRow[] =>
3435 list().map((a) => ({
3536 title: a.user.username,
36 secondary: `${a.model === "" ? "default model" : a.model} · ${a.budgetUsd === 0 ? "default budget" : `$${a.budgetUsd}`}`,
37 secondary: `${a.roles.join("+")} · ${a.model === "" ? "default model" : a.model} · ${a.budgetUsd === 0 ? "default budget" : `$${a.budgetUsd}`}`,
3738 meta: firstLine(a.brief),
3839 }));
3940
@@ -44,6 +45,7 @@ export function agentsScreen(f: Session, ctx: Ctx): Screen {
4445 binding(["m"], "model"),
4546 binding(["b"], "budget"),
4647 binding(["B"], "brief"),
48 binding(["r"], "roles"),
4749 binding(["n"], "new"),
4850 binding(["d"], "remove"),
4951 ],
@@ -101,6 +103,13 @@ export function agentsScreen(f: Session, ctx: Ctx): Screen {
101103 if (!bad.ok) return `refused: ${bad.error}`;
102104 return later(ctx, () => confirm(name, { brief: v }, `${name}: brief → "${truncate(v, 60)}". y/n`));
103105 });
106 else if (e.key === "r")
107 ask(name, `roles: ${CREW_ROLES.join(", ")} (comma separated)`, agent.roles.join(", "), (v) => {
108 const roles = v.split(",").map((r) => r.trim()).filter((r) => r !== "");
109 const bad = checkPatch(f.app, { roles });
110 if (!bad.ok) return `refused: ${bad.error}`;
111 return later(ctx, () => confirm(name, bad.value, `${name}: roles ${agent.roles.join("+")} → ${roles.join("+")}. y/n`));
112 });
104113 else if (e.key === "d")
105114 ctx.push(
106115 confirmScreen(ctx, screen, {
src/web/pages/crew-settings.ts +7 −2
@@ -43,7 +43,10 @@ export async function crewAdd(ctx: Ctx): Promise<Response> {
4343 const { repo, form } = gate;
4444 // The team card's one button names the seat it fills.
4545 const role = field(form, "role");
46 const added = addToCrew(ctx.app, ctx.auth.user, repo, field(form, "agent"), isCrewRole(role) ? role : undefined);
46 const asked = isCrewRole(role) ? role : undefined;
47 const added = addToCrew(ctx.app, ctx.auth.user, repo, field(form, "agent"), asked);
48 // An agent already on the crew picked for another list moves to it.
49 if (added.ok && asked !== undefined) setCrewRole(ctx.app.db, repo.id, added.value.user.id, asked);
4750 return flashTo(
4851 ctx,
4952 `${repoBase(repo)}/settings#crew`,
@@ -54,7 +57,9 @@ export async function crewAdd(ctx: Ctx): Promise<Response> {
5457 : {
5558 tone: "danger",
5659 message:
57 added.error === "not_an_agent"
60 added.error === "role_not_declared"
61 ? "That agent does not do that job. Change its roles first, under Settings → Crew."
62 : added.error === "not_an_agent"
5863 ? "Only an agent can be on a crew; a person is given access through an organization's team."
5964 : "No such agent on this forge.",
6065 },
src/web/pages/crew.ts +21 −1
@@ -15,6 +15,7 @@ import {
1515 } from "../../actions/crew.ts";
1616 import {
1717 BRIEF_MAX,
18 CREW_ROLES,
1819 MODEL_MAX,
1920 type Agent,
2021 } from "../../core/agents.ts";
@@ -50,7 +51,7 @@ import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts";
5051 import { listAutonomousRepos, type Repo } from "../../core/repos.ts";
5152 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
5253 import { field, readForm, type Form } from "../form.ts";
53 import { html, when, type Html } from "../html.ts";
54 import { html, raw, when, type Html } from "../html.ts";
5455 import { flashTo, page } from "../layout.ts";
5556 import {
5657 btn,
@@ -158,6 +159,7 @@ interface AgentValues {
158159 model: string;
159160 brief: string;
160161 budgetUsd: string;
162 roles: readonly string[];
161163 }
162164
163165 const agentErrors = (ctx: Ctx): Record<string, string> => ({
@@ -166,6 +168,8 @@ const agentErrors = (ctx: Ctx): Record<string, string> => ({
166168 email_taken: USERNAME_TAKEN,
167169 invalid_model: `A model is up to ${MODEL_MAX} characters with no spaces.`,
168170 brief_too_long: `A brief is at most ${BRIEF_MAX} characters.`,
171 invalid_roles: "Pick at least one job: planner, builder or reviewer.",
172 role_held: "That agent still holds a seat it would lose on a repository. Move it there first, under Settings → Crew.",
169173 invalid_budget: `A budget is a number of dollars between 0 and ${limitValue(ctx.app.db, "agent.budget_max_usd")}.`,
170174 });
171175
@@ -190,6 +194,14 @@ function agentFields(values: AgentValues, error?: string): Html {
190194 hint: "Who this agent is, in its own voice — appended to the prompt of every run. The repository's AGENTS.md says what the work is; this says who does it.",
191195 ...(error !== undefined && error.startsWith("A brief") ? { error } : {}),
192196 })}
197 <fieldset class="field" id="agent-roles">
198 <legend>Jobs</legend>
199 ${CREW_ROLES.map(
200 (r) => html`<label class="check"><input type="checkbox" name="roles" value="${r}" ${values.roles.includes(r) ? raw("checked") : null} />
201 <span>${r}</span></label>`,
202 )}
203 <p class="hint">What this agent does. A repository's team lists offer it only for these, and a dispatch never hands it another kind of job.${error !== undefined && error.startsWith("Pick at least") ? html` <strong>${error}</strong>` : null}</p>
204 </fieldset>
193205 ${uiField({
194206 label: "Budget per run (USD)",
195207 name: "budgetUsd",
@@ -206,8 +218,11 @@ const valuesOf = (agent: Agent): AgentValues => ({
206218 model: agent.model,
207219 brief: agent.brief,
208220 budgetUsd: agent.budgetUsd === 0 ? "" : String(agent.budgetUsd),
221 roles: agent.roles,
209222 });
210223
224 const rolesOf = (form: Form): string[] => form.getAll("roles").map(String);
225
211226 const budgetOf = (form: Form): number | undefined => {
212227 const raw = field(form, "budgetUsd").trim();
213228 return raw === "" ? undefined : Number(raw);
@@ -930,6 +945,7 @@ const EMPTY: AgentValues = {
930945 model: "",
931946 brief: "",
932947 budgetUsd: "",
948 roles: ["builder"],
933949 };
934950
935951 // GET /settings/crew
@@ -963,6 +979,7 @@ export async function agentCreate(ctx: Ctx): Promise<Response> {
963979 model: field(form, "model").trim(),
964980 brief: field(form, "brief"),
965981 budgetUsd: field(form, "budgetUsd").trim(),
982 roles: rolesOf(form),
966983 };
967984 const budgetUsd = budgetOf(form);
968985 const made = await createAgent(ctx.app, ctx.auth.user, {
@@ -970,6 +987,7 @@ export async function agentCreate(ctx: Ctx): Promise<Response> {
970987 ...(values.displayName === "" ? {} : { displayName: values.displayName }),
971988 model: values.model,
972989 brief: values.brief,
990 roles: values.roles,
973991 ...(budgetUsd === undefined ? {} : { budgetUsd }),
974992 });
975993 if (!made.ok) {
@@ -1059,11 +1077,13 @@ export async function agentUpdate(ctx: Ctx): Promise<Response> {
10591077 model: field(form, "model").trim(),
10601078 brief: field(form, "brief"),
10611079 budgetUsd: field(form, "budgetUsd").trim(),
1080 roles: rolesOf(form),
10621081 };
10631082 const updated = updateAgent(ctx.app, ctx.auth.user, agent.user.username, {
10641083 model: values.model,
10651084 brief: values.brief,
10661085 budgetUsd: budgetOf(form) ?? 0,
1086 roles: values.roles,
10671087 });
10681088 if (!updated.ok) {
10691089 if (updated.error === "not_found") return notFoundPage(ctx);
src/web/pages/migrate-prefill.ts +20
@@ -18,6 +18,8 @@ export interface Prefill {
1818 readonly agent?: string;
1919 /** Values that matched nothing, as given (capped) — the page escapes them. */
2020 readonly unmatched: string[];
21 /** Sentences for a name that is on the forge but is not a builder. */
22 readonly refused: string[];
2123 }
2224
2325 const FROM_MAX = 200;
@@ -36,8 +38,10 @@ export function prefill(
3638 query: { from?: string | null; crew?: string | null },
3739 origins: readonly PrefillOrigin[],
3840 agents: readonly string[],
41 notBuilders: readonly string[] = [],
3942 ): Prefill {
4043 const unmatched: string[] = [];
44 const refused: string[] = [];
4145 let repo: Prefill["repo"];
4246 let agent: string | undefined;
4347
@@ -63,6 +67,8 @@ export function prefill(
6367 if (crew !== "") {
6468 if (crew.length <= CREW_MAX && CREW_SAFE.test(crew) && agents.includes(crew))
6569 agent = crew;
70 else if (crew.length <= CREW_MAX && notBuilders.includes(crew))
71 refused.push(`${crew} is not a builder, so it cannot take a migration. Pick one of the builders below.`);
6672 else unmatched.push(`crew=${shown(crew)}`);
6773 }
6874
@@ -70,5 +76,19 @@ export function prefill(
7076 ...(repo ? { repo } : {}),
7177 ...(agent ? { agent } : {}),
7278 unmatched,
79 refused,
7380 };
7481 }
82
83 /**
84 * The agents a migration may be handed to: builders only, the ones that do
85 * nothing else first (a specialist is the surest fit), then by name. The
86 * first is the page's default, so a planner that sorts first is never picked.
87 */
88 export function migrationBuilders<A extends { roles: readonly string[]; user: { username: string } }>(
89 agents: readonly A[],
90 ): A[] {
91 return agents
92 .filter((a) => a.roles.includes("builder"))
93 .sort((a, b) => a.roles.length - b.roles.length || a.user.username.localeCompare(b.user.username));
94 }
src/web/pages/migrate.ts +15 −4
@@ -69,7 +69,7 @@ import {
6969 } from "../../origins/token-reach.ts";
7070 import type { TokenReach } from "../../origins/types.ts";
7171 import { STAGE_BADGE_JS, stageRow, stageStateFor } from "../stage-row.ts";
72 import { prefill, originHost } from "./migrate-prefill.ts";
72 import { prefill, originHost, migrationBuilders } from "./migrate-prefill.ts";
7373 import { githubConnectable, hostLabel } from "./origins.ts";
7474
7575 interface MigrateValues {
@@ -424,6 +424,7 @@ function migrateForm(
424424 branches: BranchChoices | undefined,
425425 error?: string,
426426 unmatched: readonly string[] = [],
427 refused: readonly string[] = [],
427428 ): Html {
428429 const user = ctx.auth.user!;
429430 const writeOrgs = listOrgsForUser(ctx.app.db, user.id).filter((org) =>
@@ -431,7 +432,7 @@ function migrateForm(
431432 );
432433 const owners = [user.username, ...writeOrgs.map((o) => o.username)];
433434 const agents = listAllAgents(ctx.app, user);
434 const crew = agents.ok ? agents.value : [];
435 const crew = agents.ok ? migrationBuilders(agents.value) : [];
435436
436437 return html`<div class="narrow-wide">
437438 ${pageHead("Migrate a repository", {
@@ -449,6 +450,11 @@ function migrateForm(
449450 ${icon("globe")} Ignored, nothing here matches:
450451 ${unmatched.join(", ")}
451452 </p>`}
453 ${refused.length === 0
454 ? null
455 : html`<p class="muted small mb" id="prefill-refused">
456 ${icon("globe")} ${refused.join(" ")}
457 </p>`}
452458 ${accounts(ctx, origins)}
453459 <form method="get" action="/migrate" id="migrate-search"></form>
454460 ${card(
@@ -613,6 +619,7 @@ async function show(
613619 error?: string,
614620 status = 200,
615621 unmatched: readonly string[] = [],
622 refused: readonly string[] = [],
616623 ): Promise<Response> {
617624 // Asked for only: each draw is a listing and a commit per branch at the
618625 // origin (remembered a minute), so a plain visit costs none of it.
@@ -626,7 +633,7 @@ async function show(
626633 {
627634 ctx,
628635 title: "Migrate",
629 body: migrateForm(ctx, values, origins, branches, error, unmatched),
636 body: migrateForm(ctx, values, origins, branches, error, unmatched, refused),
630637 scriptJs: migrateJs,
631638 },
632639 status,
@@ -647,11 +654,14 @@ export async function migratePage(ctx: Ctx): Promise<Response> {
647654 const agents = timePhase(ctx.request, "agents", () =>
648655 listAllAgents(ctx.app, user),
649656 );
650 const names = agents.ok ? agents.value.map((a) => a.user.username) : [];
657 const all = agents.ok ? agents.value : [];
658 const builders = migrationBuilders(all);
659 const names = builders.map((a) => a.user.username);
651660 const linked = prefill(
652661 { from: q.get("from"), crew: q.get("crew") },
653662 origins,
654663 names,
664 all.filter((a) => !builders.includes(a)).map((a) => a.user.username),
655665 );
656666 return show(
657667 ctx,
@@ -676,6 +686,7 @@ export async function migratePage(ctx: Ctx): Promise<Response> {
676686 undefined,
677687 200,
678688 linked.unmatched,
689 linked.refused,
679690 );
680691 }
681692
src/web/pages/settings.ts +3 −1
@@ -83,7 +83,7 @@ import {
8383 settingsLayout,
8484 } from "../chrome.ts";
8585 import { daySpark } from "../studio/kpi_row.ts";
86 import { settingsLead } from "../studio/crew.ts";
86 import { settingsLead, teamJs } from "../studio/crew.ts";
8787 import { pageTitle } from "../studio/nouns.ts";
8888 import { appHref } from "./apps.ts";
8989 import { field, readForm, type Form } from "../form.ts";
@@ -191,6 +191,7 @@ export function settingsPageResponse(
191191 crumbs: crumbsFor(repo),
192192 palette: repoPalette(ctx, repo),
193193 ...(styleCss !== undefined ? { styleCss } : {}),
194 scriptJs: teamJs,
194195 body: html`${repoHeader(ctx, repo, "settings", { compact: true })}
195196 ${settingsLayout(sections(repoBase(repo), active), body)}`,
196197 },
@@ -236,6 +237,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
236237 title: pageTitle("settings", readable, true),
237238 crumbs: crumbsFor(readable),
238239 palette: repoPalette(ctx, readable),
240 scriptJs: teamJs,
239241 body: html`${repoHeader(ctx, readable, "settings", { compact: true })}
240242 ${lead}${proposals ? html`<div class="mt">${proposals}</div>` : null}`,
241243 });
src/web/studio/crew.ts +78 −98
@@ -26,7 +26,7 @@
2626 */
2727
2828 import { listAllAgents, listCrew } from "../../actions/crew.ts";
29 import { CREW_ROLES, listCrewRoles, type CrewRole } from "../../core/agents.ts";
29 import { listCrewRoles, type Agent, type CrewRole } from "../../core/agents.ts";
3030 import { getAppByRepo } from "../../core/apps.ts";
3131 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3232 import { seatsOf, type Seat } from "../../crew/seats.ts";
@@ -36,7 +36,7 @@ import { governorSeen } from "../../crew/governed.ts";
3636 import { crewOffInPreview } from "../../crew/preview.ts";
3737 import type { Ctx } from "../../http/context.ts";
3838 import { repoBase } from "../chrome.ts";
39 import { html, raw, type Html } from "../html.ts";
39 import { html, type Html } from "../html.ts";
4040 import { icon } from "../icons.ts";
4141 import { crewReadiness } from "../pages/crew.ts";
4242 import { grantsSection } from "../pages/subscriptions.ts";
@@ -125,59 +125,93 @@ const SEAT_BADGE: Record<Seat["state"], { text: string; tone: Tone }> = {
125125 invite: { text: "open seat", tone: "default" },
126126 };
127127
128 /** The script: choosing in a team list, or setting its number, saves at once. */
129 export const teamJs = /* js */ `
130 (function () {
131 document.querySelectorAll("form[data-team] select, form[data-team] input[type=number]").forEach(function (el) {
132 el.addEventListener("change", function () { el.form.requestSubmit(); });
133 });
134 document.querySelectorAll("form[data-team] button[type=submit]").forEach(function (b) { b.hidden = true; });
135 })();
136 `;
137
138 const SEAT_TITLE: Record<CrewRole, string> = {
139 planner: "Planners",
140 builder: "Builders",
141 reviewer: "Reviewers",
142 };
143
128144 /**
129 * Your team, as three seats (`seatsOf`, the same facts the MCP `crew` tool
130 * returns): whether it works on its own, who fills each seat, what it does,
131 * and — for an empty one — a warning when the team is on its own, a calm
132 * invitation when it is not, each with one button that puts an agent in it.
145 * Your team, as three lists: Planners, Builders, Reviewers (`seatsOf`, the
146 * same facts the MCP `crew` tool returns). Each list holds who fills it, a
147 * picker of only the agents that declare that role, and how many may work at
148 * once; each choice posts at once (`teamJs`; without script, the button is
149 * there). A seat with nobody warns when the team is on its own.
133150 */
134151 function teamCard(
135152 ctx: Ctx,
136153 repo: Repo,
137154 base: string,
138155 admin: boolean,
139 candidate: string | undefined,
156 everyone: readonly Agent[],
140157 ): Html {
141158 const team = seatsOf(ctx.app.db, repo, crewOffInPreview());
142 const fill = (p: Seat): Html | undefined => {
143 if (!admin || p.state === "filled") return undefined;
144 if (candidate === undefined)
145 return ctx.auth.user?.isAdmin
146 ? btn("Add an agent", { href: "/settings/crew", size: "sm" })
147 : undefined;
148 return html`<form class="row-actions" method="post" action="${base}/settings/crew">
149 <input type="hidden" name="agent" value="${candidate}" />
150 <input type="hidden" name="role" value="${p.role}" />
151 ${btn(`Put ${candidate} on ${p.role}`, {
152 size: "sm",
153 variant: p.state === "warn" ? "primary" : "secondary",
154 })}
155 </form>`;
156 };
159 const held = new Map(listCrewRoles(ctx.app.db, repo.id).map((c) => [c.agent.user.username, c.role]));
157160 const seatSlots = new Map(seatSlotsOf(ctx.app.db, repo).map((p) => [p.role, p]));
158161 const rows = team.seats.map((p) => {
159 const fillBy = fill(p);
160 const b = SEAT_BADGE[p.state];
161162 const slots = seatSlots.get(p.role)!;
162163 const paused = slots.slots === 0;
163 const setter = html`<form class="row-fields" method="post" action="${base}/settings/pillars">
164 <input type="hidden" name="seat" value="${p.role}" />
165 <label class="sr-only" for="f-seat-${p.role}">${p.role}: ${SEAT_UNIT[p.role]}</label>
166 <input class="input input-sm" id="f-seat-${p.role}" name="slots" type="number" inputmode="numeric"
167 min="0" max="${String(slots.max)}" value="${String(slots.slots)}" />
168 ${btn("Set", { size: "sm" })}
169 </form>`;
164 const b = SEAT_BADGE[p.state];
165 const picks = everyone.filter(
166 (a) => a.roles.includes(p.role) && held.get(a.user.username) !== p.role,
167 );
168 const add =
169 !admin || picks.length === 0
170 ? null
171 : html`<form class="row-fields" method="post" action="${base}/settings/crew" data-team>
172 <input type="hidden" name="role" value="${p.role}" />
173 <label class="sr-only" for="f-add-${p.role}">Add to ${SEAT_TITLE[p.role]}</label>
174 <select class="input input-sm" id="f-add-${p.role}" name="agent">
175 <option value="" disabled selected>Add…</option>
176 ${picks.map((a) => html`<option value="${a.user.username}">${a.user.username}</option>`)}
177 </select>
178 ${btn("Add", { size: "sm" })}
179 </form>`;
180 const setter = !admin
181 ? null
182 : html`<form class="row-fields" method="post" action="${base}/settings/pillars" data-team>
183 <input type="hidden" name="seat" value="${p.role}" />
184 <label class="sr-only" for="f-seat-${p.role}">${p.role}: ${SEAT_UNIT[p.role]}</label>
185 <input class="input input-sm" id="f-seat-${p.role}" name="slots" type="number" inputmode="numeric"
186 min="0" max="${String(slots.max)}" value="${String(slots.slots)}" />
187 ${btn("Set", { size: "sm" })}
188 </form>`;
189 const members = p.by.map((u) =>
190 row({
191 lead: icon("bot"),
192 title: userLink(u),
193 ...(admin
194 ? {
195 trail: html`<form class="row-actions" method="post" action="${base}/settings/crew/${encodeURIComponent(u)}/remove">
196 ${btn("Remove", { size: "sm", icon: "x" })}
197 </form>`,
198 }
199 : {}),
200 data: { member: u, seat: p.role },
201 }),
202 );
170203 return row({
171204 lead: icon(SEAT_LEAD[p.state]),
172 title: p.role[0]!.toUpperCase() + p.role.slice(1),
173 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}`,
174 trail: html`${paused ? badge("paused", { tone: "attn" }) : badge(b.text, { tone: b.tone })}${fillBy ?? null}`,
205 title: SEAT_TITLE[p.role],
206 meta: html`${p.does}${p.state === "warn" ? ` No ${p.role}: ${p.without}.` : null}`,
207 trail: html`${paused ? badge("paused", { tone: "attn" }) : badge(b.text, { tone: b.tone })}${add}`,
175208 data: { seat: p.role, state: p.state },
176209 children: [
210 ...members,
177211 row({
178212 title: paused ? "Paused" : `${String(slots.slots)} ${SEAT_UNIT[p.role]}`,
179213 meta: html`${seatSlotsWords(slots)}`,
180 ...(admin ? { trail: setter } : {}),
214 ...(setter ? { trail: setter } : {}),
181215 data: { slots: p.role, at: String(slots.slots) },
182216 }),
183217 ],
@@ -187,7 +221,7 @@ function teamCard(
187221 ? "The crew is off in a preview, so nobody here is working."
188222 : team.auto
189223 ? `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.`
190 : "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. Each seat says how much it may do at once; 0 pauses it without taking it off the team.";
224 : "Auto mode is off: your team works only when you ask. Pick who fills each list, then turn auto mode on under On its own. The number beside a list is how many may work at once; 0 pauses it.";
191225 return card(
192226 html`<p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}">
193227 ${intro}
@@ -256,72 +290,18 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
256290 ...(admin
257291 ? {
258292 trail: html`<form
259 class="row-fields"
260 method="post"
261 action="${base}/settings/crew/${encodeURIComponent(
262 c.agent.user.username,
263 )}/role"
264 >
265 <label
266 class="sr-only"
267 for="f-role-${c.agent.user.username}"
268 >Role of ${c.agent.user.username}</label
269 >
270 <select
271 class="input input-sm"
272 id="f-role-${c.agent.user.username}"
273 name="role"
274 >
275 ${CREW_ROLES.map(
276 (r) =>
277 html`<option
278 value="${r}"
279 ${c.role === r ? raw("selected") : null}
280 >
281 ${ROLE_WORD[r]}
282 </option>`,
283 )}
284 </select>
285 ${btn("Set", { size: "sm" })}
286 </form>
287 <form
288 class="row-actions"
289 method="post"
290 action="${base}/settings/crew/${encodeURIComponent(
291 c.agent.user.username,
292 )}/remove"
293 >
294 ${btn("Remove", { size: "sm", icon: "x" })}
295 </form>`,
293 class="row-actions"
294 method="post"
295 action="${base}/settings/crew/${encodeURIComponent(c.agent.user.username)}/remove"
296 >
297 ${btn("Remove", { size: "sm", icon: "x" })}
298 </form>`,
296299 }
297300 : {}),
298301 }),
299302 ),
300303 );
301304
302 const add =
303 !admin || available.length === 0
304 ? null
305 : card(
306 html`<form class="edit-row" method="post" action="${base}/settings/crew">
307 <label class="sr-only" for="f-agent">Agent</label>
308 <select class="input" id="f-agent" name="agent">
309 ${available.map(
310 (agent) =>
311 html`<option value="${agent.user.username}">
312 ${agent.user.username}
313 </option>`,
314 )}
315 </select>
316 ${btn("Add to crew", {
317 variant: "primary",
318 size: "sm",
319 icon: "plus",
320 })}
321 </form>`,
322 { head: html`<h2>Add an agent</h2>`, pad: false },
323 );
324
325305 // Whose money the crew spends here is the forge admin's to say.
326306 const grants =
327307 admin && forgeAdmin
@@ -336,14 +316,14 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
336316 })()
337317 : null;
338318 return html`<div id="crew">
339 ${teamCard(ctx, repo, base, admin, available[0]?.user.username)}
319 ${teamCard(ctx, repo, base, admin, allListed.ok ? allListed.value : [])}
340320 ${admin ? crewReadiness(ctx) : null}
341321 ${card(html`${rows}`, {
342322 head: html`${icon("bot")}
343323 <h2>Crew</h2>`,
344324 pad: false,
345325 })}
346 ${add} ${grants}
326 ${grants}
347327 </div>`;
348328 }
349329
test/api-give-back.test.ts +3 −2
@@ -5,12 +5,13 @@
55 * reader, or someone who cannot see the repository, gets `not_found`.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
1112 import { join } from "node:path";
1213 import { createApp, type App } from "../src/app.ts";
13 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
14 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1415 import { getIssueById, listComments } from "../src/core/issues.ts";
1516 import { createRepo, type Repo } from "../src/core/repos.ts";
1617 import { submitReview } from "../src/core/reviews.ts";
@@ -121,7 +122,7 @@ describe("POST /api/v1/asks/give-back", () => {
121122 privateRepo = closed.value;
122123 for (const target of [repo, privateRepo]) {
123124 for (const agent of [builder, reviewer]) addCrew(app.db, target.id, agent.id, owner.id);
124 setCrewRole(app.db, target.id, reviewer.id, "reviewer");
125 seatAs(app.db, target.id, reviewer.id, "reviewer");
125126 }
126127 rw = createToken(app.db, owner.id, "rw", "rw").token;
127128 ro = createToken(app.db, owner.id, "ro", "ro").token;
test/api-user-next-steps.test.ts +3 −2
@@ -3,6 +3,7 @@
33 * same three steps as MCP `next_steps`; anyone else, or an agent, gets [].
44 */
55
6 import { seatAs } from "./helpers/seat.ts";
67 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
89 import { tmpdir } from "node:os";
@@ -10,7 +11,7 @@ import { join } from "node:path";
1011 import { createRepo } from "../src/actions/repos.ts";
1112 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1213 import { createApp, type App } from "../src/app.ts";
13 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
14 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1415 import { createToken } from "../src/core/tokens.ts";
1516 import { createUser, getUserByName } from "../src/core/users.ts";
1617 import { commitFiles } from "../src/git/write.ts";
@@ -79,7 +80,7 @@ describe("GET /api/v1/user/next-steps", () => {
7980 createdBy: alice.id,
8081 });
8182 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
82 setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner");
83 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
8384 parentServers = await serve(parent);
8485 const spawned = await spawnForge(
8586 parent,
test/ask-settles.test.ts +3 −2
@@ -5,12 +5,13 @@
55 * changes requested goes back to its builder after a day of their silence.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
1112 import { join } from "node:path";
1213 import { createApp, type App } from "../src/app.ts";
13 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
14 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1415 import { getIssueById, type Issue } from "../src/core/issues.ts";
1516 import { submitReview } from "../src/core/reviews.ts";
1617 import { createRepo, type Repo } from "../src/core/repos.ts";
@@ -63,7 +64,7 @@ describe("an ask settles", () => {
6364 if (!made.ok) throw new Error(made.error);
6465 createAgentRow(app.db, { userId: made.value.id, brief: "b" });
6566 addCrew(app.db, repo.id, made.value.id, owner.id);
66 setCrewRole(app.db, repo.id, made.value.id, role);
67 seatAs(app.db, repo.id, made.value.id, role);
6768 return made.value;
6869 };
6970 const work = (kind: "pull" | "issue", assignee: User | null, createdAt = new Date().toISOString()): Issue => {
test/asks-bulk.test.ts +3 −2
@@ -5,6 +5,7 @@
55 * first: an outsider and a plain reader act on nothing.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -17,7 +18,7 @@ import { addComment, openIssue } from "../src/actions/issues.ts";
1718 import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts";
1819 import { openPull } from "../src/actions/pulls.ts";
1920 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
20 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
21 import { getAgentByName } from "../src/core/agents.ts";
2122 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
2223 import { assignIssue } from "../src/actions/issues.ts";
2324 import { mutedRepos, recordInteraction, removeMutes } from "../src/core/interactions.ts";
@@ -169,7 +170,7 @@ describe("acting on many asks at once", () => {
169170 });
170171 addToCrew(app, alice, made.value, "bot");
171172 addToCrew(app, alice, made.value, "planner");
172 setCrewRole(
173 seatAs(
173174 app.db,
174175 made.value.id,
175176 getAgentByName(app.db, "planner")!.user.id,
test/asks-counts.test.ts +3 −2
@@ -9,13 +9,14 @@
99 * branch of `askCandidatesFor` is on the measured path.
1010 */
1111
12 import { seatAs } from "./helpers/seat.ts";
1213 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1314 import type { Database } from "bun:sqlite";
1415 import { mkdtemp, rm } from "node:fs/promises";
1516 import { tmpdir } from "node:os";
1617 import { join } from "node:path";
1718 import { createApp, type App } from "../src/app.ts";
18 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
19 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1920 import { createApp as createAppRecord } from "../src/core/apps.ts";
2021 import { createRepo, type Repo } from "../src/core/repos.ts";
2122 import { submitReview } from "../src/core/reviews.ts";
@@ -94,7 +95,7 @@ describe("home's asks are read grouped, and once", () => {
9495 });
9596 if (!made.ok) throw new Error(`seed repo ${name}: ${made.error}`);
9697 for (const agent of [builder, reviewer]) addCrew(app.db, made.value.id, agent.id, owner.id);
97 setCrewRole(app.db, made.value.id, reviewer.id, "reviewer");
98 seatAs(app.db, made.value.id, reviewer.id, "reviewer");
9899 app.db.query("UPDATE repo SET autonomy = ?2 WHERE id = ?1").run(made.value.id, autonomy);
99100 return made.value;
100101 };
test/asks-waiting.test.ts +3 −2
@@ -4,12 +4,13 @@
44 * pull still young stays the crew's.
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
1011 import { join } from "node:path";
1112 import { createApp, type App } from "../src/app.ts";
12 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
13 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1314 import { addComment } from "../src/core/issues.ts";
1415 import { setHold } from "../src/core/holds.ts";
1516 import { createRepo, type Repo } from "../src/core/repos.ts";
@@ -109,7 +110,7 @@ describe("asks that wait on a person", () => {
109110 if (!made.ok) throw new Error(`seed repo: ${made.error}`);
110111 repo = made.value;
111112 for (const agent of [builder, reviewer]) addCrew(app.db, repo.id, agent.id, owner.id);
112 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
113 seatAs(app.db, repo.id, reviewer.id, "reviewer");
113114 token = createToken(app.db, owner.id, "ro", "ro").token;
114115
115116 handed = pull(builder, "Handed over", 3);
test/authors-pull.test.ts +3 −2
@@ -7,6 +7,7 @@
77 * following its branch.
88 */
99
10 import { seatAs } from "./helpers/seat.ts";
1011 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1112 import { mkdtemp, rm } from "node:fs/promises";
1213 import { tmpdir } from "node:os";
@@ -16,7 +17,7 @@ import { openPull } from "../src/actions/pulls.ts";
1617 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1718 import { createApp, type App } from "../src/app.ts";
1819 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
19 import { setCrewRole } from "../src/core/agents.ts";
20 import {} from "../src/core/agents.ts";
2021 import { getIssueById } from "../src/core/issues.ts";
2122 import { getPull, type Pull } from "../src/core/pulls.ts";
2223 import { getRepoById, type Repo } from "../src/core/repos.ts";
@@ -105,7 +106,7 @@ describe("a person's in-repo pull is a normal pull the crew leaves to its author
105106 }
106107 builder = getUserByName(app.db, "mason")!;
107108 reviewer = getUserByName(app.db, "quill")!;
108 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
109 seatAs(app.db, repo.id, reviewer.id, "reviewer");
109110 app.db
110111 .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_slots = 3 WHERE id = ?1")
111112 .run(repo.id, alice.id);
test/autonomy.test.ts +16 −13
@@ -5,6 +5,7 @@
55 * here starts a real agent; the crew is not running, so an assignment is
66 * the observable effect.
77 */
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -18,7 +19,7 @@ import {
1819 createRepo,
1920 updateRepoSettings,
2021 } from "../src/actions/repos.ts";
21 import { setCrewRole } from "../src/core/agents.ts";
22 import {} from "../src/core/agents.ts";
2223 import {
2324 latestDecision,
2425 recordDecision,
@@ -110,9 +111,9 @@ describe("a repository on its own", () => {
110111 });
111112 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
112113 for (const name of ["scout", "mason", "atlas"]) {
113 const agent = await createAgent(app, alice, { username: name });
114 const agent = await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] });
114115 if (!agent.ok) throw new Error(String(agent.error));
115 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
116 expect(addToCrew(app, alice, repo, name, "builder").ok).toBe(true);
116117 }
117118 scout = getUserByName(
118119 app.db,
@@ -126,7 +127,7 @@ describe("a repository on its own", () => {
126127 app.db,
127128 "atlas",
128129 )!;
129 setCrewRole(app.db, repo.id, atlas.id, "planner");
130 seatAs(app.db, repo.id, atlas.id, "planner");
130131 for (const title of ["Renderer", "Input", "Scene graph"]) {
131132 const opened = openIssue(app, alice, repo, { title });
132133 if (!opened.ok) throw new Error(String(opened.error));
@@ -298,7 +299,7 @@ describe("a repository on its own", () => {
298299 ],
299300 });
300301 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
301 const opened = await openPull(app, atlas, repo, {
302 const opened = await openPull(app, mason, repo, {
302303 title: "Shared",
303304 baseBranch: "main",
304305 headBranch: "feature/shared",
@@ -335,7 +336,7 @@ describe("a repository on its own", () => {
335336 );
336337 expect(conflicts().length).toBe(1);
337338 expect(conflicts()[0]!.summary).toBe("main moved: shared.ts");
338 expect(conflicts()[0]!.actorId).toBe(atlas.id);
339 expect(conflicts()[0]!.actorId).toBe(mason.id);
339340 // Still not landed, still one run waiting: a second look queues nothing.
340341 expect(await landIfGreen(app, fresh(), pull)).toBe(false);
341342 expect(conflicts().length).toBe(1);
@@ -505,6 +506,7 @@ describe("a repository on its own", () => {
505506 model: "",
506507 brief: "",
507508 budgetUsd: 0,
509 roles: ["builder"] as const,
508510 createdBy: null,
509511 createdAt: "",
510512 };
@@ -513,6 +515,7 @@ describe("a repository on its own", () => {
513515 model: "",
514516 brief: "",
515517 budgetUsd: 0,
518 roles: ["builder"] as const,
516519 createdBy: null,
517520 createdAt: "",
518521 };
@@ -670,16 +673,16 @@ describe("the slot count is the rule the loop enforces", () => {
670673 });
671674 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
672675 for (const name of ["brick", "quill", "chart"]) {
673 const agent = await createAgent(app, alice, { username: name });
676 const agent = await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] });
674677 if (!agent.ok) throw new Error(String(agent.error));
675 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
678 expect(addToCrew(app, alice, repo, name, "builder").ok).toBe(true);
676679 }
677680 const byName = getUserByName;
678681 builder = byName(app.db, "brick")!;
679682 reviewer = byName(app.db, "quill")!;
680683 planner = byName(app.db, "chart")!;
681 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
682 setCrewRole(app.db, repo.id, planner.id, "planner");
684 seatAs(app.db, repo.id, reviewer.id, "reviewer");
685 seatAs(app.db, repo.id, planner.id, "planner");
683686 for (const title of ["Warp", "Weft", "Shuttle", "Heddle"]) {
684687 const opened = openIssue(app, alice, repo, { title });
685688 if (!opened.ok) throw new Error(String(opened.error));
@@ -815,13 +818,13 @@ describe("the planner is held while nothing lands or the plate is full", () => {
815818 });
816819 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
817820 for (const name of ["brick", "chart"]) {
818 const agent = await createAgent(app, alice, { username: name });
821 const agent = await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] });
819822 if (!agent.ok) throw new Error(String(agent.error));
820 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
823 expect(addToCrew(app, alice, repo, name, "builder").ok).toBe(true);
821824 }
822825 const byName = getUserByName;
823826 planner = byName(app.db, "chart")!;
824 setCrewRole(app.db, repo.id, planner.id, "planner");
827 seatAs(app.db, repo.id, planner.id, "planner");
825828 const on = await updateRepoSettings(app, alice, repo, {
826829 autonomy: "autonomous",
827830 autonomyDirection: "Weave the loom",
test/box-pass.test.ts +5 −4
@@ -4,6 +4,7 @@
44 * whose boxes are all ticked, and an issue held for boxes gets the same pass
55 * once. A builder is still never sent to redo shipped work.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -14,7 +15,7 @@ import { passOf } from "../src/actions/box_pass.ts";
1415 import { openIssue } from "../src/actions/issues.ts";
1516 import { mergePull, openPull, submitReview } from "../src/actions/pulls.ts";
1617 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import {} from "../src/core/agents.ts";
1819 import { PASS_LEAD, tickBoxes, unticked } from "../src/core/done_on_main.ts";
1920 import { addComment, getIssueById, listComments, updateIssueText, type Issue } from "../src/core/issues.ts";
2021 import { getPull, type Pull } from "../src/core/pulls.ts";
@@ -114,8 +115,8 @@ describe("the reviewer's pass over the boxes", () => {
114115 if (!created.ok) throw new Error(String(created.error));
115116 expect(addToCrew(app, alice, made.value, agent).ok).toBe(true);
116117 }
117 setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
118 setCrewRole(app.db, made.value.id, getUserByName(app.db, "vesper")!.id, "reviewer");
118 seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
119 seatAs(app.db, made.value.id, getUserByName(app.db, "vesper")!.id, "reviewer");
119120 vesper = getUserByName(app.db, "vesper")!;
120121 mason = getUserByName(app.db, "mason")!;
121122 const set = await updateRepoSettings(app, alice, made.value, {
@@ -382,7 +383,7 @@ describe("the review-states door", () => {
382383 const created = await createAgent(app, alice, { username: "vesper" });
383384 if (!created.ok) throw new Error(String(created.error));
384385 addToCrew(app, alice, repo, "vesper");
385 setCrewRole(app.db, repo.id, getUserByName(app.db, "vesper")!.id, "reviewer");
386 seatAs(app.db, repo.id, getUserByName(app.db, "vesper")!.id, "reviewer");
386387 token = tokenFor(app, "vesper", "rw");
387388 const seeded = await commitToBranch(app, alice, repo, {
388389 message: "Start",
test/client-home.test.ts +3 −2
@@ -5,6 +5,7 @@
55 * developer's words.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -17,7 +18,7 @@ import { getAppByRepo } from "../src/core/apps.ts";
1718 import { clientApps } from "../src/web/studio/client_home.ts";
1819 import type { Ctx } from "../src/http/context.ts";
1920 import { clientAppRows, cutTitle, doingLine } from "../src/core/client-apps.ts";
20 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
21 import { addCrew, createAgentRow } from "../src/core/agents.ts";
2122 import { moveArchived } from "../src/core/repos.ts";
2223 import { createSession } from "../src/core/sessions.ts";
2324 import { linkEveryday } from "../src/core/everyday.ts";
@@ -103,7 +104,7 @@ describe("a client's first screen", () => {
103104 createdBy: alice.id,
104105 });
105106 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
106 setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner");
107 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
107108 parentServers = await serve(parent);
108109 const spawned = await spawnForge(
109110 parent,
test/crew-direction-autonomy.test.ts +4 −3
@@ -6,6 +6,7 @@
66 * here starts a real agent: an assignment, a queued run or a merge is the
77 * observable effect, and each must not happen.
88 */
9 import { seatAs } from "./helpers/seat.ts";
910 import {
1011 afterAll,
1112 beforeAll,
@@ -35,7 +36,7 @@ import {
3536 updateRepoSettings,
3637 } from "../src/actions/repos.ts";
3738 import { tell } from "../src/actions/tell.ts";
38 import { setCrewRole } from "../src/core/agents.ts";
39 import {} from "../src/core/agents.ts";
3940 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
4041 import {
4142 getIssue,
@@ -121,8 +122,8 @@ async function autonomous(): Promise<Repo> {
121122 );
122123 for (const name of ["mason", "atlas", "judge"])
123124 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
124 setCrewRole(app.db, repo.id, atlas.id, "planner");
125 setCrewRole(app.db, repo.id, judge.id, "reviewer");
125 seatAs(app.db, repo.id, atlas.id, "planner");
126 seatAs(app.db, repo.id, judge.id, "reviewer");
126127 ok(
127128 await updateRepoSettings(app, alice, repo, {
128129 autonomy: "autonomous",
test/crew-direction-cap.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * bounds planning is the breaker, the replan floor, the hold, the dollar
55 * ceiling — and the direction's own, generous day's share.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { assignIssue, openIssue, setIssueState } from "../src/actions/issues.ts";
1415 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import { getIssueById, type Issue } from "../src/core/issues.ts";
1718 import { getRepoById, type Repo } from "../src/core/repos.ts";
1819 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
@@ -92,7 +93,7 @@ describe("the direction is paced, not capped", () => {
9293 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
9394 }
9495 atlas = getUserByName(app.db, "atlas")!;
95 setCrewRole(app.db, repo.id, atlas.id, "planner");
96 seatAs(app.db, repo.id, atlas.id, "planner");
9697 const set = await updateRepoSettings(app, alice, repo, {
9798 autonomy: "autonomous",
9899 autonomyDirection: "Keep the engine small.",
test/crew-direction.test.ts +3 −2
@@ -5,6 +5,7 @@
55 * steers a run. A reader's words stay on the thread and do nothing more,
66 * and a run's brief quotes them as a reader's, never as an instruction.
77 */
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
1011 import { mkdtemp, rm } from "node:fs/promises";
@@ -25,7 +26,7 @@ import {
2526 updateRepoSettings,
2627 } from "../src/actions/repos.ts";
2728 import { steerRepoRun } from "../src/actions/runs.ts";
28 import { setCrewRole, type Agent } from "../src/core/agents.ts";
29 import { type Agent } from "../src/core/agents.ts";
2930 import { listEvents } from "../src/core/events.ts";
3031 import { getIssue, listComments, type Issue } from "../src/core/issues.ts";
3132 import { getPull } from "../src/core/pulls.ts";
@@ -136,7 +137,7 @@ fi
136137 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
137138 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
138139 expect(addToCrew(app, alice, repo, "judge").ok).toBe(true);
139 setCrewRole(app.db, repo.id, judge.user.id, "reviewer");
140 seatAs(app.db, repo.id, judge.user.id, "reviewer");
140141 }, 30_000);
141142
142143 afterAll(async () => {
test/crew-guard.test.ts +3 −2
@@ -9,6 +9,7 @@
99 * since its last pass (#1097). Each skip and cap is counted for the
1010 * token-return view.
1111 */
12 import { seatAs } from "./helpers/seat.ts";
1213 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1314 import { mkdtemp, rm } from "node:fs/promises";
1415 import { tmpdir } from "node:os";
@@ -26,7 +27,7 @@ import {
2627 updateRepoSettings,
2728 } from "../src/actions/repos.ts";
2829 import { readUsage } from "../src/actions/usage.ts";
29 import { getAgent, setCrewRole, type Agent } from "../src/core/agents.ts";
30 import { getAgent, type Agent } from "../src/core/agents.ts";
3031 import { listDecisions } from "../src/core/decisions.ts";
3132 import {
3233 getIssueById,
@@ -417,7 +418,7 @@ describe("the planner is not given the direction again with nothing new (#1097)"
417418 );
418419 for (const name of ["bot", "atlas"])
419420 expect(addToCrew(app, alice, round, name).ok).toBe(true);
420 setCrewRole(app.db, round.id, atlas.user.id, "planner");
421 seatAs(app.db, round.id, atlas.user.id, "planner");
421422 ok(
422423 await updateRepoSettings(app, alice, round, {
423424 autonomy: "autonomous",
test/crew-held.test.ts +4 −3
@@ -6,6 +6,7 @@
66 * terminal's Flow dialog show the same counts, private repositories to the
77 * owner only. Each test makes its own repository and crew.
88 */
9 import { seatAs } from "./helpers/seat.ts";
910 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1011 import { mkdtemp, rm } from "node:fs/promises";
1112 import { tmpdir } from "node:os";
@@ -15,7 +16,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1516 import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts";
1617 import { openPull } from "../src/actions/pulls.ts";
1718 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
18 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
19 import { getAgentByName } from "../src/core/agents.ts";
1920 import { getIssueById, listIssues, type Issue } from "../src/core/issues.ts";
2021 import { getRepoById, type Repo } from "../src/core/repos.ts";
2122 import { DONE_NOTE } from "../src/core/done_on_main.ts";
@@ -49,7 +50,7 @@ async function crewed(
4950 }
5051 expect(addToCrew(app, alice, made.value, agent).ok).toBe(true);
5152 }
52 setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
53 seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
5354 const set = await updateRepoSettings(app, alice, made.value, {
5455 autonomy: "autonomous",
5556 autonomyDirection: "Keep the engine small.",
@@ -272,7 +273,7 @@ describe("the tick says why each ready issue cannot start", () => {
272273 if (!made.ok) throw new Error(String(made.error));
273274 expect(addToCrew(app, alice, fresh(), "sentinel").ok).toBe(true);
274275 const sentinel = getUserByName(app.db, "sentinel")!;
275 setCrewRole(app.db, repo.id, sentinel.id, "reviewer");
276 seatAs(app.db, repo.id, sentinel.id, "reviewer");
276277
277278 // A lane past its budget: the sentinel files it at once, unassigned.
278279 const now = Date.now();
test/crew-hot-files.test.ts +3 −2
@@ -5,6 +5,7 @@
55 * the feed, and the feed through the MCP. The hold lifts when the pile
66 * drains under five.
77 */
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -14,7 +15,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1415 import { openIssue, setIssueState } from "../src/actions/issues.ts";
1516 import { openPull } from "../src/actions/pulls.ts";
1617 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import {} from "../src/core/agents.ts";
1819 import { getIssueById, type Issue } from "../src/core/issues.ts";
1920 import type { Pull } from "../src/core/pulls.ts";
2021 import { getRepoById, type Repo } from "../src/core/repos.ts";
@@ -122,7 +123,7 @@ describe("the picker holds a build on a hot file", () => {
122123 if (!created.ok) throw new Error(String(created.error));
123124 expect(addToCrew(app, alice, made.value, agent).ok).toBe(true);
124125 }
125 setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
126 seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
126127 const set = await updateRepoSettings(app, alice, made.value, {
127128 autonomy: "autonomous",
128129 autonomyDirection: "Keep the engine small.",
test/crew-loop.test.ts +10 −7
@@ -4,6 +4,7 @@
44 * leaves no comment; a finished planning run lets go of the direction; three
55 * failed runs in an hour park the issue until a person speaks.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -18,7 +19,7 @@ import { HANDOVER_NOTE, handedOverPulls, handoverOf } from "../src/crew/guard.ts
1819 import { addReviewComment } from "../src/core/reviews.ts";
1920 import { sendToReview, waitsForPerson } from "../src/crew/review.ts";
2021 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
21 import { setCrewRole } from "../src/core/agents.ts";
22 import {} from "../src/core/agents.ts";
2223 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
2324 import { getRepoById, type Repo } from "../src/core/repos.ts";
2425 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
@@ -74,7 +75,7 @@ describe("an agent's own comment never starts its next run", () => {
7475 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
7576 }
7677 atlas = getUserByName(app.db, "atlas")!;
77 setCrewRole(app.db, repo.id, atlas.id, "planner");
78 seatAs(app.db, repo.id, atlas.id, "planner");
7879 const set = await updateRepoSettings(app, alice, repo, {
7980 autonomy: "autonomous",
8081 autonomyDirection: "Keep the engine small.",
@@ -138,13 +139,15 @@ describe("an agent's own comment never starts its next run", () => {
138139 if (!issued.ok) throw new Error(String(issued.error));
139140 const issue = issued.value;
140141 const { assignIssue } = await import("../src/actions/issues.ts");
141 assignIssue(app, alice, fresh(), issue, atlas);
142 // A builder: an issue assigned to the planner is explained, not run (#8907).
143 const builder = getUserByName(app.db, "mason")!;
144 assignIssue(app, alice, fresh(), issue, builder);
142145 await new Promise((r) => setTimeout(r, 50));
143146 // The person's assignment started one run; the agent's words add none.
144147 expect(agentRuns(issue).length).toBe(1);
145148 const before = agentRuns(issue).length;
146149 for (let i = 0; i < 5; i++) {
147 addComment(app, atlas, fresh(), issue, `Declined: not feedback ${i}`);
150 addComment(app, builder, fresh(), issue, `Declined: not feedback ${i}`);
148151 await new Promise((r) => setTimeout(r, 20));
149152 }
150153 expect(agentRuns(issue).length).toBe(before);
@@ -155,13 +158,13 @@ describe("an agent's own comment never starts its next run", () => {
155158 const issued = openIssue(app, alice, fresh(), { title: "Stuck", body: "" });
156159 if (!issued.ok) throw new Error(String(issued.error));
157160 const issue = issued.value;
158 const agent = getAgent(app.db, atlas.id)!;
161 const agent = getAgent(app.db, getUserByName(app.db, "mason")!.id)!;
159162 for (let i = 0; i < 3; i++) {
160163 const r = createRun(app.db, {
161164 repoId: repo.id,
162165 kind: "agent",
163166 issueId: issue.id,
164 actorId: atlas.id,
167 actorId: getUserByName(app.db, "mason")!.id,
165168 reason: "feedback",
166169 });
167170 claimRun(app.db, r.id);
@@ -245,7 +248,7 @@ describe("a crew reviewer's changes-requested wakes the builder", () => {
245248 }
246249 mason = getUserByName(app.db, "mason")!;
247250 sentinel = getUserByName(app.db, "sentinel")!;
248 setCrewRole(app.db, repo.id, sentinel.id, "reviewer");
251 seatAs(app.db, repo.id, sentinel.id, "reviewer");
249252 crew = await startCrew(app, {
250253 forgeUrl: "http://127.0.0.1:1",
251254 localUrl: "http://127.0.0.1:1",
test/crew-parks-planner.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * migration 43 rewords old outage parks so they no longer count. Split from
55 * `test/crew-parks.test.ts`, whose `engine` it shares.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { Database } from "bun:sqlite";
910 import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -11,7 +12,7 @@ import { join } from "node:path";
1112 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1213 import { openPull } from "../src/actions/pulls.ts";
1314 import { commitToBranch } from "../src/actions/repos.ts";
14 import { setCrewRole } from "../src/core/agents.ts";
15 import {} from "../src/core/agents.ts";
1516 import { listRuns } from "../src/core/runs.ts";
1617 import { migrations } from "../src/core/schema.ts";
1718 import { submitReview } from "../src/core/reviews.ts";
@@ -132,7 +133,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Nothing to
132133 if (!agent.ok) throw new Error(String(agent.error));
133134 expect(addToCrew(e.app, e.alice, e.fresh(), "sentinel").ok).toBe(true);
134135 const sentinel = getUserByName(e.app.db, "sentinel")!;
135 setCrewRole(e.app.db, e.repo.id, sentinel.id, "reviewer");
136 seatAs(e.app.db, e.repo.id, sentinel.id, "reviewer");
136137 const branch = await commitToBranch(e.app, e.alice, e.fresh(), {
137138 branch: "feature/spent",
138139 base: "main",
test/crew-reserve.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * brief names the target; the tick line says `reserve=startable/target`;
55 * and only a pass that did not end well waits out the 20-minute floor.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { addComment, openIssue, setIssueState } from "../src/actions/issues.ts";
1415 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
15 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
16 import { getAgentByName } from "../src/core/agents.ts";
1617 import { getIssueById, type Issue } from "../src/core/issues.ts";
1718 import { getRepoById, type Repo } from "../src/core/repos.ts";
1819 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
@@ -105,7 +106,7 @@ describe("a planner that keeps a standing reserve", () => {
105106 }
106107 mason = getUserByName(app.db, "mason")!;
107108 atlas = getUserByName(app.db, "atlas")!;
108 setCrewRole(app.db, repo.id, atlas.id, "planner");
109 seatAs(app.db, repo.id, atlas.id, "planner");
109110 // Seven slots, every one of them busy: seven issues with a run in flight.
110111 for (let n = 0; n < 7; n++) {
111112 const opened = openIssue(app, alice, repo, { title: `Busy ${n}` });
test/crew-review-budget.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * `crew.test.ts` so each part runs apart (#1798).
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import type { App } from "../src/app.ts";
910 import type { Repo } from "../src/core/repos.ts";
@@ -12,7 +13,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1213 import { openPull } from "../src/actions/pulls.ts";
1314 import { commitToBranch } from "../src/actions/repos.ts";
1415 import { queueAgentRun } from "../src/crew/dispatch.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import { getIssue } from "../src/core/issues.ts";
1718 import { listRuns, type Run } from "../src/core/runs.ts";
1819 import {
@@ -45,7 +46,7 @@ describe("the crew, with a stand-in Claude Code: review budget", () => {
4546 const made = await createAgent(app, alice, { username: "slowrev" });
4647 if (!made.ok) throw new Error(String(made.error));
4748 expect(addToCrew(app, alice, repo, "slowrev").ok).toBe(true);
48 setCrewRole(app.db, repo.id, made.value.user.id, "reviewer");
49 seatAs(app.db, repo.id, made.value.user.id, "reviewer");
4950 const branch = await commitToBranch(app, alice, repo, {
5051 branch: "slow-fence",
5152 base: "main",
test/crew-review.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * `crew.test.ts` so each part runs apart (#1798).
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { readFileSync } from "node:fs";
910 import type { App } from "../src/app.ts";
@@ -13,7 +14,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openPull } from "../src/actions/pulls.ts";
1415 import { commitToBranch } from "../src/actions/repos.ts";
1516 import { queueAgentRun } from "../src/crew/dispatch.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import {} from "../src/core/agents.ts";
1718 import { getIssue, listComments } from "../src/core/issues.ts";
1819 import { listReviews } from "../src/core/reviews.ts";
1920 import type { Run } from "../src/core/runs.ts";
@@ -47,7 +48,7 @@ describe("the crew, with a stand-in Claude Code: review", () => {
4748 const made = await createAgent(app, alice, { username: "rev" });
4849 if (!made.ok) throw new Error(String(made.error));
4950 expect(addToCrew(app, alice, repo, "rev").ok).toBe(true);
50 setCrewRole(app.db, repo.id, made.value.user.id, "reviewer");
51 seatAs(app.db, repo.id, made.value.user.id, "reviewer");
5152 const branch = await commitToBranch(app, alice, repo, {
5253 branch: "fence",
5354 base: "main",
test/crew-roles.test.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * An agent's roles are its own config (#8907): a repository narrows them and
3 * never widens them, a dispatch never hands over a job outside them, and
4 * /migrate offers builders only.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp } 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, askedRoles, createAgent, updateAgent } from "../src/actions/crew.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { crewRole, getAgentByName, type Agent } from "../src/core/agents.ts";
14 import { createRepo, type Repo } from "../src/core/repos.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { outsideSeat } from "../src/crew/dispatch.ts";
17 import { migrationBuilders, prefill } from "../src/web/pages/migrate-prefill.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 let app: App;
23 let admin: User;
24 let repo: Repo;
25
26 const agent = (name: string): Agent => getAgentByName(app.db, name)!;
27
28 beforeAll(async () => {
29 app = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-roles-")), port: 0 });
30 const made = await createUser(app.db, { origin: "seed", username: "alice", email: "[email protected]", password: "a-long-crew-passphrase", adminIfFirst: true, trusted: true });
31 if (!made.ok) throw new Error("user");
32 admin = made.value;
33 const r = await createRepo(app.db, app.reposRoot, { ownerId: admin.id, name: "yard", visibility: "public" });
34 if (!r.ok) throw new Error(String(r.error));
35 repo = r.value;
36 const make = async (username: string, roles?: string[]) => {
37 const a = await createAgent(app, admin, { username, ...(roles ? { roles } : {}) });
38 if (!a.ok) throw new Error(`${username}: ${a.error}`);
39 };
40 await make("atlas", ["planner"]);
41 await make("both", ["planner", "builder"]);
42 await make("mason", ["builder"]);
43 await make("rev", ["reviewer"]);
44 await make("plain");
45 });
46 afterAll(() => app.db.close());
47
48 describe("roles are the agent's config", () => {
49 test("an agent says its roles; none given reads as builder, bad ones are refused", () => {
50 expect(agent("atlas").roles).toEqual(["planner"]);
51 expect(agent("both").roles).toEqual(["planner", "builder"]);
52 expect(agent("plain").roles).toEqual(["builder"]);
53 const bad = updateAgent(app, admin, "mason", { roles: ["boss"] });
54 expect(bad.ok ? "" : bad.error).toBe("invalid_roles");
55 const empty = updateAgent(app, admin, "mason", { roles: [] });
56 expect(empty.ok ? "" : empty.error).toBe("invalid_roles");
57 expect(agent("mason").roles).toEqual(["builder"]);
58 });
59
60 test("a repository can narrow a role, never widen it", () => {
61 const widened = addToCrew(app, admin, repo, "atlas", "builder");
62 expect(widened.ok ? "" : widened.error).toBe("role_not_declared");
63 expect(crewRole(app.db, repo.id, agent("atlas").user.id)).toBeUndefined();
64 expect(addToCrew(app, admin, repo, "both", "builder").ok).toBe(true);
65 expect(crewRole(app.db, repo.id, agent("both").user.id)).toBe("builder");
66 });
67
68 test("no role named: the first the agent declares, never builder by default", () => {
69 expect(addToCrew(app, admin, repo, "rev").ok).toBe(true);
70 expect(crewRole(app.db, repo.id, agent("rev").user.id)).toBe("reviewer");
71 });
72
73 test("taking a role an agent still holds on a repository is refused, not moved silently", () => {
74 const held = updateAgent(app, admin, "both", { roles: ["planner"] });
75 expect(held.ok ? "" : held.error).toBe("role_held");
76 expect(agent("both").roles).toEqual(["planner", "builder"]);
77 expect(crewRole(app.db, repo.id, agent("both").user.id)).toBe("builder");
78 expect(updateAgent(app, admin, "both", { roles: ["builder", "reviewer"] }).ok).toBe(true);
79 updateAgent(app, admin, "both", { roles: ["planner", "builder"] });
80 });
81
82 test("roles that are not a list of strings are invalid_roles, not ignored", () => {
83 for (const bad of ["planner", 3, null, { a: 1 }, [1], [["planner"]]]) {
84 const r = updateAgent(app, admin, "mason", { ...askedRoles(bad) });
85 expect(r.ok ? "" : r.error).toBe("invalid_roles");
86 }
87 expect(askedRoles(undefined)).toEqual({});
88 expect(agent("mason").roles).toEqual(["builder"]);
89 });
90 });
91
92 describe("a dispatch never hands over a job outside the seat", () => {
93 let issue: Awaited<ReturnType<typeof openIssue>> & { ok: true };
94 beforeAll(async () => {
95 const i = await openIssue(app, admin, repo, { title: "work" });
96 if (!i.ok) throw new Error(i.error);
97 issue = i as typeof issue;
98 addToCrew(app, admin, repo, "mason", "builder");
99 });
100
101 test("planner seat: a build is refused", () => {
102 app.db.query("UPDATE repo_crew SET role = 'planner' WHERE user_id = ?1").run(agent("both").user.id);
103 expect(outsideSeat(app, repo, issue.value, agent("both"), "assigned")).toBe(true);
104 });
105 test("builder seat: a build is allowed, a review is not", () => {
106 expect(outsideSeat(app, repo, issue.value, agent("mason"), "assigned")).toBe(false);
107 expect(outsideSeat(app, repo, issue.value, agent("mason"), "review")).toBe(true);
108 });
109 test("reviewer seat: a review is allowed, a build is not", () => {
110 expect(outsideSeat(app, repo, issue.value, agent("rev"), "review")).toBe(false);
111 expect(outsideSeat(app, repo, issue.value, agent("rev"), "assigned")).toBe(true);
112 });
113 });
114
115 describe("every kind of run keeps to the seat", () => {
116 const reasons = ["assigned", "feedback", "check_failed", "conflict", "resolve", "converge"] as const;
117 test("a non-builder is outside every building reason; a builder is outside a review", async () => {
118 const i = await openIssue(app, admin, repo, { title: "more work" });
119 if (!i.ok) throw new Error(i.error);
120 app.db.query("UPDATE repo_crew SET role = 'planner' WHERE user_id = ?1").run(agent("both").user.id);
121 for (const reason of reasons) {
122 expect(outsideSeat(app, repo, i.value, agent("both"), reason)).toBe(true);
123 expect(outsideSeat(app, repo, i.value, agent("rev"), reason)).toBe(true);
124 expect(outsideSeat(app, repo, i.value, agent("mason"), reason)).toBe(false);
125 }
126 expect(outsideSeat(app, repo, i.value, agent("mason"), "review")).toBe(true);
127 });
128 });
129
130 describe("/migrate offers builders only", () => {
131 test("a planner that sorts first is never the default; crew= naming it is refused in a sentence", () => {
132 const all = ["atlas", "both", "mason", "rev", "plain"].map(agent);
133 const builders = migrationBuilders(all).map((a) => a.user.username);
134 expect(builders).not.toContain("atlas");
135 expect(builders).not.toContain("rev");
136 expect(builders[0]).toBe("mason");
137 const linked = prefill({ crew: "atlas" }, [], builders, ["atlas", "rev"]);
138 expect(linked.agent).toBeUndefined();
139 expect(linked.refused[0]).toContain("atlas is not a builder");
140 });
141 });
test/crew-seats.test.ts +13 −11
@@ -4,13 +4,14 @@
44 * `addToCrew` names the seat only for an agent who joins now; the terminal's
55 * About card lists the seats and `p` fills one.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
1011 import { join } from "node:path";
1112 import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { crewRole, listCrewRoles, removeCrew, setCrewRole } from "../src/core/agents.ts";
14 import { crewRole, listCrewRoles, removeCrew } from "../src/core/agents.ts";
1415 import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts";
1516 import { createUser, getUserByName, type User } from "../src/core/users.ts";
1617 import { seatsOf } from "../src/crew/seats.ts";
@@ -58,7 +59,7 @@ describe("the team's seats", () => {
5859 const made = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "yard", visibility: "public" });
5960 if (!made.ok) throw new Error(String(made.error));
6061 repo = made.value;
61 for (const name of ["aplan", "bbuild"]) if (!(await createAgent(app, alice, { username: name })).ok) throw new Error("agent");
62 for (const name of ["aplan", "bbuild"]) if (!(await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] })).ok) throw new Error("agent");
6263 cookies = { alice: await signIn("alice"), bob: await signIn("bob") };
6364 }, 30_000);
6465
@@ -83,30 +84,31 @@ describe("the team's seats", () => {
8384 expect(seatsOf(app.db, fresh(), true).auto).toBe(false);
8485 });
8586
86 test("the card puts the first free agent on the seat it names, for an admin only", async () => {
87 test("each list picks from the agents that declare its role, for an admin only", async () => {
8788 mode("autonomous");
8889 const html = await page("alice");
89 for (const role of ["planner", "builder", "reviewer"])
90 for (const role of ["planner", "builder", "reviewer"]) {
9091 expect(html).toContain(`name="role" value="${role}"`);
91 expect(html).toContain('name="agent" value="aplan"');
92 expect(html).toContain("Put aplan on reviewer");
92 expect(html).toContain(`id="f-add-${role}"`);
93 }
94 expect(html).toContain('<option value="aplan">');
95 expect(html).toContain("Planners");
9396 expect(await page("bob")).not.toContain('name="role"');
9497 });
9598
96 test("a filled seat names who fills it and has no button; the others still do", async () => {
99 test("a filled seat names who fills it; the others still have a picker", async () => {
97100 expect(addToCrew(app, alice, fresh(), "aplan", "planner").ok).toBe(true);
98101 expect(states()).toEqual({ planner: "filled", builder: "warn", reviewer: "warn" });
99102 expect(seatsOf(app.db, fresh()).seats[0]!.by).toEqual(["aplan"]);
100103 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).toContain('data-member="aplan"');
105 expect(html).toContain('id="f-add-builder"');
104106 expect(html).not.toContain("No planner:");
105107 expect(html).toContain("No builder: nothing gets built.");
106108 });
107109
108110 test("an agent already on the crew keeps its role", () => {
109 setCrewRole(app.db, repo.id, idOf("aplan"), "reviewer");
111 seatAs(app.db, repo.id, idOf("aplan"), "reviewer");
110112 const again = addToCrew(app, alice, fresh(), "aplan", "builder");
111113 expect(again.ok).toBe(true);
112114 expect(crewRole(app.db, repo.id, idOf("aplan"))).toBe("reviewer");
test/crew-settings-doors.test.ts +2 −2
@@ -97,7 +97,7 @@ describe("settings › crew doors", () => {
9797 });
9898 if (!made.ok) throw new Error(String(made.error));
9999 repo = made.value;
100 const bot = await createAgent(app, a.value, { username: "yardbot" });
100 const bot = await createAgent(app, a.value, { username: "yardbot", roles: ["planner", "builder", "reviewer"] });
101101 if (!bot.ok) throw new Error("agent");
102102 botId = bot.value.user.id;
103103 const sub = addSubscription(app.db, dataDir, {
@@ -106,7 +106,7 @@ describe("settings › crew doors", () => {
106106 });
107107 if (!sub.ok) throw new Error("subscription");
108108 subId = sub.value.id;
109 const other = await createAgent(app, a.value, { username: "otherbot" });
109 const other = await createAgent(app, a.value, { username: "otherbot", roles: ["planner", "builder", "reviewer"] });
110110 if (!other.ok) throw new Error("agent");
111111 otherId = other.value.user.id;
112112 const c = await createUser(app.db, {
test/crew-stalls.test.ts +3 −2
@@ -7,6 +7,7 @@
77 * The repositories are assisted, where nothing parks a stall when its run
88 * ends, so the tick is what frees it.
99 */
10 import { seatAs } from "./helpers/seat.ts";
1011 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
1112 import { mkdtemp, rm } from "node:fs/promises";
1213 import { tmpdir } from "node:os";
@@ -16,7 +17,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1617 import { openIssue, setIssueState } from "../src/actions/issues.ts";
1718 import { mergePull, openPull } from "../src/actions/pulls.ts";
1819 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
19 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
20 import { getAgentByName } from "../src/core/agents.ts";
2021 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
2122 import { getRepoById, listAutonomousRepos, type Repo } from "../src/core/repos.ts";
2223 import {
@@ -55,7 +56,7 @@ async function crewed(builders: readonly string[]): Promise<Repo> {
5556 }
5657 expect(addToCrew(app, alice, created.value, name).ok).toBe(true);
5758 }
58 setCrewRole(app.db, created.value.id, getUserByName(app.db, "atlas")!.id, "planner");
59 seatAs(app.db, created.value.id, getUserByName(app.db, "atlas")!.id, "planner");
5960 const set = await updateRepoSettings(app, alice, created.value, {
6061 autonomy: "assisted",
6162 autonomyDirection: "Keep the engine small.",
test/decide.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * which case the rule's answer stands and the caller never knows the
55 * difference except by `by`.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openIssue } from "../src/actions/issues.ts";
1415 import { createRepo } from "../src/actions/repos.ts";
15 import { listCrewRoles, setCrewRole } from "../src/core/agents.ts";
16 import { listCrewRoles } from "../src/core/agents.ts";
1617 import { getIssueById } from "../src/core/issues.ts";
1718 import type { Repo } from "../src/core/repos.ts";
1819 import { createUser, getUserByName, type User } from "../src/core/users.ts";
@@ -252,7 +253,7 @@ describe("routing an issue", () => {
252253 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
253254 }
254255 scout = getUserByName(app.db, "scout")!;
255 setCrewRole(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner");
256 seatAs(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner");
256257 });
257258 afterAll(async () => {
258259 app.close();
test/finishing-attempts.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * and ended without a push. A run the forge cut short — a network error, a
55 * checkout that failed — is no attempt, and a new base is a new chance.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openPull } from "../src/actions/pulls.ts";
1415 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import type { Pull } from "../src/core/pulls.ts";
1718 import { getRepoById, type Repo } from "../src/core/repos.ts";
1819 import { submitReview } from "../src/core/reviews.ts";
@@ -73,7 +74,7 @@ beforeAll(async () => {
7374 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
7475 }
7576 const quill = getUserByName(app.db, "quill")!;
76 setCrewRole(app.db, repo.id, quill.id, "reviewer");
77 seatAs(app.db, repo.id, quill.id, "reviewer");
7778 const branch = await commitToBranch(app, alice, repo, {
7879 branch: "feature/1",
7980 base: "main",
test/finishing-pages.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * up to date. Its entry and every pull it drops are logged once per reason,
55 * and with no candidate the tick line says `0 candidates of N open`.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openPull } from "../src/actions/pulls.ts";
1415 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import { createRun } from "../src/core/runs.ts";
1718 import type { Pull } from "../src/core/pulls.ts";
1819 import { getRepoById, type Repo } from "../src/core/repos.ts";
@@ -86,7 +87,7 @@ beforeAll(async () => {
8687 if (!agent.ok) throw new Error(String(agent.error));
8788 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
8889 }
89 setCrewRole(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer");
90 seatAs(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer");
9091 // The oldest pull edits the file main is about to change; fifty newer
9192 // ones each add their own file and merge clean.
9293 oldest = await pullOn(0, "export const zero = 'branch';\n");
test/finishing-room.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * queues no more bring-up-to-date runs — each a push, each a check — than
55 * the lane has slots for, however many builder slots are free.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openPull } from "../src/actions/pulls.ts";
1415 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import { setCapacity } from "../src/core/capacity.ts";
1718 import type { Pull } from "../src/core/pulls.ts";
1819 import type { Repo } from "../src/core/repos.ts";
@@ -69,7 +70,7 @@ beforeAll(async () => {
6970 }
7071 const mason = getUserByName(app.db, "mason")!;
7172 const quill = getUserByName(app.db, "quill")!;
72 setCrewRole(app.db, repo.id, quill.id, "reviewer");
73 seatAs(app.db, repo.id, quill.id, "reviewer");
7374
7475 // Three approved pulls that all touch the file main is about to change.
7576 for (const n of [1, 2, 3]) {
test/finishing-skips.test.ts +4 −3
@@ -4,6 +4,7 @@
44 * only when the reason changes, and the tick line's
55 * `finishing="queued Q, in flight F, skipped S: <why>×k"`.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openPull } from "../src/actions/pulls.ts";
1415 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import { setCapacity } from "../src/core/capacity.ts";
1718 import { setHold } from "../src/core/holds.ts";
1819 import type { Pull } from "../src/core/pulls.ts";
@@ -74,7 +75,7 @@ beforeAll(async () => {
7475 }
7576 const mason = getUserByName(app.db, "mason")!;
7677 const quill = getUserByName(app.db, "quill")!;
77 setCrewRole(app.db, repo.id, quill.id, "reviewer");
78 seatAs(app.db, repo.id, quill.id, "reviewer");
7879 for (const n of [1, 2, 3]) {
7980 const branch = await commitToBranch(app, alice, repo, {
8081 branch: `feature/${n}`,
@@ -147,7 +148,7 @@ test("a changed reason is said again, for that pull only", async () => {
147148 expect(full.skips[0]).toContain('finishing: skip pull=3 why="waits for room in the check lane"');
148149 expect(listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.issueId === pulls[2]!.id)).toHaveLength(0);
149150
150 setCrewRole(app.db, repo.id, getUserByName(app.db, "mason")!.id, "reviewer");
151 seatAs(app.db, repo.id, getUserByName(app.db, "mason")!.id, "reviewer");
151152 const none = await pass(1);
152153 expect(none.skips).toHaveLength(1);
153154 expect(none.skips[0]).toContain(`finishing: skip pull=3 why=${JSON.stringify(NO_BUILDER_ON_CREW)}`);
test/finishing.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * updates that end without a push hand it to a person, on their review
55 * list, instead of closing it or trying again.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -13,7 +14,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openIssue } from "../src/actions/issues.ts";
1415 import { openPull } from "../src/actions/pulls.ts";
1516 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import {} from "../src/core/agents.ts";
1718 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
1819 import type { Pull } from "../src/core/pulls.ts";
1920 import type { Repo } from "../src/core/repos.ts";
@@ -85,7 +86,7 @@ beforeAll(async () => {
8586 }
8687 mason = getUserByName(app.db, "mason")!;
8788 const quill = getUserByName(app.db, "quill")!;
88 setCrewRole(app.db, repo.id, quill.id, "reviewer");
89 seatAs(app.db, repo.id, quill.id, "reviewer");
8990
9091 const branch = await commitToBranch(app, alice, repo, {
9192 branch: "feature/shared",
test/first-login.test.ts +3 −2
@@ -7,6 +7,7 @@
77 * same three steps over the MCP.
88 */
99
10 import { seatAs } from "./helpers/seat.ts";
1011 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1112 import { orgAccessByOrg } from "../src/core/orgs.ts";
1213 import { getUserSetting } from "../src/core/settings.ts";
@@ -16,7 +17,7 @@ import { join } from "node:path";
1617 import { createRepo } from "../src/actions/repos.ts";
1718 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1819 import { createApp, type App } from "../src/app.ts";
19 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
20 import { addCrew, createAgentRow } from "../src/core/agents.ts";
2021 import { createSession } from "../src/core/sessions.ts";
2122 import { createToken } from "../src/core/tokens.ts";
2223 import { createUser, getUserByName } from "../src/core/users.ts";
@@ -108,7 +109,7 @@ describe("the first login to a spawned forge", () => {
108109 createdBy: alice.id,
109110 });
110111 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
111 setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner");
112 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
112113 parentServers = await serve(parent);
113114 const spawned = await spawnForge(
114115 parent,
test/guests.test.ts +5 −2
@@ -4,6 +4,7 @@
44 * can; and every older refusal keeps its own reason.
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { setArchivedIn } from "./helpers/archive.ts";
910 import { mkdtemp, rm } from "node:fs/promises";
@@ -14,7 +15,7 @@ import { changeCapacity } from "../src/actions/capacity.ts";
1415 import { proposePlan } from "../src/actions/plan.ts";
1516 import { redirectProposal } from "../src/actions/proposals.ts";
1617 import { ARCHIVED_WHY } from "../src/core/repos.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import {} from "../src/core/agents.ts";
1819 import { getProposal } from "../src/core/proposals.ts";
1920 import { listComments } from "../src/core/issues.ts";
2021 import { ensureDirectionIssue } from "../src/crew/autonomy.ts";
@@ -266,7 +267,7 @@ describe("a guest cannot own, copy or run", () => {
266267 expect(assigned).toBe(0);
267268 });
268269 test("redirecting a wave: a guest's own pending proposal is refused before anything is written", async () => {
269 setCrewRole(app.db, lab.id, helper.id, "planner");
270 seatAs(app.db, lab.id, helper.id, "planner");
270271 const direction = ensureDirectionIssue(app, alice, lab)!;
271272 const proposed = proposePlan(app, alice, lab, [{ key: "a", title: "Weed", body: "", blockedBy: [] }], {
272273 title: "Spring",
@@ -313,6 +314,8 @@ describe("a guest cannot own, copy or run", () => {
313314 });
314315
315316 test("the wake-up sweep starts no run on an issue a guest assigned, and a hundred of them do not hold back a member's", async () => {
317 // Back to building: a dispatch gives a planner no build (#8907).
318 seatAs(app.db, lab.id, helper.id, "builder");
316319 // As an assignment made before the guard (or by a member since demoted):
317320 // the agent set, the event kept.
318321 const assignedBy = (who: User, title: string) => {
test/handover-give-back.test.ts +4 −3
@@ -4,6 +4,7 @@
44 * and the card leaves; a foreign Origin is refused; a reader gets a 404.
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { chmodSync, writeFileSync } from "node:fs";
910 import { addComment } from "../src/actions/issues.ts";
@@ -12,7 +13,7 @@ import { mkdtemp, rm } from "node:fs/promises";
1213 import { tmpdir } from "node:os";
1314 import { join } from "node:path";
1415 import { createApp, type App } from "../src/app.ts";
15 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
16 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1617 import { getIssueById, listComments, setAssignee } from "../src/core/issues.ts";
1718 import { handoverOf } from "../src/crew/guard.ts";
1819 import { createRepo, type Repo } from "../src/core/repos.ts";
@@ -146,7 +147,7 @@ describe("give a handed-over pull back to the crew", () => {
146147 if (!made.ok) throw new Error(`seed repo: ${made.error}`);
147148 repo = made.value;
148149 for (const agent of [builder, reviewer]) addCrew(app.db, repo.id, agent.id, owner.id);
149 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
150 seatAs(app.db, repo.id, reviewer.id, "reviewer");
150151 cookie = await signIn("alice", "a-good-passphrase");
151152 readerCookie = await signIn("reader", "a-good-passphrase");
152153 }, 30_000);
@@ -226,7 +227,7 @@ describe("give a handed-over pull back to the crew", () => {
226227 if (!planner.ok) throw new Error(planner.error);
227228 createAgentRow(app.db, { userId: planner.value.id, brief: "plans." });
228229 addCrew(app.db, repo.id, planner.value.id, owner.id);
229 setCrewRole(app.db, repo.id, planner.value.id, "planner");
230 seatAs(app.db, repo.id, planner.value.id, "planner");
230231 const fake = join(dataDir, "fake-claude.sh");
231232 writeFileSync(
232233 fake,
test/helpers/parks-engine.ts +3 −2
@@ -1,3 +1,4 @@
1 import { seatAs } from "./seat.ts";
12 import { expect } from "bun:test";
23 import { mkdtemp, rm } from "node:fs/promises";
34 import { tmpdir } from "node:os";
@@ -6,7 +7,7 @@ import { createApp } from "../../src/app.ts";
67 import { addToCrew, createAgent } from "../../src/actions/crew.ts";
78 import { openIssue } from "../../src/actions/issues.ts";
89 import { createRepo, updateRepoSettings } from "../../src/actions/repos.ts";
9 import { setCrewRole } from "../../src/core/agents.ts";
10 import {} from "../../src/core/agents.ts";
1011 import type { Issue } from "../../src/core/issues.ts";
1112 import { getRepoById } from "../../src/core/repos.ts";
1213 import { claimRun, createRun, finishRun } from "../../src/core/runs.ts";
@@ -48,7 +49,7 @@ export async function engine(
4849 }
4950 const atlas = getUserByName(app.db, "atlas")!;
5051 const mason = getUserByName(app.db, "mason")!;
51 setCrewRole(app.db, repo.id, atlas.id, "planner");
52 seatAs(app.db, repo.id, atlas.id, "planner");
5253 const set = await updateRepoSettings(app, alice, repo, {
5354 autonomy: "autonomous",
5455 autonomyDirection: "Keep the engine small.",
test/helpers/seat.ts added +14
@@ -0,0 +1,14 @@
1 /**
2 * Seat an agent for a test: widens what it declares to include the seat, then
3 * sets it on the repository. The product only ever narrows (#8907); a test
4 * that wants a planner out of a plain agent says so here.
5 */
6 import type { Database } from "bun:sqlite";
7 import { getAgent, parseRoles, setCrewRole, type CrewRole } from "../../src/core/agents.ts";
8
9 export function seatAs(db: Database, repoId: string, userId: string, role: CrewRole): boolean {
10 const agent = getAgent(db, userId);
11 if (agent && !agent.roles.includes(role))
12 db.query("UPDATE agent SET roles = ?2 WHERE user_id = ?1").run(userId, parseRoles([...agent.roles, role].join(",")).join(","));
13 return setCrewRole(db, repoId, userId, role);
14 }
test/holds.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * or lands it. The refusals come first.
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -14,7 +15,7 @@ import { openIssue } from "../src/actions/issues.ts";
1415 import { mergePull, openPull } from "../src/actions/pulls.ts";
1516 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1617 import { createApp, type App } from "../src/app.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import {} from "../src/core/agents.ts";
1819 import { createApp as insertApp } from "../src/core/apps.ts";
1920 import {
2021 cleanWhy,
@@ -453,7 +454,7 @@ describe("a held pull and the rules", () => {
453454 });
454455
455456 test("a held green pull with an approving review does not land", async () => {
456 setCrewRole(f.app.db, f.repo.id, quill.id, "reviewer");
457 seatAs(f.app.db, f.repo.id, quill.id, "reviewer");
457458 const pull = await greenPull("approved", mason);
458459 approve(pull, f.alice);
459460 hold(pull);
test/issue-teams-doors.test.ts +3 −2
@@ -5,6 +5,7 @@
55 * on the crew can ask never stops the pull being reviewed.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -14,7 +15,7 @@ import { openIssue } from "../src/actions/issues.ts";
1415 import { openPull } from "../src/actions/pulls.ts";
1516 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1617 import { createApp, type App } from "../src/app.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import {} from "../src/core/agents.ts";
1819 import { listEvents } from "../src/core/events.ts";
1920 import { teamsOfIssue, teamVerdicts } from "../src/core/issue_teams.ts";
2021 import type { Issue } from "../src/core/issues.ts";
@@ -142,7 +143,7 @@ describe("naming teams, at every door", () => {
142143 const agent = await createAgent(app, alice, { username: "quill" });
143144 if (!agent.ok) throw new Error(String(agent.error));
144145 addToCrew(app, alice, repo, "quill");
145 setCrewRole(app.db, repo.id, id("quill"), "reviewer");
146 seatAs(app.db, repo.id, id("quill"), "reviewer");
146147 }, 30_000);
147148
148149 afterAll(async () => {
test/issue-teams.test.ts +4 −3
@@ -4,6 +4,7 @@
44 * and the naming is a writer's, validated at the boundary.
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -14,7 +15,7 @@ import { openIssue } from "../src/actions/issues.ts";
1415 import { openPull } from "../src/actions/pulls.ts";
1516 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1617 import { createApp, type App } from "../src/app.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import {} from "../src/core/agents.ts";
1819 import { teamsOfIssue, teamVerdicts } from "../src/core/issue_teams.ts";
1920 import type { Issue } from "../src/core/issues.ts";
2021 import type { Pull } from "../src/core/pulls.ts";
@@ -114,7 +115,7 @@ describe("cross-team work", () => {
114115 const agent = await createAgent(app, alice, { username: name });
115116 if (!agent.ok) throw new Error(String(agent.error));
116117 addToCrew(app, alice, repo, name);
117 setCrewRole(app.db, repo.id, id(name), "reviewer");
118 seatAs(app.db, repo.id, id(name), "reviewer");
118119 }
119120 });
120121 await step("teams", () => {
@@ -191,7 +192,7 @@ describe("cross-team work", () => {
191192 const agent = await createAgent(app, alice, { username: "ember" });
192193 if (!agent.ok) throw new Error(String(agent.error));
193194 addToCrew(app, alice, repo, "ember");
194 setCrewRole(app.db, repo.id, id("ember"), "reviewer");
195 seatAs(app.db, repo.id, id("ember"), "reviewer");
195196 saveTeam(app.db, "security", { charter: "", dial: 0, members: [id("ember")] });
196197 saveTeam(app.db, "platform", { charter: "", dial: 0, members: [id("ember")] });
197198 const pull = await pullClosing(issueNaming(["security", "platform"]));
test/landed-held.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * holds it under `landed`, and a person's comment or edit since the merge
55 * makes it pickable again; an agent's word does not.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -13,7 +14,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { addComment, editIssue, openIssue } from "../src/actions/issues.ts";
1415 import { mergePull, openPull } from "../src/actions/pulls.ts";
1516 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import {} from "../src/core/agents.ts";
1718 import { landedNote } from "../src/core/done_on_main.ts";
1819 import type { ForgeEvent } from "../src/core/events.ts";
1920 import { getIssueById, listComments, setAssignee, type Issue } from "../src/core/issues.ts";
@@ -79,7 +80,7 @@ describe("a landing with boxes unticked waits for a person", () => {
7980 if (!created.ok) throw new Error(String(created.error));
8081 expect(addToCrew(app, alice, made.value, agent).ok).toBe(true);
8182 }
82 setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
83 seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
8384 mason = getUserByName(app.db, "mason")!;
8485 const set = await updateRepoSettings(app, alice, made.value, {
8586 autonomy: "autonomous",
test/ledger.test.ts +3 −2
@@ -3,6 +3,7 @@
33 * ship or show, try again or stop — each with a rule underneath and a fake
44 * decider on top, and each written down with what came of it.
55 */
6 import { seatAs } from "./helpers/seat.ts";
67 import { beforeAll, afterAll, describe, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
89 import { tmpdir } from "node:os";
@@ -30,7 +31,7 @@ import { submitReview } from "../src/core/reviews.ts";
3031 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
3132 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
3233 import { createUser, getUserByName, type User } from "../src/core/users.ts";
33 import { setCrewRole } from "../src/core/agents.ts";
34 import {} from "../src/core/agents.ts";
3435 import type { Answer, Decider, Questions } from "../src/crew/decide.ts";
3536 import { driveRepo, landIfGreen } from "../src/crew/autonomy.ts";
3637 import { summariseCheckFailure } from "../src/checks/summary.ts";
@@ -103,7 +104,7 @@ describe("the decision ledger", () => {
103104 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
104105 }
105106 scout = getUserByName(app.db, "scout")!;
106 setCrewRole(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner");
107 seatAs(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner");
107108 updateRepoSettings(app.db, repo, {
108109 autonomy: "autonomous",
109110 autonomyDirection: "Sell one real thing end to end.",
test/limb-replan.test.ts +3 −2
@@ -7,6 +7,7 @@
77 * closes only as split into what is new; a person takes it back before it
88 * runs; the milestone shows the change.
99 */
10 import { seatAs } from "./helpers/seat.ts";
1011 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1112 import { mkdtemp, rm } from "node:fs/promises";
1213 import { tmpdir } from "node:os";
@@ -17,7 +18,7 @@ import { linkIssue } from "../src/actions/dependencies.ts";
1718 import { openIssue, setIssueState } from "../src/actions/issues.ts";
1819 import { previewReplan, replanLimb, takeBackLimb } from "../src/actions/limb_replan.ts";
1920 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
20 import { setCrewRole } from "../src/core/agents.ts";
21 import {} from "../src/core/agents.ts";
2122 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
2223 import { getIssueById, type Issue } from "../src/core/issues.ts";
2324 import { pendingReplans, replanSince } from "../src/core/limb_replan.ts";
@@ -141,7 +142,7 @@ describe("a limb handed to the planner", () => {
141142 }
142143 atlas = getUserByName(app.db, "atlas")!;
143144 mason = getUserByName(app.db, "mason")!;
144 setCrewRole(app.db, repo.id, atlas.id, "planner");
145 seatAs(app.db, repo.id, atlas.id, "planner");
145146 const set = await updateRepoSettings(app, alice, repo, {
146147 autonomy: "autonomous",
147148 autonomyDirection: "Keep the engine small.",
test/limbs.test.ts +3 −2
@@ -9,6 +9,7 @@
99 * writes, what something outside the limb still needs is never taken, and
1010 * undo puts it back.
1111 */
12 import { seatAs } from "./helpers/seat.ts";
1213 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1314 import { mkdtemp, rm } from "node:fs/promises";
1415 import { tmpdir } from "node:os";
@@ -20,7 +21,7 @@ import { undoFleetOp } from "../src/actions/fleet.ts";
2021 import { openIssue, setIssueState } from "../src/actions/issues.ts";
2122 import { applyLimb, previewLimb } from "../src/actions/limbs.ts";
2223 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
23 import { setCrewRole } from "../src/core/agents.ts";
24 import {} from "../src/core/agents.ts";
2425 import {
2526 dependenciesOf,
2627 readyNow,
@@ -202,7 +203,7 @@ describe("limb actions", () => {
202203 }
203204 mason = getUserByName(app.db, "mason")!;
204205 atlas = getUserByName(app.db, "atlas")!;
205 setCrewRole(app.db, engine.id, atlas.id, "planner");
206 seatAs(app.db, engine.id, atlas.id, "planner");
206207
207208 trust = issue(engine, "P0 Trust: agents contained");
208209 feed = issue(engine, "P1 Feed: one front door");
test/map-first.test.ts +3 −2
@@ -2,6 +2,7 @@
22 * "This first" (#7806): a writer's rank puts a ready issue ahead of the rest
33 * for the dispatcher; it never lifts a blocker, and it ends when the issue closes.
44 */
5 import { seatAs } from "./helpers/seat.ts";
56 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
67 import { mkdtemp, rm } from "node:fs/promises";
78 import { tmpdir } from "node:os";
@@ -10,7 +11,7 @@ import { createApp, type App } from "../src/app.ts";
1011 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1112 import { openIssue, setIssueState } from "../src/actions/issues.ts";
1213 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
13 import { setCrewRole } from "../src/core/agents.ts";
14 import {} from "../src/core/agents.ts";
1415 import { addDependency, readyNow, repoGraph } from "../src/core/dependencies.ts";
1516 import { firstAt, putFirst } from "../src/core/first.ts";
1617 import { getIssueById, type Issue } from "../src/core/issues.ts";
@@ -45,7 +46,7 @@ describe("this first", () => {
4546 if (!created.ok) throw new Error(String(created.error));
4647 expect(addToCrew(app, alice, made.value, agent).ok).toBe(true);
4748 }
48 setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
49 seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
4950 const set = await updateRepoSettings(app, alice, made.value, {
5051 autonomy: "autonomous",
5152 autonomyDirection: "Keep the engine small.",
test/mcp.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * action the pages call — so what a person may not do on a page they may
55 * not do here.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { chmodSync, writeFileSync } from "node:fs";
910 import { mkdtemp, rm } from "node:fs/promises";
@@ -14,7 +15,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1415 import { pullDir } from "../src/core/pulls.ts";
1516 import { resolveOid } from "../src/git/merge.ts";
1617 import { openIssue } from "../src/actions/issues.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import {} from "../src/core/agents.ts";
1819 import { openPull } from "../src/actions/pulls.ts";
1920 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
2021 import { createApp as insertApp } from "../src/core/apps.ts";
@@ -571,7 +572,7 @@ describe("the forge over MCP", () => {
571572 const made = await createAgent(app, alice, { username: "closer", brief: "x" });
572573 if (!made.ok) throw new Error(String(made.error));
573574 expect(addToCrew(app, alice, game, "closer").ok).toBe(true);
574 setCrewRole(app.db, game.id, made.value.user.id, "planner");
575 seatAs(app.db, game.id, made.value.user.id, "planner");
575576 const botToken = createToken(app.db, made.value.user.id, "bot", "rw").token;
576577 const mine = (title: string) => {
577578 const o = openIssue(app, made.value.user, game, { title });
test/member-crew.test.ts +1 −1
@@ -47,7 +47,7 @@ describe("a member and the crew's standing instructions", () => {
4747 fetch(`${servers.url}/settings/crew/robin`, {
4848 method: "POST",
4949 headers: { cookie, origin: FORGE_URL },
50 body: new URLSearchParams({ model: "", brief: text, budgetUsd: "1" }),
50 body: new URLSearchParams({ model: "", brief: text, budgetUsd: "1", roles: "builder" }),
5151 redirect: "manual",
5252 });
5353
test/next-steps-card.test.ts +3 −2
@@ -5,6 +5,7 @@
55 * second account.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { join } from "node:path";
1213 import { createRepo } from "../src/actions/repos.ts";
1314 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1415 import { createApp, type App } from "../src/app.ts";
15 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
16 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1617 import { createSession } from "../src/core/sessions.ts";
1718 import { createUser } from "../src/core/users.ts";
1819 import { commitFiles } from "../src/git/write.ts";
@@ -102,7 +103,7 @@ describe("the next-steps card of a spawned forge", () => {
102103 createdBy: alice.id,
103104 });
104105 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
105 setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner");
106 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
106107 parentServers = await serve(parent);
107108 const spawned = await spawnForge(
108109 parent,
test/planner-closes-pull.test.ts +3 −2
@@ -2,6 +2,7 @@
22 * The planner's one close on a pull (#3301): an agent's pull whose issue is
33 * closed. An open issue, a person's pull, a merge and a reopen stay refused.
44 */
5 import { seatAs } from "./helpers/seat.ts";
56 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
67 import { mkdtemp, rm } from "node:fs/promises";
78 import { tmpdir } from "node:os";
@@ -11,7 +12,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1112 import { listComments } from "../src/core/issues.ts";
1213 import { openIssue, setIssueState } from "../src/actions/issues.ts";
1314 import { createRepo } from "../src/actions/repos.ts";
14 import { setCrewRole } from "../src/core/agents.ts";
15 import {} from "../src/core/agents.ts";
1516 import { getIssueById, type Issue } from "../src/core/issues.ts";
1617 import type { Repo } from "../src/core/repos.ts";
1718 import { createToken } from "../src/core/tokens.ts";
@@ -71,7 +72,7 @@ describe("the planner closes a pull", () => {
7172 }
7273 atlas = getUserByName(app.db, "atlas")!;
7374 mason = getUserByName(app.db, "mason")!;
74 setCrewRole(app.db, repo.id, atlas.id, "planner");
75 seatAs(app.db, repo.id, atlas.id, "planner");
7576 token = createToken(app.db, atlas.id, "rw", "rw").token;
7677 });
7778
test/planner-lent.test.ts +4 −3
@@ -2,6 +2,7 @@
22 * A repository in auto mode with a direction and no planner gets one, and
33 * its first planned issues start without a further step (#7637).
44 */
5 import { seatAs } from "./helpers/seat.ts";
56 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
67 import { mkdtemp, rm } from "node:fs/promises";
78 import { tmpdir } from "node:os";
@@ -9,7 +10,7 @@ import { join } from "node:path";
910 import { createApp, type App } from "../src/app.ts";
1011 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1112 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
12 import { crewRole, setCrewRole } from "../src/core/agents.ts";
13 import { crewRole } from "../src/core/agents.ts";
1314 import { getIssueById } from "../src/core/issues.ts";
1415 import { createRepo as coreCreateRepo, getRepoById, type Repo } from "../src/core/repos.ts";
1516 import { createUser, getUserByName, type User } from "../src/core/users.ts";
@@ -60,7 +61,7 @@ describe("a migrated repository with only a builder", () => {
6061 // Elsewhere on the forge atlas plans.
6162 const home = await repo("home");
6263 expect(addToCrew(app, alice, home, "atlas").ok).toBe(true);
63 setCrewRole(app.db, home.id, getUserByName(app.db, "atlas")!.id, "planner");
64 seatAs(app.db, home.id, getUserByName(app.db, "atlas")!.id, "planner");
6465 // The migrated one: a builder, nothing else.
6566 pixel = await repo("pixel-plays");
6667 expect(addToCrew(app, alice, pixel, "torque").ok).toBe(true);
@@ -121,7 +122,7 @@ describe("a migrated repository with only a builder", () => {
121122 test("a planner with a crew but no direction issue yet reads the second message", async () => {
122123 const third = await repo("third");
123124 expect(addToCrew(app, alice, third, "atlas").ok).toBe(true);
124 setCrewRole(app.db, third.id, getUserByName(app.db, "atlas")!.id, "planner");
125 seatAs(app.db, third.id, getUserByName(app.db, "atlas")!.id, "planner");
125126 await updateRepoSettings(app, alice, third, {
126127 autonomy: "assisted",
127128 autonomyDirection: "more",
test/proposal-card.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * Refusals come first: a non-owner and a cross-origin post change nothing.
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { proposePlan } from "../src/actions/plan.ts";
1415 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
16 import { getAgentByName } from "../src/core/agents.ts";
1617 import { listIssues, listComments } from "../src/core/issues.ts";
1718 import { getProposal } from "../src/core/proposals.ts";
1819 import type { Repo } from "../src/core/repos.ts";
@@ -90,7 +91,7 @@ describe("a wave proposal is one inbox card", () => {
9091 repo = made.value;
9192 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# wave\n" }] });
9293 addToCrew(app, alice, repo, "planner");
93 setCrewRole(app.db, repo.id, getAgentByName(app.db, "planner")!.user.id, "planner");
94 seatAs(app.db, repo.id, getAgentByName(app.db, "planner")!.user.id, "planner");
9495 }, 30_000);
9596
9697 afterAll(async () => {
test/pull-tui-teams.test.ts +3 −2
@@ -2,6 +2,7 @@
22 * The terminal's pull screen names the team that still owes a review (#2897),
33 * as the web pull page's team card does.
44 */
5 import { seatAs } from "./helpers/seat.ts";
56 import { afterAll, beforeAll, expect, test } from "bun:test";
67 import { mkdtemp, rm } from "node:fs/promises";
78 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { openIssue } from "../src/actions/issues.ts";
1213 import { openPull } from "../src/actions/pulls.ts";
1314 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1415 import { createApp, type App } from "../src/app.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import { getPull } from "../src/core/pulls.ts";
1718 import { submitReview } from "../src/core/reviews.ts";
1819 import { saveTeam } from "../src/core/teams.ts";
@@ -58,7 +59,7 @@ test("a two-team issue shows one team approved and one awaited; no team shows no
5859 const agent = await createAgent(app, alice, { username: name });
5960 if (!agent.ok) throw new Error(String(agent.error));
6061 addToCrew(app, alice, repo, name);
61 setCrewRole(app.db, repo.id, id(name), "reviewer");
62 seatAs(app.db, repo.id, id(name), "reviewer");
6263 }
6364 saveTeam(app.db, "design", { charter: "", dial: 0, members: [id("quill")] });
6465 saveTeam(app.db, "data-science", { charter: "", dial: 0, members: [id("pike")] });
test/red-approved.test.ts +3 −2
@@ -2,6 +2,7 @@
22 * An approved pull red for 24 h gets one re-check per head, then goes back
33 * to its builder naming the failing test (#6349). `now` is passed in.
44 */
5 import { seatAs } from "./helpers/seat.ts";
56 import { afterAll, beforeAll, expect, test } from "bun:test";
67 import { mkdtemp, rm } from "node:fs/promises";
78 import { tmpdir } from "node:os";
@@ -11,7 +12,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1112 import { openPull } from "../src/actions/pulls.ts";
1213 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1314 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
14 import { setCrewRole } from "../src/core/agents.ts";
15 import {} from "../src/core/agents.ts";
1516 import { listComments } from "../src/core/issues.ts";
1617 import type { Pull } from "../src/core/pulls.ts";
1718 import type { Repo } from "../src/core/repos.ts";
@@ -54,7 +55,7 @@ beforeAll(async () => {
5455 }
5556 const mason = getUserByName(app.db, "mason")!;
5657 quill = getUserByName(app.db, "quill")!;
57 setCrewRole(app.db, repo.id, quill.id, "reviewer");
58 seatAs(app.db, repo.id, quill.id, "reviewer");
5859 await commitToBranch(app, alice, repo, {
5960 branch: "feature/x",
6061 base: "main",
test/requested-pulls.test.ts +3 −2
@@ -5,6 +5,7 @@
55 * share, so these pin it.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -13,7 +14,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openPull } from "../src/actions/pulls.ts";
1415 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1516 import { createApp, type App } from "../src/app.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import {} from "../src/core/agents.ts";
1718 import type { Pull } from "../src/core/pulls.ts";
1819 import { getPull } from "../src/core/pulls.ts";
1920 import { requestedPulls, verdictIsStale } from "../src/core/requested.ts";
@@ -100,7 +101,7 @@ describe("requestedPulls", () => {
100101 if (!r.ok) throw new Error(String(r.error));
101102 await push(r.value, "main");
102103 for (const who of ["mason", "quill"]) expect(addToCrew(app, alice, r.value, who).ok).toBe(true);
103 setCrewRole(app.db, r.value.id, reviewer.id, "reviewer");
104 seatAs(app.db, r.value.id, reviewer.id, "reviewer");
104105 repos.push(r.value);
105106 }
106107 [garden, orchard] = repos as [Repo, Repo];
test/requested.test.ts +3 −2
@@ -6,6 +6,7 @@
66 * which pulls wait on a builder and which on a reviewer.
77 */
88
9 import { seatAs } from "./helpers/seat.ts";
910 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1011 import { mkdtemp, rm } from "node:fs/promises";
1112 import { tmpdir } from "node:os";
@@ -14,7 +15,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1415 import { openPull } from "../src/actions/pulls.ts";
1516 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1617 import { createApp, type App } from "../src/app.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import {} from "../src/core/agents.ts";
1819 import { getIssueById, listComments } from "../src/core/issues.ts";
1920 import { getPull, type Pull } from "../src/core/pulls.ts";
2021 import { getRepoById, type Repo } from "../src/core/repos.ts";
@@ -148,7 +149,7 @@ describe("a pull with changes requested always moves", () => {
148149 }
149150 builder = getUserByName(app.db, "mason")!;
150151 reviewer = getUserByName(app.db, "quill")!;
151 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
152 seatAs(app.db, repo.id, reviewer.id, "reviewer");
152153 // Assisted: a lapsed head is not sent for being autonomous, only for answering a verdict.
153154 app.db
154155 .query("UPDATE repo SET autonomy = 'assisted', autonomy_by = ?2, autonomy_slots = 3 WHERE id = ?1")
test/review-from-check.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * reviewer's task (#1237). A review that ends with no verdict is sent again,
55 * to the other reviewer when the crew has one.
66 */
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1213 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1314 import { openPull } from "../src/actions/pulls.ts";
1415 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import type { Repo } from "../src/core/repos.ts";
1718 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
1819 import { createUser, getUserByName, type User } from "../src/core/users.ts";
@@ -99,7 +100,7 @@ describe("a review that comes back with no verdict", () => {
99100 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
100101 }
101102 for (const name of ["juniper", "sentinel"])
102 setCrewRole(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer");
103 seatAs(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer");
103104 });
104105
105106 afterAll(async () => {
test/review-from-green.test.ts +3 −2
@@ -3,6 +3,7 @@
33 * a slow check queue must not lapse a pull before review was ever sent.
44 */
55
6 import { seatAs } from "./helpers/seat.ts";
67 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
89 import { tmpdir } from "node:os";
@@ -11,7 +12,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1112 import { openPull } from "../src/actions/pulls.ts";
1213 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1314 import { createApp, type App } from "../src/app.ts";
14 import { setCrewRole } from "../src/core/agents.ts";
15 import {} from "../src/core/agents.ts";
1516 import type { Pull } from "../src/core/pulls.ts";
1617 import { getRepoById, type Repo } from "../src/core/repos.ts";
1718 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
@@ -98,7 +99,7 @@ describe("review is owed from the check's green", () => {
9899 const agent = await createAgent(app, alice, { username: "quill" });
99100 if (!agent.ok) throw new Error(String(agent.error));
100101 addToCrew(app, alice, repo, "quill");
101 setCrewRole(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer");
102 seatAs(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer");
102103 }, 30_000);
103104
104105 afterAll(async () => {
test/review-gate.test.ts +13 −12
@@ -5,6 +5,7 @@
55 * reviewer: it waits for a person (#597). A person's own pull lands as
66 * before.
77 */
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -13,7 +14,7 @@ import { createApp, type App } from "../src/app.ts";
1314 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1415 import { openPull } from "../src/actions/pulls.ts";
1516 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import {} from "../src/core/agents.ts";
1718 import { getPull, type Pull } from "../src/core/pulls.ts";
1819 import { listComments } from "../src/core/issues.ts";
1920 import {
@@ -137,7 +138,7 @@ describe("the review gate", () => {
137138 ["an agent's", () => mason],
138139 ] as const) {
139140 test(`${who} green pull is reviewed, and lands only on an approval of its head`, async () => {
140 setCrewRole(app.db, repo.id, quill.id, "reviewer");
141 seatAs(app.db, repo.id, quill.id, "reviewer");
141142 const name = who.includes("person") ? "by-person" : "by-agent";
142143 const pull = await greenPull(author(), name);
143144 expect(await landIfGreen(app, fresh(), pull)).toBe(false);
@@ -180,7 +181,7 @@ describe("the review gate", () => {
180181 };
181182
182183 test("an agent's pull whose review lapses never ships: it waits for a person, and a person's approval lands it", async () => {
183 setCrewRole(app.db, repo.id, quill.id, "reviewer");
184 seatAs(app.db, repo.id, quill.id, "reviewer");
184185 const pull = await greenPull(mason, "unanswered");
185186 expect(await landIfGreen(app, fresh(), pull)).toBe(false);
186187 unanswered(pull);
@@ -200,7 +201,7 @@ describe("the review gate", () => {
200201 });
201202
202203 test("with no reviewer on the crew an agent's pull waits for a person and says why", async () => {
203 setCrewRole(app.db, repo.id, quill.id, "builder");
204 seatAs(app.db, repo.id, quill.id, "builder");
204205 const pull = await greenPull(mason, "no-reviewer");
205206 expect(await landIfGreen(app, fresh(), pull)).toBe(false);
206207 expect(reviewRuns(pull)).toHaveLength(0);
@@ -210,7 +211,7 @@ describe("the review gate", () => {
210211 });
211212
212213 test("a person's own pull is unchanged: a review that lapses does not hold it; it lands, and says so", async () => {
213 setCrewRole(app.db, repo.id, quill.id, "reviewer");
214 seatAs(app.db, repo.id, quill.id, "reviewer");
214215 const pull = await greenPull(alice, "person-unanswered");
215216 expect(await landIfGreen(app, fresh(), pull)).toBe(false);
216217 unanswered(pull);
@@ -222,7 +223,7 @@ describe("the review gate", () => {
222223 });
223224
224225 test("a change requested on the head holds the pull", async () => {
225 setCrewRole(app.db, repo.id, quill.id, "reviewer");
226 seatAs(app.db, repo.id, quill.id, "reviewer");
226227 const pull = await greenPull(alice, "contested");
227228 submitReview(app.db, {
228229 issueId: pull.id,
@@ -255,11 +256,11 @@ describe("the review gate", () => {
255256 }
256257
257258 test("a reviewer taken off the crew no longer holds the pulls it judged", async () => {
258 setCrewRole(app.db, repo.id, quill.id, "reviewer");
259 seatAs(app.db, repo.id, quill.id, "reviewer");
259260 const pull = await greenPull(mason, "judged");
260261 askChanges(pull, quill);
261262 expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(1);
262 setCrewRole(app.db, repo.id, quill.id, "builder");
263 seatAs(app.db, repo.id, quill.id, "builder");
263264 expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(0);
264265 // A person's request is the person's, whatever their crew.
265266 askChanges(pull, alice);
@@ -267,7 +268,7 @@ describe("the review gate", () => {
267268 });
268269
269270 test(`the reviewer gets ${REVIEW_ROUNDS} rounds of changes; a third is advice and the pull lands`, async () => {
270 setCrewRole(app.db, repo.id, quill.id, "reviewer");
271 seatAs(app.db, repo.id, quill.id, "reviewer");
271272 let pull = await greenPull(mason, "rounds");
272273 askChanges(pull, quill);
273274 // Asked twice on one head is still one round.
@@ -283,8 +284,8 @@ describe("the review gate", () => {
283284 });
284285
285286 test("a builder's run that answers a change request without pushing parks the pull", async () => {
286 setCrewRole(app.db, repo.id, quill.id, "reviewer");
287 setCrewRole(app.db, repo.id, mason.id, "builder");
287 seatAs(app.db, repo.id, quill.id, "reviewer");
288 seatAs(app.db, repo.id, mason.id, "builder");
288289 const pull = await greenPull(mason, "unmoved");
289290 askChanges(pull, quill);
290291 expect(
@@ -348,7 +349,7 @@ describe("a pull nobody reviewed, however old (#1655)", () => {
348349 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
349350 }
350351 mason = getUserByName(app.db, "mason")!;
351 setCrewRole(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer");
352 seatAs(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer");
352353 });
353354
354355 afterAll(async () => {
test/review-page.test.ts +3 −2
@@ -1,4 +1,5 @@
11 /** `/review` lists each ask waiting on a person as one row with its why. */
2 import { seatAs } from "./helpers/seat.ts";
23 import { afterAll, beforeAll, expect, test } from "bun:test";
34 import { mkdtempSync, rmSync } from "node:fs";
45 import { tmpdir } from "node:os";
@@ -6,7 +7,7 @@ import { join } from "node:path";
67 import { createApp, type App } from "../src/app.ts";
78 import { addComment, openIssue } from "../src/actions/issues.ts";
89 import { addComment as addRawComment } from "../src/core/issues.ts";
9 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
10 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1011 import { recordLook } from "../src/core/looks.ts";
1112 import { previewEnv } from "../src/apps/data.ts";
1213 import { createApp as createAppRow } from "../src/core/apps.ts";
@@ -58,7 +59,7 @@ beforeAll(async () => {
5859 if (!made.ok) throw new Error(made.error);
5960 const repo = made.value;
6061 for (const a of [builder, reviewer]) addCrew(app.db, repo.id, a.id, alice.id);
61 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
62 seatAs(app.db, repo.id, reviewer.id, "reviewer");
6263 let seq = 0;
6364 const pull = (author: string, title: string, age: number) => {
6465 const issueId = id();
test/review-route.test.ts +3 −2
@@ -6,6 +6,7 @@
66 * not only one that ships on its own. A review run is judged by its verdict:
77 * it pushes nothing by design.
88 */
9 import { seatAs } from "./helpers/seat.ts";
910 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1011 import { mkdtemp, rm } from "node:fs/promises";
1112 import { tmpdir } from "node:os";
@@ -14,7 +15,7 @@ import { createApp, type App } from "../src/app.ts";
1415 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1516 import { openPull } from "../src/actions/pulls.ts";
1617 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
17 import { setCrewRole, type Agent } from "../src/core/agents.ts";
18 import { type Agent } from "../src/core/agents.ts";
1819 import type { Pull } from "../src/core/pulls.ts";
1920 import { getRepoById, type Repo } from "../src/core/repos.ts";
2021 import type { Review } from "../src/core/reviews.ts";
@@ -185,7 +186,7 @@ describe("every green pull an agent wrote is sent to review", () => {
185186 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
186187 }
187188 for (const name of ["juniper", "sentinel"])
188 setCrewRole(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer");
189 seatAs(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer");
189190 // Not autonomous: review is not the shipping loop's alone.
190191 expect(repo.autonomy).not.toBe("autonomous");
191192 });
test/review-wait.test.ts +3 −2
@@ -3,6 +3,7 @@
33 * word), not in the owner's asks, and moves to the owner on a verdict (#1602).
44 */
55
6 import { seatAs } from "./helpers/seat.ts";
67 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
89 import { tmpdir } from "node:os";
@@ -11,7 +12,7 @@ import { createApp, type App } from "../src/app.ts";
1112 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1213 import { openPull } from "../src/actions/pulls.ts";
1314 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { setCrewRole } from "../src/core/agents.ts";
15 import {} from "../src/core/agents.ts";
1516 import type { Pull } from "../src/core/pulls.ts";
1617 import type { Repo } from "../src/core/repos.ts";
1718 import { submitReview } from "../src/core/reviews.ts";
@@ -84,7 +85,7 @@ describe("a green agent pull and its reviewer", () => {
8485 await createAgent(app, alice, { username: name });
8586 addToCrew(app, alice, repo, name);
8687 }
87 setCrewRole(app.db, repo.id, getUserByName(app.db, "rev")!.id, "reviewer");
88 seatAs(app.db, repo.id, getUserByName(app.db, "rev")!.id, "reviewer");
8889 pull = await greenPull(1);
8990 await home(); // warm the first render so no test pays for it under load
9091 }, 60_000);
test/seat-slots.test.ts +10 −9
@@ -5,6 +5,7 @@
55 * a change is audited and takes effect on the next tick, and the MCP `crew`
66 * tool sets the same numbers.
77 */
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { join } from "node:path";
1213 import { createApp, type App } from "../src/app.ts";
1314 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1415 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import {} from "../src/core/agents.ts";
1617 import { crewNow } from "../src/actions/crew_now.ts";
1718 import { reviewSays } from "../src/crew/review_says.ts";
1819 import { askLinesFor } from "../src/web/studio/asks.ts";
@@ -72,13 +73,13 @@ beforeAll(async () => {
7273 servers = await serve(app);
7374 alice = ok(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, adminIfFirst: true }));
7475 proveAll(app);
75 for (const name of ["mason", "atlas", "judge"]) ok(await createAgent(app, alice, { username: name }));
76 for (const name of ["mason", "atlas", "judge"]) ok(await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] }));
7677 repo = ok(await createRepo(app, alice, { name: "yard", visibility: "public" }));
7778 ok(await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "README.md", content: "# yard\n" }] }));
78 for (const name of ["mason", "atlas", "judge"]) expect(addToCrew(app, alice, repo, name).ok).toBe(true);
79 for (const name of ["mason", "atlas", "judge"]) expect(addToCrew(app, alice, repo, name, "builder").ok).toBe(true);
7980 const id = (n: string) => getUserByName(app.db, n)!.id;
80 setCrewRole(app.db, repo.id, id("atlas"), "planner");
81 setCrewRole(app.db, repo.id, id("judge"), "reviewer");
81 seatAs(app.db, repo.id, id("atlas"), "planner");
82 seatAs(app.db, repo.id, id("judge"), "reviewer");
8283 ok(await updateRepoSettings(app, alice, repo, { autonomy: "autonomous", autonomyDirection: "Make the yard rounder.", autonomyCeilingUsd: 10, autonomySlots: 2, autonomyBy: alice.id }));
8384 const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: PASSWORD }), redirect: "manual" });
8485 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
@@ -230,11 +231,11 @@ test("a lapsed review says why on the now list, the MCP and the asks: paused, un
230231 expect(paused.ask).toContain("the reviewer is paused");
231232 app.db.query("UPDATE repo SET reviewer_slots = 8 WHERE id = ?1").run(repo.id);
232233
233 setCrewRole(app.db, repo.id, judge.id, "builder");
234 seatAs(app.db, repo.id, judge.id, "builder");
234235 const none = await says();
235236 expect(none.now).toBe("no reviewer on the crew");
236237 expect(none.ask).toContain("no reviewer on the crew");
237 setCrewRole(app.db, repo.id, judge.id, "reviewer");
238 seatAs(app.db, repo.id, judge.id, "reviewer");
238239 });
239240
240241 test("three change requests are worded as such, never as an empty crew", () => {
@@ -291,8 +292,8 @@ describe("the reviewer's cap, end to end (#8734)", () => {
291292 beforeAll(async () => {
292293 porch = ok(await createRepo(app, alice, { name: "porch", visibility: "private" }));
293294 ok(await commitToBranch(app, alice, porch, { message: "Start", files: [{ path: "README.md", content: "# porch\n" }] }));
294 expect(addToCrew(app, alice, porch, "judge").ok).toBe(true);
295 setCrewRole(app.db, porch.id, getUserByName(app.db, "judge")!.id, "reviewer");
295 expect(addToCrew(app, alice, porch, "judge", "builder").ok).toBe(true);
296 seatAs(app.db, porch.id, getUserByName(app.db, "judge")!.id, "reviewer");
296297 });
297298
298299 test("at 1, the second agent pull is queued only after the first review ends", async () => {
test/spawn-child-apps.test.ts +3 −2
@@ -5,6 +5,7 @@
55 * credential, the home and the app page say so and where to add one.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
1011 import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ import { join } from "node:path";
1213 import { createRepo } from "../src/actions/repos.ts";
1314 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1415 import { createApp, type App } from "../src/app.ts";
15 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
16 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1617 import { getAppByRepo, listApps } from "../src/core/apps.ts";
1718 import { getRepo } from "../src/core/repos.ts";
1819 import {
@@ -91,7 +92,7 @@ describe("a spawned child's apps", () => {
9192 createdBy: alice.id,
9293 });
9394 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
94 setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner");
95 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
9596 }
9697 }
9798 const spawned = await spawnForge(
test/spawn-first-boot.test.ts +3 −3
@@ -6,6 +6,7 @@
66 * child's own pages — and a link that is used, expired or made up says so.
77 */
88
9 import { seatAs } from "./helpers/seat.ts";
910 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1011 import { readdir, mkdtemp, rm } from "node:fs/promises";
1112 import { tmpdir } from "node:os";
@@ -21,8 +22,7 @@ import {
2122 addCrew,
2223 createAgentRow,
2324 listCrewRoles,
24 setCrewRole,
25 } from "../src/core/agents.ts";
25 } from "../src/core/agents.ts";
2626 import { listIssues } from "../src/core/issues.ts";
2727 import { getOrg, listTeams, listTeamMembers } from "../src/core/orgs.ts";
2828 import { getRepo } from "../src/core/repos.ts";
@@ -113,7 +113,7 @@ describe("a spawned child's first boot", () => {
113113 createdBy: alice.id,
114114 });
115115 addCrew(parent.db, repo.value.id, agent.value.id, alice.id);
116 setCrewRole(parent.db, repo.value.id, agent.value.id, role);
116 seatAs(parent.db, repo.value.id, agent.value.id, role);
117117 }
118118 const source: SpawnSource = {
119119 catalog: [
test/studio-runs.test.ts +4 −3
@@ -5,6 +5,7 @@
55 * test/studio*.test.ts. It runs in about 2.5 s with no waits (#3578).
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
89 import {
910 afterAll,
1011 afterEach,
@@ -34,7 +35,7 @@ import {
3435 createRepo,
3536 updateRepoSettings,
3637 } from "../src/actions/repos.ts";
37 import { setCrewRole } from "../src/core/agents.ts";
38 import {} from "../src/core/agents.ts";
3839 import type { Issue } from "../src/core/issues.ts";
3940 import { getPull, type Pull } from "../src/core/pulls.ts";
4041 import type { Repo } from "../src/core/repos.ts";
@@ -112,8 +113,8 @@ describe("the studio's slot count", () => {
112113 }
113114 const reviewer = getUserByName(app.db, "quill")!;
114115 const planner = getUserByName(app.db, "chart")!;
115 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
116 setCrewRole(app.db, repo.id, planner.id, "planner");
116 seatAs(app.db, repo.id, reviewer.id, "reviewer");
117 seatAs(app.db, repo.id, planner.id, "planner");
117118 const on = await updateRepoSettings(app, alice, repo, {
118119 autonomy: "autonomous",
119120 autonomyDirection: "",
test/team-dispatch.test.ts +3 −2
@@ -4,6 +4,7 @@
44 * a planning run receives the teams section in its task.
55 */
66
7 import { seatAs } from "./helpers/seat.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
910 import { mkdtemp, rm } from "node:fs/promises";
@@ -13,7 +14,7 @@ import { createApp, type App } from "../src/app.ts";
1314 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1415 import { assignIssue, openIssue } from "../src/actions/issues.ts";
1516 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import {} from "../src/core/agents.ts";
1718 import { createLabel, getIssueById, setIssueLabels, type Issue } from "../src/core/issues.ts";
1819 import { getRepoById, type Repo } from "../src/core/repos.ts";
1920 import { listRuns } from "../src/core/runs.ts";
@@ -64,7 +65,7 @@ beforeAll(async () => {
6465 if (!agent.ok) throw new Error(String(agent.error));
6566 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
6667 }
67 setCrewRole(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner");
68 seatAs(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner");
6869 // The stand-in agent keeps the first line it is told and ends at once.
6970 const fake = join(dataDir, "fake-claude");
7071 writeFileSync(
test/tui-agents.test.ts +18
@@ -191,6 +191,24 @@ describe("agents in the terminal", () => {
191191 h.stop();
192192 });
193193
194 test("roles change after a confirm and a bad one is refused before it", async () => {
195 const h = boot("alice");
196 await open(h);
197 await h.press("r");
198 await clear(h, 20);
199 await h.type("planner, boss");
200 await h.press("enter");
201 expect(getAgentByName(forge.db, "bot1")!.roles).toEqual(["builder"]);
202 await h.press("escape");
203 await h.press("r");
204 await clear(h, 20);
205 await h.type("planner, reviewer");
206 await h.press("enter");
207 await h.press("y");
208 expect(getAgentByName(forge.db, "bot1")!.roles).toEqual(["planner", "reviewer"]);
209 h.stop();
210 });
211
194212 test("a non-admin and an admin agent are never offered the screen", async () => {
195213 for (const who of ["bob", "carol"]) {
196214 const h = boot(who);
test/tui-limbs-replanned.test.ts +3 −2
@@ -3,6 +3,7 @@
33 * `Re-planned <time ago>` and one line per split, as the milestones page does,
44 * and Enter on a split line opens the new issue. Owner facts only.
55 */
6 import { seatAs } from "./helpers/seat.ts";
67 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
89 import { tmpdir } from "node:os";
@@ -13,7 +14,7 @@ import { linkIssue } from "../src/actions/dependencies.ts";
1314 import { openIssue, setIssueState } from "../src/actions/issues.ts";
1415 import { replanLimb } from "../src/actions/limb_replan.ts";
1516 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import {} from "../src/core/agents.ts";
1718 import { getIssueById, type Issue } from "../src/core/issues.ts";
1819 import { getRepoById, type Repo } from "../src/core/repos.ts";
1920 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
@@ -90,7 +91,7 @@ describe("the limbs screen after a re-plan", () => {
9091 if (!agent.ok) throw new Error(String(agent.error));
9192 expect(addToCrew(app, alice, repo, "atlas").ok).toBe(true);
9293 atlas = getUserByName(app.db, "atlas")!;
93 setCrewRole(app.db, repo.id, atlas.id, "planner");
94 seatAs(app.db, repo.id, atlas.id, "planner");
9495 const set = await updateRepoSettings(app, alice, repo, {
9596 autonomy: "autonomous",
9697 autonomyDirection: "Keep the engine small.",
test/tui-limbs.test.ts +3 −2
@@ -8,6 +8,7 @@
88 * keys to change it, a guest sees nothing private, and a move reaches the
99 * outbox (and so a milestones page open on the web) as `limb.moved`.
1010 */
11 import { seatAs } from "./helpers/seat.ts";
1112 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1213 import { mkdtemp, rm } from "node:fs/promises";
1314 import { tmpdir } from "node:os";
@@ -16,7 +17,7 @@ import { linkIssue } from "../src/actions/dependencies.ts";
1617 import { openIssue } from "../src/actions/issues.ts";
1718 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1819 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
19 import { setCrewRole } from "../src/core/agents.ts";
20 import {} from "../src/core/agents.ts";
2021 import { pendingReplans } from "../src/core/limb_replan.ts";
2122 import { createApp, type App as Forge } from "../src/app.ts";
2223 import { listEvents } from "../src/core/events.ts";
@@ -274,7 +275,7 @@ describe("raise and lower a limb in the terminal", () => {
274275 const agent = await createAgent(forge, alice, { username: "atlas" });
275276 if (!agent.ok) throw new Error(String(agent.error));
276277 addToCrew(forge, alice, r, "atlas");
277 setCrewRole(forge.db, r.id, getUserByName(forge.db, "atlas")!.id, "planner");
278 seatAs(forge.db, r.id, getUserByName(forge.db, "atlas")!.id, "planner");
278279 await updateRepoSettings(forge, alice, r, {
279280 autonomy: "autonomous",
280281 autonomyDirection: "Keep it small.",
test/web-apps.test.ts +4 −3
@@ -732,7 +732,7 @@ describe("apps, runs and the crew on the web", () => {
732732 expect(await bodyOf(alice, "/settings")).toContain('href="/settings/crew"');
733733
734734 const bad = await web(alice, "/settings/crew", {
735 form: { username: "bot", budgetUsd: "-4" },
735 form: { username: "bot", budgetUsd: "-4", roles: "builder" },
736736 });
737737 expect(bad.status).toBe(422);
738738 const badPage = words(await bad.text());
@@ -751,6 +751,7 @@ describe("apps, runs and the crew on the web", () => {
751751 model: "",
752752 brief: "Be brief.",
753753 budgetUsd: "2",
754 roles: "builder",
754755 },
755756 });
756757 expect(made.status).toBe(303);
@@ -765,14 +766,14 @@ describe("apps, runs and the crew on the web", () => {
765766 expect(edit).toContain('value="bot"');
766767 expect(edit).toContain("Retire this agent");
767768 const updated = await web(alice, "/settings/crew/bot", {
768 form: { model: "sonnet", brief: "Be brief.", budgetUsd: "3" },
769 form: { model: "sonnet", brief: "Be brief.", budgetUsd: "3", roles: "builder" },
769770 });
770771 expect(updated.status).toBe(303);
771772 expect(getAgentByName(app.db, "bot")?.model).toBe("sonnet");
772773 expect(getAgentByName(app.db, "bot")?.budgetUsd).toBe(3);
773774 // A refused edit shows the error beside its field.
774775 const refused = await web(alice, "/settings/crew/bot", {
775 form: { model: "sonnet", brief: "Be brief.", budgetUsd: "-4" },
776 form: { model: "sonnet", brief: "Be brief.", budgetUsd: "-4", roles: "builder" },
776777 });
777778 expect(refused.status).toBe(422);
778779 const refusedPage = words(await refused.text());

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs