diff --git a/src/features/team-mode/team-registry/validator.test.ts b/src/features/team-mode/team-registry/validator.test.ts new file mode 100644 index 000000000..b101ded2c --- /dev/null +++ b/src/features/team-mode/team-registry/validator.test.ts @@ -0,0 +1,166 @@ +/// + +import { describe, expect, test } from "bun:test" + +import { TeamSpecSchema } from "../types" + +import type { Member, TeamSpec } from "../types" +import { + TeamSpecValidationError, + validateDualSupport, + validateMemberEligibility, + validateSpec, +} from "./validator" + +const PROMETHEUS_REJECTION_MESSAGE = + "Agent 'prometheus' is plan-mode-only; can only write to .sisyphus/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead." + +function createCategoryMember(name: string): Member { + return { + kind: "category", + name, + category: "deep", + prompt: `implement the assigned work for ${name}`, + backendType: "in-process", + isActive: true, + } +} + +function createBaseTeamSpec(): TeamSpec { + return { + version: 1, + name: "validator-team", + createdAt: 1, + leadAgentId: "lead", + members: [createCategoryMember("lead"), createCategoryMember("reviewer")], + } +} + +describe("team-registry validator", () => { + test("rejects members that specify both category and subagent_type", () => { + // given + const teamSpec = { + ...createBaseTeamSpec(), + members: [ + { + kind: "category", + name: "lead", + category: "deep", + prompt: "implement the assigned work for lead", + subagent_type: "sisyphus", + }, + ], + } + + // when + const result = TeamSpecSchema.safeParse(teamSpec) + + // then + expect(result.success).toBe(false) + }) + + test("rejects members that omit the kind discriminator", () => { + // given + const teamSpec = { + ...createBaseTeamSpec(), + members: [{ name: "lead", category: "deep", prompt: "implement the assigned work for lead" }], + } + + // when + const result = TeamSpecSchema.safeParse(teamSpec) + + // then + expect(result.success).toBe(false) + }) + + test("rejects prometheus subagent members with the exact plan message", () => { + // given + const member: Member = { + kind: "subagent_type", + name: "planner", + subagent_type: "prometheus", + backendType: "in-process", + isActive: true, + } + + // when + const act = () => validateMemberEligibility(member) + + // then + expect(act).toThrow(PROMETHEUS_REJECTION_MESSAGE) + expect(act).toThrow(TeamSpecValidationError) + }) + + test("accepts hephaestus subagent members after the D-36 eligibility change", () => { + // given + const member: Member = { + kind: "subagent_type", + name: "craftsman", + subagent_type: "hephaestus", + backendType: "in-process", + isActive: true, + } + + // when + const act = () => validateMemberEligibility(member) + + // then + expect(act).not.toThrow() + }) + + test("rejects leadAgentId values that do not match a member name", () => { + // given + const teamSpec = { ...createBaseTeamSpec(), leadAgentId: "ghost" } + + // when + const act = () => validateSpec(teamSpec) + + // then + expect(act).toThrow("Team 'validator-team' leadAgentId 'ghost' must match exactly one member.name.") + }) + + test("rejects duplicate member names within a team", () => { + // given + const duplicateMember = createCategoryMember("lead") + const teamSpec = { ...createBaseTeamSpec(), members: [createCategoryMember("lead"), duplicateMember] } + + // when + const act = () => validateSpec(teamSpec) + + // then + expect(act).toThrow("Member name 'lead' is duplicated within team 'validator-team'. Member names must be unique.") + }) + + test("rejects teams that exceed the 8-member cap", () => { + // given + const teamSpec = { + ...createBaseTeamSpec(), + members: Array.from({ length: 9 }, (_, index) => createCategoryMember(`member-${index}`)), + leadAgentId: "member-0", + } + + // when + const act = () => validateSpec(teamSpec) + + // then + expect(act).toThrow("Team 'validator-team' exceeds max 8 members.") + }) + + test("rejects category prompts that collapse to empty text", () => { + // given + const member: Member = { + kind: "category", + name: "lead", + category: "deep", + prompt: " ", + backendType: "in-process", + isActive: true, + } + + // when + const act = () => validateDualSupport(member) + + // then + expect(act).toThrow("Member 'lead' prompt must not be empty after trimming whitespace.") + }) +}) diff --git a/src/features/team-mode/team-registry/validator.ts b/src/features/team-mode/team-registry/validator.ts new file mode 100644 index 000000000..ceea0a1f0 --- /dev/null +++ b/src/features/team-mode/team-registry/validator.ts @@ -0,0 +1,106 @@ +import { AGENT_ELIGIBILITY_REGISTRY } from "../types" + +import type { Member, TeamSpec } from "../types" + +const MAX_TEAM_MEMBERS = 8 +const UNKNOWN_SUBAGENT_MESSAGE = + "Unknown subagent_type ''. Available ELIGIBLE agents: sisyphus, atlas, sisyphus-junior, hephaestus (if D-36 applied). Use delegate-task for read-only agents like oracle, librarian, explore, metis, momus, multimodal-looker." + +export class TeamSpecValidationError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly field?: string, + public readonly memberName?: string, + ) { + super(message) + this.name = "TeamSpecValidationError" + } +} + +export function validateSpec(spec: TeamSpec): void { + if (spec.members.length > MAX_TEAM_MEMBERS) { + throw new TeamSpecValidationError( + `Team '${spec.name}' exceeds max 8 members.`, + "TEAM_MEMBER_LIMIT_EXCEEDED", + "members", + ) + } + + const seenMemberNames = new Set() + let leadMatchCount = 0 + + for (const member of spec.members) { + if (seenMemberNames.has(member.name)) { + throw new TeamSpecValidationError( + `Member name '${member.name}' is duplicated within team '${spec.name}'. Member names must be unique.`, + "DUPLICATE_MEMBER_NAME", + "members", + member.name, + ) + } + + seenMemberNames.add(member.name) + validateMemberEligibility(member) + validateDualSupport(member) + + if (member.name === spec.leadAgentId) { + leadMatchCount += 1 + } + } + + if (leadMatchCount !== 1) { + throw new TeamSpecValidationError( + `Team '${spec.name}' leadAgentId '${spec.leadAgentId}' must match exactly one member.name.`, + "INVALID_LEAD_AGENT_ID", + "leadAgentId", + ) + } +} + +export function validateMemberEligibility(member: Member): void { + if (member.kind !== "subagent_type") { + return + } + + const eligibility = AGENT_ELIGIBILITY_REGISTRY[member.subagent_type] + if (!eligibility) { + throw new TeamSpecValidationError( + UNKNOWN_SUBAGENT_MESSAGE.replace("", member.subagent_type), + "UNKNOWN_SUBAGENT_TYPE", + "subagent_type", + member.name, + ) + } + + if (eligibility.verdict === "hard-reject") { + throw new TeamSpecValidationError( + eligibility.rejectionMessage ?? `Agent '${member.subagent_type}' is not eligible as a team member.`, + "INELIGIBLE_AGENT", + "subagent_type", + member.name, + ) + } +} + +export function validateDualSupport(member: Member): void { + const trimmedPrompt = member.prompt?.trim() + + if (trimmedPrompt === "") { + throw new TeamSpecValidationError( + `Member '${member.name}' prompt must not be empty after trimming whitespace.`, + "EMPTY_PROMPT", + "prompt", + member.name, + ) + } + + if (member.kind === "category" && member.prompt.trim().length < 8) { + throw new TeamSpecValidationError( + `Member '${member.name}' category prompt must be at least 8 characters long.`, + "CATEGORY_PROMPT_TOO_SHORT", + "prompt", + member.name, + ) + } +}