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/<name>.md` by convention, where
`<name>` 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