diff --git a/src/features/team-mode/team-registry/team-spec-input-normalizer.test.ts b/src/features/team-mode/team-registry/team-spec-input-normalizer.test.ts
new file mode 100644
index 000000000..d14e266d1
--- /dev/null
+++ b/src/features/team-mode/team-registry/team-spec-input-normalizer.test.ts
@@ -0,0 +1,144 @@
+///
+
+import { describe, expect, test } from "bun:test"
+
+import { resolveCallerTeamLead } from "../resolve-caller-team-lead"
+import { normalizeTeamSpecInput } from "./team-spec-input-normalizer"
+
+describe("normalizeTeamSpecInput", () => {
+ test("injects the caller as lead when no lead is specified", () => {
+ // given
+ const rawSpec = {
+ name: "alpha-team",
+ members: [{ kind: "category", category: "quick", prompt: "Inspect the workspace" }],
+ }
+
+ // when
+ const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
+ callerTeamLead: resolveCallerTeamLead("\u200BSisyphus - Ultraworker"),
+ })
+
+ // then
+ expect(normalizedSpec).toMatchObject({
+ leadAgentId: "lead",
+ members: [
+ { name: "lead", kind: "subagent_type", subagent_type: "sisyphus" },
+ { name: "quick-1", kind: "category", category: "quick" },
+ ],
+ })
+ })
+
+ test("keeps an explicit leadAgentId unchanged when the caller is eligible", () => {
+ // given
+ const rawSpec = {
+ name: "alpha-team",
+ leadAgentId: "captain",
+ members: [
+ { kind: "subagent_type", name: "captain", subagent_type: "atlas" },
+ { kind: "category", name: "member-1", category: "quick", prompt: "Inspect the workspace" },
+ ],
+ }
+
+ // when
+ const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
+ callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
+ })
+
+ // then
+ expect(normalizedSpec).toEqual(rawSpec)
+ })
+
+ test("prefers isLead over the caller when both are present", () => {
+ // given
+ const rawSpec = {
+ name: "alpha-team",
+ members: [
+ { kind: "subagent_type", name: "captain", subagent_type: "atlas", isLead: true },
+ { kind: "category", category: "quick", prompt: "Inspect the workspace" },
+ ],
+ }
+
+ // when
+ const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
+ callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
+ })
+
+ // then
+ expect(normalizedSpec).toMatchObject({
+ leadAgentId: "captain",
+ members: [
+ { kind: "subagent_type", name: "captain", subagent_type: "atlas" },
+ { kind: "category", name: "quick-1", category: "quick" },
+ ],
+ })
+ })
+
+ test("throws a clear error when the caller is not eligible and no lead is specified", () => {
+ // given
+ const rawSpec = {
+ name: "alpha-team",
+ members: [{ kind: "category", category: "quick", prompt: "Inspect the workspace" }],
+ }
+
+ // when
+ const result = () => normalizeTeamSpecInput(rawSpec, {
+ callerTeamLead: resolveCallerTeamLead("explore"),
+ })
+
+ // then
+ expect(result).toThrow("Caller agent explore is not eligible as team lead; specify leadAgentId explicitly")
+ })
+
+ test("normalizes natural inline names to schema-safe names", () => {
+ // given
+ const rawSpec = {
+ name: "Project Analysis Team",
+ leadAgentId: "Agent Lead",
+ members: [
+ { kind: "category", name: "Agent Lead", category: "quick", prompt: "Lead the analysis work" },
+ { kind: "category", name: "Agent 1: Structure Analyst", category: "quick", prompt: "Inspect the workspace" },
+ { kind: "category", name: "Agent 1 Structure Analyst", category: "quick", prompt: "Inspect related tests" },
+ ],
+ }
+
+ // when
+ const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
+ callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
+ })
+
+ // then
+ expect(normalizedSpec).toMatchObject({
+ name: "project-analysis-team",
+ leadAgentId: "agent-lead",
+ members: [
+ { name: "agent-lead" },
+ { name: "agent-1-structure-analyst" },
+ { name: "agent-1-structure-analyst-2" },
+ ],
+ })
+ })
+
+ test("uses the provided default category for role-only natural members", () => {
+ // given
+ const rawSpec = {
+ name: "analysis-team",
+ members: [
+ { name: "Structure Analyst", role: "Structure Analyst", capabilities: ["structure", "modules"] },
+ ],
+ }
+
+ // when
+ const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
+ callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
+ defaultCategoryName: "analysis",
+ })
+
+ // then
+ expect(normalizedSpec).toMatchObject({
+ members: [
+ { name: "lead", kind: "subagent_type" },
+ { name: "structure-analyst", kind: "category", category: "analysis", prompt: "Role: Structure Analyst\nstructure, modules" },
+ ],
+ })
+ })
+})
diff --git a/src/features/team-mode/team-registry/team-spec-input-normalizer.ts b/src/features/team-mode/team-registry/team-spec-input-normalizer.ts
new file mode 100644
index 000000000..3856c7ead
--- /dev/null
+++ b/src/features/team-mode/team-registry/team-spec-input-normalizer.ts
@@ -0,0 +1,254 @@
+import type { CallerTeamLead } from "../resolve-caller-team-lead"
+
+type JsonRecord = Record
+
+export type NormalizeTeamSpecInputOptions = {
+ callerTeamLead?: CallerTeamLead
+ defaultCategoryName?: string
+}
+
+function isJsonRecord(value: unknown): value is JsonRecord {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function cloneJsonRecord(value: JsonRecord): JsonRecord {
+ return { ...value }
+}
+
+function getMemberName(value: unknown): string | undefined {
+ return isJsonRecord(value) && typeof value.name === "string" ? value.name : undefined
+}
+
+function normalizeNameStem(value: string): string {
+ const normalizedStem = value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+
+ return normalizedStem.length > 0 ? normalizedStem : "member"
+}
+
+function deriveMemberNameStem(member: JsonRecord): string {
+ if (member.kind === "category" && typeof member.category === "string") {
+ return normalizeNameStem(member.category)
+ }
+
+ if (member.kind === "subagent_type" && typeof member.subagent_type === "string") {
+ return normalizeNameStem(member.subagent_type)
+ }
+
+ return "member"
+}
+
+function assignGeneratedMemberNames(rawMembers: unknown[]): unknown[] {
+ const usedNames = new Set()
+
+ return rawMembers.map((member) => {
+ if (!isJsonRecord(member)) {
+ return member
+ }
+
+ const rawName = getMemberName(member)
+ const stem = rawName === undefined ? deriveMemberNameStem(member) : normalizeNameStem(rawName)
+ let generatedName = rawName === undefined ? `${stem}-1` : stem
+ let suffix = rawName === undefined ? 1 : 2
+ while (usedNames.has(generatedName)) {
+ generatedName = `${stem}-${suffix}`
+ suffix += 1
+ }
+
+ usedNames.add(generatedName)
+ return { ...member, name: generatedName }
+ })
+}
+
+function stripMemberLeadFlag(value: unknown): unknown {
+ if (!isJsonRecord(value) || !Object.hasOwn(value, "isLead")) {
+ return value
+ }
+
+ const { isLead: _isLead, ...memberWithoutLeadFlag } = value
+ return memberWithoutLeadFlag
+}
+
+function hasMemberLeadFlag(rawMembers: unknown[]): boolean {
+ return rawMembers.some((member) => isJsonRecord(member) && member.isLead === true)
+}
+
+function createCallerLeadMember(callerAgentTypeId: string): JsonRecord {
+ return {
+ name: "lead",
+ kind: "subagent_type",
+ subagent_type: callerAgentTypeId,
+ }
+}
+
+function getPromptAlias(member: JsonRecord): string | undefined {
+ if (typeof member.prompt === "string") {
+ return member.prompt
+ }
+
+ if (typeof member.systemPrompt === "string") {
+ return member.systemPrompt
+ }
+
+ if (typeof member.system_prompt === "string") {
+ return member.system_prompt
+ }
+
+ return undefined
+}
+
+function formatStringArray(value: unknown): string | undefined {
+ if (!Array.isArray(value)) {
+ return undefined
+ }
+
+ const strings = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
+ return strings.length > 0 ? strings.join(", ") : undefined
+}
+
+function buildPromptFromNaturalMember(member: JsonRecord): string {
+ const promptAlias = getPromptAlias(member)
+ if (promptAlias !== undefined) {
+ return promptAlias
+ }
+
+ const promptParts = [
+ typeof member.role === "string" ? `Role: ${member.role}` : undefined,
+ typeof member.description === "string" ? member.description : undefined,
+ formatStringArray(member.capabilities),
+ formatStringArray(member.responsibilities),
+ ].filter((part): part is string => part !== undefined && part.trim().length > 0)
+
+ return promptParts.length > 0
+ ? promptParts.join("\n")
+ : "Work on the assigned team task and report findings to the lead."
+}
+
+function normalizeInlineMember(member: JsonRecord, options?: NormalizeTeamSpecInputOptions): JsonRecord {
+ const {
+ capabilities: _capabilities,
+ description: _description,
+ loadSkills: _loadSkills,
+ load_skills: _loadSkillsSnakeCase,
+ responsibilities: _responsibilities,
+ role: _role,
+ systemPrompt: _systemPrompt,
+ system_prompt: _systemPromptSnakeCase,
+ ...normalizedMember
+ } = member
+
+ const rawKind = normalizedMember.kind
+
+ if (normalizedMember.kind === undefined) {
+ if (typeof normalizedMember.category === "string") {
+ normalizedMember.kind = "category"
+ } else if (typeof normalizedMember.subagent_type === "string") {
+ normalizedMember.kind = "subagent_type"
+ } else if (options?.defaultCategoryName !== undefined) {
+ normalizedMember.kind = "category"
+ normalizedMember.category = options.defaultCategoryName
+ }
+ } else if (normalizedMember.kind !== "category" && normalizedMember.kind !== "subagent_type") {
+ if (typeof normalizedMember.category === "string") {
+ normalizedMember.kind = "category"
+ } else if (typeof normalizedMember.subagent_type === "string") {
+ normalizedMember.kind = "subagent_type"
+ } else if (typeof rawKind === "string" && rawKind !== "agent" && rawKind !== "member" && rawKind !== "worker" && rawKind !== "analyst") {
+ normalizedMember.kind = "category"
+ normalizedMember.category = rawKind
+ } else if (options?.defaultCategoryName !== undefined) {
+ normalizedMember.kind = "category"
+ normalizedMember.category = options.defaultCategoryName
+ }
+ }
+
+ if (normalizedMember.kind === "category" && normalizedMember.prompt === undefined) {
+ normalizedMember.prompt = buildPromptFromNaturalMember(member)
+ }
+
+ return normalizedMember
+}
+
+export function normalizeTeamSpecInput(raw: unknown, options?: NormalizeTeamSpecInputOptions): unknown {
+ if (!isJsonRecord(raw)) {
+ return raw
+ }
+
+ const normalizedSpec = cloneJsonRecord(raw)
+ if (typeof normalizedSpec.name === "string") {
+ normalizedSpec.name = normalizeNameStem(normalizedSpec.name)
+ }
+
+ const rawMembers = raw.members
+ const rawLead = raw.lead
+ let leadAgentId = typeof raw.leadAgentId === "string" ? raw.leadAgentId : undefined
+ const hasExplicitLead = leadAgentId !== undefined
+ || isJsonRecord(rawLead)
+ || (Array.isArray(rawMembers) && hasMemberLeadFlag(rawMembers))
+
+ if (Array.isArray(rawMembers)) {
+ let normalizedMembers = rawMembers.map((member) => isJsonRecord(member) ? normalizeInlineMember(member, options) : member)
+
+ if (isJsonRecord(rawLead)) {
+ const leadMember = normalizeInlineMember(rawLead, options)
+ if (leadMember.name === undefined) {
+ leadMember.name = "lead"
+ }
+
+ const leadName = getMemberName(leadMember)
+ const alreadyPresent = leadName !== undefined && normalizedMembers.some((member) => getMemberName(member) === leadName)
+ if (!alreadyPresent) {
+ normalizedMembers = [leadMember, ...normalizedMembers]
+ }
+
+ if (leadAgentId === undefined && leadName !== undefined) {
+ leadAgentId = leadName
+ }
+ }
+
+ if (!hasExplicitLead) {
+ const callerTeamLead = options?.callerTeamLead
+ if (callerTeamLead?.isEligibleForTeamLead && callerTeamLead.agentTypeId !== undefined) {
+ normalizedMembers = [createCallerLeadMember(callerTeamLead.agentTypeId), ...normalizedMembers]
+ leadAgentId = "lead"
+ } else if (callerTeamLead?.displayName !== undefined) {
+ throw new Error(`Caller agent ${callerTeamLead.displayName} is not eligible as team lead; specify leadAgentId explicitly`)
+ }
+ }
+
+ normalizedMembers = assignGeneratedMemberNames(normalizedMembers)
+
+ normalizedMembers = normalizedMembers.map((member) => {
+ const memberName = getMemberName(member)
+ const isLead = isJsonRecord(member) && member.isLead === true
+ if (leadAgentId === undefined && isLead && memberName !== undefined) {
+ leadAgentId = memberName
+ }
+ return stripMemberLeadFlag(member)
+ })
+
+ if (leadAgentId !== undefined && !normalizedMembers.some((member) => getMemberName(member) === leadAgentId)) {
+ const normalizedLeadAgentId = normalizeNameStem(leadAgentId)
+ if (normalizedMembers.some((member) => getMemberName(member) === normalizedLeadAgentId)) {
+ leadAgentId = normalizedLeadAgentId
+ }
+ }
+
+ if (leadAgentId === undefined && normalizedMembers.length === 1) {
+ leadAgentId = getMemberName(normalizedMembers[0])
+ }
+
+ normalizedSpec.members = normalizedMembers
+ }
+
+ if (leadAgentId !== undefined) {
+ normalizedSpec.leadAgentId = leadAgentId
+ }
+
+ delete normalizedSpec.lead
+
+ return normalizedSpec
+}