feat(team-mode): add team registry validator with tests

This commit is contained in:
YeonGyu-Kim
2026-04-28 10:45:51 +09:00
parent cf050dc63a
commit 2866d3276e
2 changed files with 272 additions and 0 deletions
@@ -0,0 +1,166 @@
/// <reference types="bun-types" />
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.")
})
})
@@ -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 '<name>'. 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<string>()
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("<name>", 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,
)
}
}