src/funnel/team.rs
//! **Team roster + role↔task-kind assignment** — the real-person half of the
//! funnel-prompt-planner keystone (spec §3).
//!
//! Deliberately impure/nornir-side (file I/O over `.nornir/team.toml`), unlike
//! the pure `Card`/`Lane`/`Event` vocabulary that lives in `funnel-core`: a
//! roster is workspace config, not a DAG primitive. `Role` here is a plain
//! nornir enum (NOT re-exported from `funnel-core`) so `Event::CardAssigned`'s
//! `role` field stays a free-form `String` and funnel-core never needs to know
//! nornir's role vocabulary — one fewer cross-repo coupling point.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
/// A team member's function. Drives `--assign auto`'s role↔task-kind mapping
/// (spec §3): `code:write`/`code:fix` → Coder, `test:write` → Tester,
/// architecture/decomposition parents (the mother idea) → Architect.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Architect,
Coder,
Reviewer,
Tester,
}
impl Role {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Role::Architect => "architect",
Role::Coder => "coder",
Role::Reviewer => "reviewer",
Role::Tester => "tester",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"architect" => Some(Role::Architect),
"coder" | "developer" | "dev" => Some(Role::Coder),
"reviewer" => Some(Role::Reviewer),
"tester" | "qa" => Some(Role::Tester),
_ => None,
}
}
/// The role `--assign auto` picks for a decomposed **task node**, from its
/// free-verb `node_kind` (spec §3): `test:*` → Tester, everything else
/// (`code:write`/`code:fix` and any other verb) → Coder. The DECOMPOSITION
/// PARENT (the mother idea) is handled separately — see [`Role::for_parent`]
/// — it is always Architect, regardless of node_kind.
#[must_use]
pub fn for_node_kind(kind: &str) -> Self {
if kind.starts_with("test:") {
Role::Tester
} else {
Role::Coder
}
}
/// The role a decomposition's PARENT/mother node is assigned under
/// (spec §3: "architecture/decomposition parents → an Architect").
#[must_use]
pub fn for_parent() -> Self {
Role::Architect
}
}
/// One team member: name, the role(s) they can be allocated for, and how many
/// concurrent cards they can carry (`--assign auto`'s capacity cap).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Member {
pub name: String,
pub roles: Vec<Role>,
#[serde(default = "default_capacity")]
pub capacity: u32,
}
fn default_capacity() -> u32 {
3
}
impl Member {
#[must_use]
pub fn can(&self, role: Role) -> bool {
self.roles.contains(&role)
}
}
/// The roster. Source: `.nornir/team.toml` (deterministic, testable, v1 per
/// spec §3) — `members = [[…]]` TOML tables, e.g.:
///
/// ```toml
/// [[members]]
/// name = "ada"
/// roles = ["architect", "coder"]
/// capacity = 4
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Team {
pub members: Vec<Member>,
}
impl Team {
/// Load from an explicit TOML file path.
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read team roster {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("parse team roster {}", path.display()))
}
/// Load `<workspace_root>/.nornir/team.toml`, falling back to
/// [`Team::sample`] when the file doesn't exist (so `--assign auto` always
/// has *someone* to allocate to, even in a repo that never ran the sample
/// setup). A present-but-unparsable file is a hard error (never a silent
/// fallback — a typo'd roster should be loud, not invisible).
pub fn load_default(workspace_root: &Path) -> Result<Self> {
let path = workspace_root.join(".nornir").join("team.toml");
if path.exists() {
Self::load(&path)
} else {
Ok(Self::sample())
}
}
/// The default sample roster (also shipped on disk as `.nornir/team.toml`):
/// one Architect, two Coders (uneven capacity so round-robin has something
/// to prove), one Tester, one Reviewer.
#[must_use]
pub fn sample() -> Self {
Team {
members: vec![
Member {
name: "ada".into(),
roles: vec![Role::Architect, Role::Coder],
capacity: 4,
},
Member {
name: "grace".into(),
roles: vec![Role::Coder],
capacity: 3,
},
Member {
name: "linus".into(),
roles: vec![Role::Coder],
capacity: 2,
},
Member {
name: "margaret".into(),
roles: vec![Role::Tester],
capacity: 3,
},
Member {
name: "alan".into(),
roles: vec![Role::Reviewer, Role::Architect],
capacity: 3,
},
],
}
}
/// Deterministically allocate ONE member for `role`, given the current
/// in-flight `load` (card count already assigned to each member THIS run —
/// the caller owns and mutates this across a whole `prompt --assign auto`
/// batch so round-robin spreads across subtasks). Prefers a member who
/// both (a) has `role` and (b) is under `capacity`; ties break on the
/// LOWEST current load, then lexicographically on `name` (spec §3:
/// "deterministic tie-break"). Falls back to ignoring the capacity cap
/// (still role-filtered + deterministically tie-broken) when every
/// eligible member is already at capacity, so allocation never fails as
/// long as at least one member can fill the role.
#[must_use]
pub fn allocate(&self, role: Role, load: &mut BTreeMap<String, u32>) -> Option<String> {
let eligible: Vec<&Member> = self.members.iter().filter(|m| m.can(role)).collect();
if eligible.is_empty() {
return None;
}
let under_cap: Vec<&Member> = eligible
.iter()
.filter(|m| load.get(m.name.as_str()).copied().unwrap_or(0) < m.capacity)
.copied()
.collect();
let pool = if under_cap.is_empty() {
&eligible
} else {
&under_cap
};
let picked = pool
.iter()
.min_by_key(|m| {
(
load.get(m.name.as_str()).copied().unwrap_or(0),
m.name.clone(),
)
})
.copied()?;
*load.entry(picked.name.clone()).or_insert(0) += 1;
Some(picked.name.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_round_trips_str_and_kind_mapping() {
for r in [Role::Architect, Role::Coder, Role::Reviewer, Role::Tester] {
assert_eq!(Role::parse(r.as_str()), Some(r));
}
assert_eq!(Role::for_node_kind("code:write"), Role::Coder);
assert_eq!(Role::for_node_kind("code:fix"), Role::Coder);
assert_eq!(Role::for_node_kind("test:write"), Role::Tester);
assert_eq!(Role::for_parent(), Role::Architect);
}
#[test]
fn sample_roster_parses_and_has_every_role() {
let team = Team::sample();
for r in [Role::Architect, Role::Coder, Role::Reviewer, Role::Tester] {
assert!(
team.members.iter().any(|m| m.can(r)),
"sample roster covers {r:?}"
);
}
}
/// Round-robin within a role: 5 Coder-role allocations spread across the
/// two Coder-capable members roughly by capacity, deterministically — a
/// SECOND run with a fresh `load` map produces the IDENTICAL sequence.
#[test]
fn allocate_round_robins_deterministically_within_role() {
let team = Team::sample();
let run = || {
let mut load = BTreeMap::new();
(0..5)
.map(|_| team.allocate(Role::Coder, &mut load).unwrap())
.collect::<Vec<_>>()
};
let a = run();
let b = run();
assert_eq!(a, b, "same inputs -> byte-identical allocation sequence");
// ada (cap 4) and grace (cap 3) and linus (cap 2) are the 3 Coders;
// lowest-load-then-name tie-break means the very first pick is
// whichever name sorts first among the (load=0) trio: "ada".
assert_eq!(a[0], "ada");
}
/// Capacity is respected while headroom exists: a member never receives a
/// second card while a same-role peer is still under capacity.
#[test]
fn allocate_respects_capacity_before_overflowing() {
let team = Team {
members: vec![
Member {
name: "low".into(),
roles: vec![Role::Coder],
capacity: 1,
},
Member {
name: "high".into(),
roles: vec![Role::Coder],
capacity: 5,
},
],
};
let mut load = BTreeMap::new();
let picks: Vec<String> = (0..3)
.map(|_| team.allocate(Role::Coder, &mut load).unwrap())
.collect();
// The capacity invariant, asserted order-independently: "low" (cap 1)
// may be filled AT MOST once, and every remaining card overflows to
// "high" (cap 5). (Exact sequence here is [high, low, high] — "high"
// sorts BEFORE "low", so the first zero-load tie-break picks it — but
// that's incidental; what matters is nobody exceeds capacity while a
// peer has headroom.)
assert_eq!(
picks.iter().filter(|p| p.as_str() == "low").count(),
1,
"low (cap 1) filled exactly once"
);
assert_eq!(
picks.iter().filter(|p| p.as_str() == "high").count(),
2,
"high (cap 5) absorbs the overflow"
);
}
/// When every eligible member is at capacity, allocation still succeeds
/// (overflow, not a hard failure) and stays deterministic.
#[test]
fn allocate_overflows_deterministically_when_all_at_capacity() {
let team = Team {
members: vec![Member {
name: "solo".into(),
roles: vec![Role::Tester],
capacity: 1,
}],
};
let mut load = BTreeMap::new();
assert_eq!(
team.allocate(Role::Tester, &mut load),
Some("solo".to_string())
);
// Over capacity now, but still the only Tester -> still returns it.
assert_eq!(
team.allocate(Role::Tester, &mut load),
Some("solo".to_string())
);
}
/// No member has the role -> None (never a wrong-role assignment).
#[test]
fn allocate_returns_none_for_uncovered_role() {
let team = Team {
members: vec![Member {
name: "solo".into(),
roles: vec![Role::Coder],
capacity: 3,
}],
};
let mut load = BTreeMap::new();
assert_eq!(team.allocate(Role::Tester, &mut load), None);
}
#[test]
fn load_default_falls_back_to_sample_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let team = Team::load_default(dir.path()).unwrap();
assert_eq!(team.members.len(), Team::sample().members.len());
}
#[test]
fn load_default_reads_the_toml_file_when_present() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".nornir")).unwrap();
std::fs::write(
dir.path().join(".nornir").join("team.toml"),
"[[members]]\nname = \"solo\"\nroles = [\"coder\"]\ncapacity = 9\n",
)
.unwrap();
let team = Team::load_default(dir.path()).unwrap();
assert_eq!(team.members.len(), 1);
assert_eq!(team.members[0].name, "solo");
assert_eq!(team.members[0].capacity, 9);
}
}