diff --git a/src/features/team-mode/team-registry/loader-member-name-normalization.test.ts b/src/features/team-mode/team-registry/loader-member-name-normalization.test.ts new file mode 100644 index 000000000..369e34b82 --- /dev/null +++ b/src/features/team-mode/team-registry/loader-member-name-normalization.test.ts @@ -0,0 +1,93 @@ +/// + +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 { + 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 { + 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"]) + }) +}) diff --git a/src/features/team-mode/team-registry/loader.test.ts b/src/features/team-mode/team-registry/loader.test.ts new file mode 100644 index 000000000..cee6cb029 --- /dev/null +++ b/src/features/team-mode/team-registry/loader.test.ts @@ -0,0 +1,320 @@ +/// + +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> +} { + 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 { + 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 { + 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", + }) + }) +}) diff --git a/src/features/team-mode/team-registry/loader.ts b/src/features/team-mode/team-registry/loader.ts new file mode 100644 index 000000000..74e5510a5 --- /dev/null +++ b/src/features/team-mode/team-registry/loader.ts @@ -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>[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 } + } + })) +}