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 }
+ }
+ }))
+}