import { readFile } from "node:fs/promises" import { ZodError } from "zod" import type { TeamModeConfig } from "../../../config/schema/team-mode" import { log } from "../../../shared/logger" import type { NormalizeTeamSpecInputOptions } from "./team-spec-input-normalizer" import { TeamSpecSchema } from "../types" import type { TeamSpec } from "../types" import { normalizeTeamSpecInput } from "./team-spec-input-normalizer" import { discoverTeamSpecs, getTeamSpecPath, resolveBaseDir } from "./paths" import { TeamSpecValidationError, validateSpec } from "./validator" type DiscoveredTeamSpec = Awaited>[number] type JsonRecord = Record function isJsonRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value) } function normalizeError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } function createSpecialCaseValidationError(rawSpec: unknown): TeamSpecValidationError | undefined { if (!isJsonRecord(rawSpec)) { return undefined } const rawMembers = rawSpec.members if (!Array.isArray(rawMembers)) { return undefined } if (rawMembers.length > 8) { const teamName = typeof rawSpec.name === "string" ? rawSpec.name : "" return new TeamSpecValidationError( `Team '${teamName}' exceeds max 8 members.`, "TEAM_MEMBER_LIMIT_EXCEEDED", "members", ) } for (const rawMember of rawMembers) { if (!isJsonRecord(rawMember)) { continue } const memberName = typeof rawMember.name === "string" ? rawMember.name : "" const hasKind = Object.hasOwn(rawMember, "kind") const hasCategory = Object.hasOwn(rawMember, "category") const hasSubagentType = Object.hasOwn(rawMember, "subagent_type") if (hasCategory && hasSubagentType) { return new TeamSpecValidationError( `Member '${memberName}' specifies both 'category' and 'subagent_type'. Must specify exactly one via 'kind' discriminator.`, "AMBIGUOUS_MEMBER_KIND", "kind", memberName, ) } if (!hasKind) { return new TeamSpecValidationError( `Member '${memberName}' missing 'kind' discriminator. Specify either {kind:'category', category, prompt} or {kind:'subagent_type', subagent_type}.`, "MISSING_MEMBER_KIND", "kind", memberName, ) } if (rawMember.kind === "category" && !Object.hasOwn(rawMember, "prompt")) { const category = typeof rawMember.category === "string" ? rawMember.category : "" return new TeamSpecValidationError( `Member '${memberName}' uses category '${category}' but is missing required 'prompt' field. Category members must supply a task prompt.`, "MISSING_CATEGORY_PROMPT", "prompt", memberName, ) } } return undefined } function createZodValidationError(rawSpec: unknown, error: ZodError): TeamSpecValidationError { const specialCaseError = createSpecialCaseValidationError(rawSpec) if (specialCaseError) { return specialCaseError } const firstIssue = error.issues[0] const field = firstIssue?.path.join(".") || undefined const message = field ? `Invalid team spec field '${field}': ${firstIssue.message}` : `Invalid team spec: ${error.message}` return new TeamSpecValidationError(message, "INVALID_TEAM_SPEC", field) } async function loadTeamSpecFromEntry( entry: DiscoveredTeamSpec, options?: NormalizeTeamSpecInputOptions, ): Promise { let rawText: string try { rawText = await readFile(entry.path, "utf8") } catch (error) { const normalizedError = normalizeError(error) throw new TeamSpecValidationError( `Failed to read team spec '${entry.name}': ${normalizedError.message}`, "TEAM_SPEC_READ_FAILED", ) } let rawSpec: unknown try { rawSpec = JSON.parse(rawText) } catch (error) { const normalizedError = normalizeError(error) throw new TeamSpecValidationError( `Failed to parse team spec '${entry.name}' JSON: ${normalizedError.message}`, "INVALID_JSON", ) } const normalizedRawSpec = normalizeTeamSpecInput(rawSpec, options) const parsedSpec = TeamSpecSchema.safeParse(normalizedRawSpec) if (!parsedSpec.success) { throw createZodValidationError(normalizedRawSpec, parsedSpec.error) } validateSpec(parsedSpec.data) return parsedSpec.data } export { TeamSpecValidationError } from "./validator" export { normalizeTeamSpecInput } from "./team-spec-input-normalizer" export async function loadTeamSpec( teamName: string, config: TeamModeConfig, projectRoot: string, options?: NormalizeTeamSpecInputOptions, ): Promise { const discoveredTeamSpecs = await discoverTeamSpecs(config, projectRoot) const matchedTeamSpec = discoveredTeamSpecs.find((entry) => entry.name === teamName) if (!matchedTeamSpec) { const baseDir = resolveBaseDir(config) const projectSpecPath = getTeamSpecPath(baseDir, teamName, "project", projectRoot) const userSpecPath = getTeamSpecPath(baseDir, teamName, "user") throw new TeamSpecValidationError( `Team '${teamName}' was not found. Expected '${projectSpecPath}' or '${userSpecPath}'.`, "TEAM_SPEC_NOT_FOUND", "name", ) } return loadTeamSpecFromEntry(matchedTeamSpec, options) } export async function loadAllTeamSpecs( config: TeamModeConfig, projectRoot: string, ): Promise> { const discoveredTeamSpecs = await discoverTeamSpecs(config, projectRoot) return Promise.all(discoveredTeamSpecs.map(async (entry) => { try { const spec = await loadTeamSpecFromEntry(entry) return { name: entry.name, scope: entry.scope, spec } } catch (error) { const normalizedError = normalizeError(error) log("team-spec load failed", { event: "team-spec-load-failed", teamName: entry.name, scope: entry.scope, path: entry.path, error: normalizedError.message, }) return { name: entry.name, scope: entry.scope, error: normalizedError } } })) }