feat(team-mode): add team registry loader with normalization tests
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||
import { resolveCallerTeamLead } from "../resolve-caller-team-lead"
|
||||
import { loadTeamSpec } from "./loader"
|
||||
|
||||
async function createTemporaryRoot(): Promise<string> {
|
||||
const directoryPath = path.join(tmpdir(), `team-mode-loader-${randomUUID()}`)
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
return directoryPath
|
||||
}
|
||||
|
||||
function getFixturePaths(rootDirectory: string, teamName: string) {
|
||||
const projectRoot = path.join(rootDirectory, "project")
|
||||
const userBaseDir = path.join(rootDirectory, "home", ".omo")
|
||||
|
||||
return {
|
||||
projectRoot,
|
||||
userBaseDir,
|
||||
userConfigPath: path.join(userBaseDir, "teams", teamName, "config.json"),
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
describe("loadTeamSpec member name normalization", () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||
await rm(directoryPath, { recursive: true, force: true })
|
||||
}))
|
||||
})
|
||||
|
||||
test("auto-assigns missing member names for specs on disk", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "autoname")
|
||||
await writeJsonFile(fixturePaths.userConfigPath, {
|
||||
name: "autoname",
|
||||
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
||||
members: [
|
||||
{ kind: "category", category: "quick", prompt: "Quick scout the workspace structure." },
|
||||
{ kind: "category", category: "deep", prompt: "Deep dive the runtime setup." },
|
||||
{ kind: "category", category: "deep", prompt: "Deep dive the mailbox implementation." },
|
||||
{ kind: "subagent_type", subagent_type: "atlas" },
|
||||
],
|
||||
})
|
||||
|
||||
// when
|
||||
const teamSpec = await loadTeamSpec("autoname", TeamModeConfigSchema.parse({ base_dir: fixturePaths.userBaseDir }), fixturePaths.projectRoot)
|
||||
|
||||
// then
|
||||
expect(teamSpec.leadAgentId).toBe("lead")
|
||||
expect(teamSpec.members.map((member) => member.name)).toEqual(["lead", "quick-1", "deep-1", "deep-2", "atlas-1"])
|
||||
})
|
||||
|
||||
test("injects the caller as lead for preset specs without explicit lead metadata", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "caller-lead")
|
||||
await writeJsonFile(fixturePaths.userConfigPath, {
|
||||
name: "caller-lead",
|
||||
members: [
|
||||
{ kind: "category", category: "quick", prompt: "Quick scout the workspace structure." },
|
||||
{ kind: "subagent_type", subagent_type: "atlas" },
|
||||
],
|
||||
})
|
||||
|
||||
// when
|
||||
const teamSpec = await loadTeamSpec(
|
||||
"caller-lead",
|
||||
TeamModeConfigSchema.parse({ base_dir: fixturePaths.userBaseDir }),
|
||||
fixturePaths.projectRoot,
|
||||
{ callerTeamLead: resolveCallerTeamLead("\u200BSisyphus - Ultraworker") },
|
||||
)
|
||||
|
||||
// then
|
||||
expect(teamSpec.leadAgentId).toBe("lead")
|
||||
expect(teamSpec.members.map((member) => member.name)).toEqual(["lead", "quick-1", "atlas-1"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||
|
||||
const ORACLE_REJECTION_MESSAGE =
|
||||
"Agent 'oracle' is read-only (cannot write files). Team members must write to mailbox inbox files. Use delegate-task with subagent_type: 'oracle' for read-only analysis instead."
|
||||
|
||||
const logCalls: Array<[string, unknown?]> = []
|
||||
|
||||
mock.module("../../../shared/logger", () => ({
|
||||
log: (message: string, data?: unknown) => {
|
||||
logCalls.push([message, data])
|
||||
},
|
||||
}))
|
||||
|
||||
const { TeamSpecValidationError, loadAllTeamSpecs, loadTeamSpec } = await import("./loader")
|
||||
|
||||
function createBaseSpec(teamName: string): {
|
||||
version: 1
|
||||
name: string
|
||||
description: string
|
||||
createdAt: number
|
||||
leadAgentId: string
|
||||
members: Array<Record<string, unknown>>
|
||||
} {
|
||||
return {
|
||||
version: 1,
|
||||
name: teamName,
|
||||
description: `${teamName} description`,
|
||||
createdAt: Date.now(),
|
||||
leadAgentId: "lead",
|
||||
members: [
|
||||
{ kind: "category", name: "lead", category: "deep", prompt: "implement the leader task" },
|
||||
{ kind: "category", name: "reviewer", category: "quick", prompt: "review the current output" },
|
||||
{ kind: "category", name: "tester", category: "deep", prompt: "verify the resulting behavior" },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function createTemporaryRoot(): Promise<string> {
|
||||
const directoryPath = path.join(tmpdir(), `team-mode-loader-${randomUUID()}`)
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
return directoryPath
|
||||
}
|
||||
|
||||
function getFixturePaths(rootDirectory: string, teamName: string) {
|
||||
const projectRoot = path.join(rootDirectory, "project")
|
||||
const userBaseDir = path.join(rootDirectory, "home", ".omo")
|
||||
|
||||
return {
|
||||
projectRoot,
|
||||
userBaseDir,
|
||||
projectConfigPath: path.join(projectRoot, ".omo", "teams", teamName, "config.json"),
|
||||
userConfigPath: path.join(userBaseDir, "teams", teamName, "config.json"),
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function createConfig(userBaseDir: string) {
|
||||
return TeamModeConfigSchema.parse({ base_dir: userBaseDir })
|
||||
}
|
||||
|
||||
describe("team-registry loader", () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
logCalls.splice(0)
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||
await rm(directoryPath, { recursive: true, force: true })
|
||||
}))
|
||||
})
|
||||
|
||||
test("loads and validates a valid 3-member team spec", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "alpha")
|
||||
await writeJsonFile(fixturePaths.userConfigPath, createBaseSpec("alpha"))
|
||||
|
||||
// when
|
||||
const teamSpec = await loadTeamSpec("alpha", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
|
||||
// then
|
||||
expect(teamSpec.name).toBe("alpha")
|
||||
expect(teamSpec.members).toHaveLength(3)
|
||||
expect(teamSpec.leadAgentId).toBe("lead")
|
||||
})
|
||||
|
||||
test("defaults version when omitted from stored specs", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "default-version")
|
||||
const { version: _version, ...teamSpecWithoutVersion } = createBaseSpec("default-version")
|
||||
await writeJsonFile(fixturePaths.userConfigPath, teamSpecWithoutVersion)
|
||||
|
||||
// when
|
||||
const teamSpec = await loadTeamSpec("default-version", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
|
||||
// then
|
||||
expect(teamSpec.version).toBe(1)
|
||||
})
|
||||
|
||||
test("defaults createdAt from Date.now when omitted from stored specs", async () => {
|
||||
// given
|
||||
const originalDateNow = Date.now
|
||||
Date.now = () => 222_333_444
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "default-created-at")
|
||||
const { createdAt: _createdAt, ...teamSpecWithoutCreatedAt } = createBaseSpec("default-created-at")
|
||||
await writeJsonFile(fixturePaths.userConfigPath, teamSpecWithoutCreatedAt)
|
||||
|
||||
try {
|
||||
// when
|
||||
const teamSpec = await loadTeamSpec("default-created-at", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
|
||||
// then
|
||||
expect(teamSpec.createdAt).toBe(222_333_444)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("derives leadAgentId and prepends lead shorthand to members", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "lead-shorthand")
|
||||
await writeJsonFile(fixturePaths.userConfigPath, {
|
||||
name: "lead-shorthand",
|
||||
description: "team with shorthand lead",
|
||||
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
||||
members: [
|
||||
{ kind: "category", name: "scout-1", category: "deep", prompt: "Scout the src directory for auth patterns." },
|
||||
{ kind: "category", name: "scout-2", category: "quick", prompt: "Scout tests for auth coverage." },
|
||||
],
|
||||
})
|
||||
|
||||
// when
|
||||
const teamSpec = await loadTeamSpec("lead-shorthand", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
|
||||
// then
|
||||
expect(teamSpec.leadAgentId).toBe("lead")
|
||||
expect(teamSpec.members).toHaveLength(3)
|
||||
expect(teamSpec.members[0]).toMatchObject({ kind: "subagent_type", name: "lead", subagent_type: "sisyphus" })
|
||||
})
|
||||
|
||||
test("derives leadAgentId from the only member when no lead hint exists", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "solo")
|
||||
await writeJsonFile(fixturePaths.userConfigPath, {
|
||||
name: "solo",
|
||||
members: [{ kind: "category", name: "solo-lead", category: "deep", prompt: "Implement the assigned work for the solo team." }],
|
||||
})
|
||||
|
||||
// when
|
||||
const teamSpec = await loadTeamSpec("solo", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
|
||||
// then
|
||||
expect(teamSpec.leadAgentId).toBe("solo-lead")
|
||||
expect(teamSpec.members).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("rejects multi-member specs without any lead indicator with a helpful message", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "missing-lead")
|
||||
await writeJsonFile(fixturePaths.userConfigPath, {
|
||||
name: "missing-lead",
|
||||
members: [
|
||||
{ kind: "category", name: "member-1", category: "deep", prompt: "Implement the assigned work for member one." },
|
||||
{ kind: "category", name: "member-2", category: "quick", prompt: "Review the assigned work for member one." },
|
||||
],
|
||||
})
|
||||
|
||||
// when
|
||||
let thrownError: unknown
|
||||
try {
|
||||
await loadTeamSpec("missing-lead", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
// then
|
||||
expect(thrownError).toMatchObject({
|
||||
name: TeamSpecValidationError.name,
|
||||
message: "Invalid team spec field 'leadAgentId': leadAgentId required (or write a `lead: {...}` field, or mark one member with `isLead: true`)",
|
||||
code: "INVALID_TEAM_SPEC",
|
||||
field: "leadAgentId",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects oracle subagent members with the exact plan message", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "oracle-team")
|
||||
const teamSpec = createBaseSpec("oracle-team")
|
||||
teamSpec.members = [{ kind: "subagent_type", name: "lead", subagent_type: "oracle" }]
|
||||
await writeJsonFile(fixturePaths.userConfigPath, teamSpec)
|
||||
|
||||
// when
|
||||
let thrownError: unknown
|
||||
try {
|
||||
await loadTeamSpec("oracle-team", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
// then
|
||||
expect(thrownError).toMatchObject({
|
||||
name: TeamSpecValidationError.name,
|
||||
message: ORACLE_REJECTION_MESSAGE,
|
||||
code: "INELIGIBLE_AGENT",
|
||||
field: "subagent_type",
|
||||
memberName: "lead",
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers the project-scoped team spec when both scopes define the same name", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "dup")
|
||||
const projectSpec = { ...createBaseSpec("dup"), description: "project-owned" }
|
||||
const userSpec = { ...createBaseSpec("dup"), description: "user-owned" }
|
||||
|
||||
await writeJsonFile(fixturePaths.projectConfigPath, projectSpec)
|
||||
await writeJsonFile(fixturePaths.userConfigPath, userSpec)
|
||||
|
||||
// when
|
||||
const teamSpec = await loadTeamSpec("dup", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
|
||||
// then
|
||||
expect(teamSpec.description).toBe("project-owned")
|
||||
expect(logCalls).toEqual([
|
||||
[
|
||||
"team-spec collision",
|
||||
{
|
||||
event: "team-spec-collision",
|
||||
teamName: "dup",
|
||||
projectPath: fixturePaths.projectConfigPath,
|
||||
userPath: fixturePaths.userConfigPath,
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("returns malformed team specs as data during load-all startup", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const goodFixturePaths = getFixturePaths(rootDirectory, "good")
|
||||
const badFixturePaths = getFixturePaths(rootDirectory, "broken")
|
||||
|
||||
await writeJsonFile(goodFixturePaths.userConfigPath, createBaseSpec("good"))
|
||||
await mkdir(path.dirname(badFixturePaths.userConfigPath), { recursive: true })
|
||||
await writeFile(badFixturePaths.userConfigPath, "{\n invalid json\n")
|
||||
|
||||
// when
|
||||
const results = await loadAllTeamSpecs(createConfig(goodFixturePaths.userBaseDir), goodFixturePaths.projectRoot)
|
||||
|
||||
// then
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "good", scope: "user", spec: expect.objectContaining({ name: "good" }) }),
|
||||
expect.objectContaining({
|
||||
name: "broken",
|
||||
scope: "user",
|
||||
error: expect.objectContaining({ name: TeamSpecValidationError.name, code: "INVALID_JSON" }),
|
||||
}),
|
||||
]))
|
||||
})
|
||||
|
||||
test("rejects specs with more than 8 members", async () => {
|
||||
// given
|
||||
const rootDirectory = await createTemporaryRoot()
|
||||
temporaryDirectories.push(rootDirectory)
|
||||
const fixturePaths = getFixturePaths(rootDirectory, "too-many")
|
||||
const teamSpec = createBaseSpec("too-many")
|
||||
teamSpec.members = Array.from({ length: 9 }, (_, index) => ({
|
||||
kind: "category",
|
||||
name: `member-${index}`,
|
||||
category: "deep",
|
||||
prompt: `implement task number ${index}`,
|
||||
}))
|
||||
teamSpec.leadAgentId = "member-0"
|
||||
await writeJsonFile(fixturePaths.userConfigPath, teamSpec)
|
||||
|
||||
// when
|
||||
let thrownError: unknown
|
||||
try {
|
||||
await loadTeamSpec("too-many", createConfig(fixturePaths.userBaseDir), fixturePaths.projectRoot)
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
// then
|
||||
expect(thrownError).toMatchObject({
|
||||
name: TeamSpecValidationError.name,
|
||||
message: "Team 'too-many' exceeds max 8 members.",
|
||||
code: "TEAM_MEMBER_LIMIT_EXCEEDED",
|
||||
field: "members",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
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<ReturnType<typeof discoverTeamSpecs>>[number]
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
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 : "<unknown>"
|
||||
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 : "<unknown>"
|
||||
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 : "<unknown>"
|
||||
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<TeamSpec> {
|
||||
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<TeamSpec> {
|
||||
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<Array<{ name: string; scope: "project" | "user"; spec?: TeamSpec; error?: Error }>> {
|
||||
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 }
|
||||
}
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user