defmodule Planck.Agent.Team do @moduledoc """ A named collection of agents that share a `team_id` and can address each other via the inter-agent tools. Teams can be hydrated from a directory on disk (static) or constructed in-memory by an orchestrator using `spawn_agent` (dynamic). Both paths produce the same struct. ## Directory layout .planck/teams/ elixir-dev-workflow/ TEAM.json # required — member list and metadata members/ orchestrator.md # system prompt for the orchestrator planner.md builder.md Each member's system prompt lives in `members/.md` by convention, where `` is the member's `name` (which defaults to `type` when not set). ## TEAM.json format { "name": "elixir-dev-workflow", "description": "Plan, build, and test Elixir changes.", "members": [ { "type": "orchestrator", "provider": "anthropic", "model_id": "claude-sonnet-4-6", "system_prompt": "members/orchestrator.md" }, { "type": "builder", "provider": "anthropic", "model_id": "claude-sonnet-4-6", "system_prompt": "members/builder.md", "tools": ["read", "write", "edit", "bash"], "skills": ["refactor"] } ] } Member entries follow the schema documented on `Planck.Agent.AgentSpec`. Exactly one member must have `"type": "orchestrator"`; the rest are workers. All `system_prompt` file paths are resolved relative to the team directory. Tools and skills are global (loaded by `planck_headless` from `~/.planck/tools` and `~/.planck/skills`). Each member declares which of them it should see via the `"tools"` and `"skills"` arrays; names are resolved against the global pool at agent-start time. """ require Logger alias Planck.Agent.AgentSpec @team_file "TEAM.json" @orchestrator_type "orchestrator" @typedoc """ A team definition. - `:id` — team_id generated at materialization; `nil` before `start_session`. - `:alias` — folder name for static teams; `nil` for dynamic teams. - `:source` — `:filesystem` or `:dynamic`. - `:name` — informational label from TEAM.json. - `:description` — one-line purpose shown in team listings. - `:dir` — absolute path to the team directory, `nil` for dynamic teams. - `:members` — agent specs; exactly one has `type: "orchestrator"`. """ @type t :: %__MODULE__{ id: String.t() | nil, alias: String.t() | nil, source: :filesystem | :dynamic, name: String.t() | nil, description: String.t() | nil, dir: Path.t() | nil, members: [AgentSpec.t()] } @enforce_keys [:source, :members] defstruct id: nil, alias: nil, source: :filesystem, name: nil, description: nil, dir: nil, members: [] @doc """ Load a team from a directory containing a `TEAM.json` file. Resolves member `system_prompt` paths relative to the team directory. Returns `{:ok, team}` or `{:error, reason}`. """ @spec load(Path.t()) :: {:ok, t()} | {:error, String.t()} def load(dir) do expanded = Path.expand(dir) team_file = Path.join(expanded, @team_file) with :ok <- ensure_dir(expanded), {:ok, content} <- read_team_file(team_file), {:ok, data} <- decode_json(content, team_file), {:ok, members_data, name, description} <- parse_team(data, team_file), {:ok, members} <- parse_members(members_data, expanded), :ok <- validate_members(members, team_file) do {:ok, %__MODULE__{ alias: Path.basename(expanded), source: :filesystem, name: name, description: description, dir: expanded, members: members }} end end @doc """ Build a dynamic team from a single orchestrator spec. Dynamic teams have no filesystem footprint. Workers may be added later via the orchestrator's `spawn_agent` tool. Used both when `start_session/1` runs with no `template:` (team of one) and when the orchestrator grows the team at runtime. """ @spec dynamic(AgentSpec.t()) :: t() def dynamic(%AgentSpec{type: @orchestrator_type} = orchestrator) do %__MODULE__{source: :dynamic, members: [orchestrator]} end # --------------------------------------------------------------------------- # Private # --------------------------------------------------------------------------- @spec ensure_dir(Path.t()) :: :ok | {:error, String.t()} defp ensure_dir(dir) do if File.dir?(dir), do: :ok, else: {:error, "team directory not found: #{dir}"} end @spec read_team_file(Path.t()) :: {:ok, String.t()} | {:error, String.t()} defp read_team_file(path) do case File.read(path) do {:ok, content} -> {:ok, content} {:error, reason} -> {:error, "cannot read #{path}: #{:file.format_error(reason)}"} end end @spec decode_json(String.t(), Path.t()) :: {:ok, term()} | {:error, String.t()} defp decode_json(content, path) do case Jason.decode(content) do {:ok, data} -> {:ok, data} {:error, %Jason.DecodeError{} = e} -> {:error, "invalid JSON in #{path}: #{Exception.message(e)}"} end end @spec parse_team(term(), Path.t()) :: {:ok, [map()], String.t() | nil, String.t() | nil} | {:error, String.t()} defp parse_team(%{"members" => members} = data, _path) when is_list(members) and members != [] do {:ok, members, string_or_nil(data["name"]), string_or_nil(data["description"])} end defp parse_team(%{"members" => []}, path), do: {:error, "#{path}: members must not be empty"} defp parse_team(%{"members" => _}, path), do: {:error, "#{path}: members must be an array"} defp parse_team(_, path), do: {:error, "#{path}: missing required field 'members'"} @spec string_or_nil(term()) :: String.t() | nil defp string_or_nil(v) when is_binary(v) and v != "", do: v defp string_or_nil(_), do: nil @spec parse_members([map()], Path.t()) :: {:ok, [AgentSpec.t()]} | {:error, String.t()} defp parse_members(entries, base_dir) do case AgentSpec.from_list(entries, base_dir: base_dir) do [] -> {:error, "no valid members in TEAM.json at #{base_dir}"} specs -> {:ok, specs} end end @spec validate_members([AgentSpec.t()], Path.t()) :: :ok | {:error, String.t()} defp validate_members(members, path) do with :ok <- validate_single_orchestrator(members, path) do validate_unique_names(members, path) end end @spec validate_single_orchestrator([AgentSpec.t()], Path.t()) :: :ok | {:error, String.t()} defp validate_single_orchestrator(members, path) do case Enum.count(members, &(&1.type == @orchestrator_type)) do 1 -> :ok 0 -> {:error, "#{path}: team must have exactly one member with type \"orchestrator\""} n -> {:error, "#{path}: team must have exactly one orchestrator, found #{n}"} end end @spec validate_unique_names([AgentSpec.t()], Path.t()) :: :ok | {:error, String.t()} defp validate_unique_names(members, path) do names = Enum.map(members, & &1.name) duplicates = names -- Enum.uniq(names) case duplicates do [] -> :ok [dup | _] -> {:error, "#{path}: duplicate member name #{inspect(dup)} " <> "(names default to type; give explicit names to disambiguate same-type members)"} end end end