//! The barrier round: gather due people → dispatch LLM jobs → freeze →
//! collect every result → apply all actions → advance sim clock.
//!
//! Day cycle: 09:00 standup (everyone woken, manager assigns) → work blocks
//! → 12:00 lunch → 17:00 next day.
use bevy::prelude::*;
use std::collections::HashSet;
use crate::company::{CompanyCfg, Workspace, WriteKind};
use crate::llm::{
system_prompt, write_prompts, Action, DecisionResult, Job, JobResult, LlmPool,
};
use crate::people::{BossNotes, Bubble, Memory, Names, Person, Walking};
use crate::scene::{flat, Spots};
use crate::sim::*;
use crate::tasks::{find_person_name, TaskBoard, TaskStatus};
/// Resolve a person reference from the model: "Manager"/"boss" maps to the
/// manager, otherwise fuzzy name matching.
/// "boss"/"you"/"user" → the human behind the keyboard.
fn resolve_target(cfg: &CompanyCfg, given: &str) -> Option<String> {
let low = given.trim().to_lowercase();
if low == "boss" || low == "user" || low == "me (the boss)" || low == "the boss" {
return Some("__boss__".into());
}
if let Some(hit) = find_person_name(cfg.people.iter().map(|p| p.name.as_str()), given) {
return Some(hit);
}
let g = given.trim().to_lowercase();
if g.contains("manager") || g == "boss" || g.contains("lead") && g.contains("the") {
return cfg.people.iter().find(|p| p.is_manager).map(|p| p.name.clone());
}
None
}
/// Kick off a new round if the barrier is open and anyone is due.
pub fn gather_due(
time: Res<Time>,
mut phase: ResMut<Phase>,
mut round: ResMut<RoundState>,
clock: Res<SimClock>,
pool: Res<LlmPool>,
cfg: Res<CompanyCfg>,
board: Res<TaskBoard>,
workspace: Res<Workspace>,
events: Res<CompanyEvents>,
bonds: Res<crate::shared::Bonds>,
mut people: Query<(Entity, &Person, &Memory, &mut BossNotes, &Transform)>,
others: Query<(Entity, &Person, &Transform)>,
) {
// Wait out the animation window before starting the next compute round.
if *phase != Phase::Stepping || time.elapsed_secs() < round.step_until {
return;
}
let day_start = clock.minute_of_day() == DAY_START;
let meeting_live = events
.meeting
.as_ref()
.is_some_and(|m| clock.0 < m.until);
let due: Vec<Entity> = people.iter().map(|(e, ..)| e).collect();
if due.is_empty() {
return;
}
let mut pending = HashSet::new();
for entity in &due {
// One mutable borrow per person: read everything, drain boss notes.
let Ok((_, person, memory, mut boss_notes, transform)) = people.get_mut(*entity) else {
continue;
};
let at = if person.location == "meeting" || (meeting_live && day_start) {
"the meeting room".to_string()
} else if person.location == "break" {
"the break room".to_string()
} else {
"their desk".to_string()
};
let my_work = board.my_work(&person.name);
let board_summary = board.summary();
let inbox = workspace.unread_mail(&person.name);
let inbox_str = if inbox.is_empty() {
"(inbox empty)".to_string()
} else {
inbox.join("\n---\n")
};
let memories: Vec<String> = memory.recent(8).cloned().collect();
// Boss chat: consume pending notes for this person.
let boss_tmp: Vec<String> = boss_notes.0.drain(..).collect();
let memories_str = if memories.is_empty() {
"(no memories yet)".to_string()
} else {
memories
.iter()
.enumerate()
.map(|(i, m)| format!(" {}. {m}", i + 1))
.collect::<Vec<_>>()
.join("\n")
};
let mut nearby = Vec::new();
for (_, other, otransform) in &others {
if other.name == person.name {
continue;
}
if flat(otransform.translation).distance(flat(transform.translation)) < 130.0
{
nearby.push(other.name.clone());
}
}
let nearby_str = if nearby.is_empty() {
"nobody nearby".to_string()
} else {
nearby.join(", ")
};
let meeting_str = match &events.meeting {
Some(m) if clock.0 < m.until => format!(
"MEETING IN PROGRESS: \"{}\" (ends {:02}:{:02}). Expected attendees are in the meeting room.",
m.topic,
(m.until % 1440.0) as u32 / 60,
(m.until % 1440.0) as u32 % 60
),
_ => "no meeting scheduled".to_string(),
};
let standup_str = if day_start {
"\nIt is 09:00 — MORNING STANDUP in the meeting room. The manager assigns today's \
work (assign); employees say what they'll do and raise blockers (talk / raise_concern)."
} else {
""
};
let lunch_str = if clock.minute_of_day() >= LUNCH_START
&& clock.minute_of_day() < LUNCH_END
{
"\nIt is lunch time (12:00–13:00)."
} else {
""
};
// (boss placeholder removed)
let plan_txt = match workspace.plan() {
Some(p) => format!("Company plan (work/plan.md):\n{}\n", short(&p, 800)),
None => {
if person.manager {
"\nThe company has NO plan yet. Your first action MUST be `kickoff` — \
decide the sprint plan before anyone codes.\n"
.to_string()
} else {
"\n(no company plan yet — expect the manager to kick off at standup)\n"
.to_string()
}
}
};
let boss_txt = if boss_tmp.is_empty() {
String::new()
} else {
format!(
"*** MESSAGE FROM THE BOSS (the user watching this sim; they outrank \
the manager): ***\n{}\nAnswer them directly and consider acting on it.\n",
boss_tmp.join("\\n")
)
};
// Per-person dynamic state: personal machine + working relationships.
let computer_files = {
let dir = workspace.computer_dir(&person.name);
let names: Vec<String> = crate::company::list_files(&dir)
.into_iter()
.map(|(f, _)| f)
.collect();
if names.is_empty() {
"(empty — your personal machine has no scratch files yet)".to_string()
} else {
names.join(", ")
}
};
let bonds_str = bonds.lines_for(&person.name);
// Diff-based review context: the manager reads what a person actually
// wrote (last commit) instead of the vibe of a work-log note.
let review_diff_str = if person.manager
&& board.tasks.iter().any(|t| t.status == TaskStatus::InReview)
{
match crate::gitrepo::show_last_diff(&workspace.project_root(), 900) {
Some(d) => format!(
"\nWork submitted for review (the author's last commit):\n{d}\nJudge it like a real reviewer: what's actually wrong / good.\n"
),
None => String::new(),
}
} else {
String::new()
};
let user = format!(
"[Day {}, {}] You are at: {at}.\n\
{plan_txt}\
Your assigned work:\n{my_work}\n\
Task board:\n{board_summary}\n\
Your inbox (unread email):\n{inbox_str}\n\
Nearby: {nearby_str}.\n\
{meeting_str}.{standup_str}{lunch_str}\n\
Your computer (private files on your machine):\n{computer_files}\n\
Your working relationships (trust grows when you help each other and \
it never resets silently — people remember slights):\n{bonds_str}\n{review_diff_str}\
Your memories:\n{memories_str}\n\
{boss_txt}\
Choose your one action as JSON. If something needs real talking-through \
(a design question, a disagreement, planning a hand-off), prefer \
`discuss` over `talk` — you get a real conversation, and BOTH of you \
remember what was agreed.",
clock.day(),
clock.hhmm(),
plan_txt = plan_txt,
boss_txt = boss_txt,
);
let job = Job {
person: person.name.clone(),
task: String::new(),
write_kind: WriteKind::WorkLog, // unused for decides
image: None,
system: system_prompt(
&cfg,
person.idx,
person.manager,
clock.day(),
workspace.plan().is_some(),
),
user,
};
crate::activity::dispatch(
&person.name,
false,
"decide",
&job.system,
&job.user,
);
if pool.decide_tx.send(job).is_ok() {
pending.insert(person.name.clone());
}
// Reading mail consumes it — it's in this prompt now.
workspace.mark_read(&person.name);
}
if pending.is_empty() {
return;
}
round.start_round();
round.pending = pending;
round.computing_since = time.elapsed_secs();
*phase = Phase::Computing;
info!(
"[round {}] {} people computing ({})",
round.rounds + 1,
round.pending.len(),
if day_start { "standup" } else { "work block" }
);
}
type PeopleQuery<'w, 's> = Query<
'w,
's,
(
Entity,
&'static mut Person,
&'static mut Memory,
&'static mut Walking,
&'static mut Bubble,
&'static mut BossNotes,
&'static Transform,
),
>;
/// Collect results while frozen; when the last one lands, lift the barrier.
#[allow(clippy::too_many_arguments)]
pub fn collect_results(
time: Res<Time>,
mut phase: ResMut<Phase>,
mut round: ResMut<RoundState>,
mut clock: ResMut<SimClock>,
mut pool: ResMut<LlmPool>,
mut company_events: ResMut<CompanyEvents>,
cfg: Res<CompanyCfg>,
mut board: ResMut<TaskBoard>,
workspace: Res<Workspace>,
spots: Res<Spots>,
names: Res<Names>,
mut people: PeopleQuery,
mut bonds: ResMut<crate::shared::Bonds>,
) {
if *phase != Phase::Computing {
return;
}
// Drain everything that has arrived.
loop {
let next = {
let rx = pool.results_rx.lock().unwrap_or_else(|e| e.into_inner());
rx.try_recv()
};
let Ok(result) = next else { break };
pool.in_flight = pool.in_flight.saturating_sub(1);
match result {
JobResult::Decide {
person,
action,
elapsed: _,
endpoint,
dialogue,
} => {
round.pending.remove(&person);
match action {
Ok(act) => {
pool.completed += 1;
// Split mode: writing-heavy decisions pause here until the
// coder model produces real content — the barrier stays
// closed (`pending_writes`) until every expansion lands.
let defer_target: Option<(WriteKind, String, String, String, String)> =
match &act {
Action::Work { task, note, .. } => {
match board.tasks.iter().find(|t| {
t.id.eq_ignore_ascii_case(task)
&& t.assignee.as_deref() == Some(person.as_str())
}) {
Some(t) => Some((
WriteKind::WorkLog,
t.id.clone(),
t.title.clone(),
t.description.clone(),
note.clone(),
)),
None => None, // not theirs — apply rejects it
}
}
Action::Email { to, subject, body } => Some((
WriteKind::Email,
to.clone(),
subject.clone(),
body.clone(),
format!("{subject}. {body}"),
)),
Action::Kickoff { plan } if workspace.plan().is_none() => {
let mut backlog_txt = String::new();
for t in &board.tasks {
backlog_txt.push_str(&format!(
"[{}] {} — {}\n",
t.id, t.title, t.description
));
}
Some((
WriteKind::Plan,
"plan.md".into(),
"sprint kickoff".into(),
backlog_txt,
plan.clone(),
))
}
Action::Look { about, .. } if cfg.company.vision => Some((
WriteKind::Look,
"the live camera feed".into(),
"a frame from the camera".into(),
String::new(),
about.clone(),
)),
_ => None,
};
if let Some((wk, subject, detail, extra, intent)) = defer_target {
if let Some(write_tx) = &pool.write_tx {
let (system, user) = write_prompts(
&cfg,
&person,
wk,
&subject,
&detail,
&extra,
&intent,
&workspace.plan().unwrap_or_default(),
);
// Vision spot-check: capture an actual frame
// first, so the LLM sees the real scene.
let frame_path = if wk == WriteKind::Look {
let dir = workspace.project_root().join("log");
std::fs::create_dir_all(&dir).ok();
let path = dir.join("last_frame.png");
let tstamp = format!("{}.{}", clock.day() * 10, ((clock.minute_of_day() * 3.0) % 1000.0) as u32);
let still = std::process::Command::new("pixi")
.current_dir(workspace.project_root())
.args(["run", "python", "scene.py", "still",
path.display().to_string().as_str(),
"--t", tstamp.as_str()])
.output();
match still {
Ok(o) if o.status.success() => Some(path.display().to_string()),
_ => {
info!("[look] frame capture failed — falling back to no image");
None
}
}
} else {
None
};
let job = Job {
person: person.clone(),
task: subject.clone(),
write_kind: wk,
system,
user,
image: frame_path,
};
crate::activity::dispatch(
&person,
true,
match wk {
WriteKind::WorkLog => "write (work log)",
WriteKind::Email => "write (email)",
WriteKind::Plan => "write (plan)",
WriteKind::Look => "look (vision)",
},
&job.system,
&job.user,
);
if write_tx.send(job).is_ok() {
round.pending_writes += 1;
round.deferred.insert(
person.clone(),
DecisionResult {
person: person.clone(),
action: Ok(act.clone()),
elapsed: 0.0,
endpoint: endpoint.clone(),
dialogue: None,
},
);
info!(
"[{}] → {} writes {} ({:.60})",
person,
match wk {
WriteKind::WorkLog => "coder",
WriteKind::Email => "writer",
WriteKind::Plan => "planner",
WriteKind::Look => "eyes",
},
subject,
intent
);
continue;
}
}
}
round.results.push(DecisionResult {
person,
action: Ok(act),
elapsed: 0.0,
endpoint,
dialogue,
});
}
Err(err) => {
pool.failed += 1;
warn!("[llm] decision failed for {person}: {err}");
round.results.push(DecisionResult {
person,
action: Err(err),
elapsed: 0.0,
endpoint,
dialogue: None,
});
}
}
}
JobResult::Write {
person,
task,
write_kind,
content,
elapsed: _,
endpoint,
} => {
round.pending_writes = round.pending_writes.saturating_sub(1);
let Some(mut deferred) = round.deferred.remove(&person) else {
continue; // timed out already; decide draft was applied
};
match content {
Ok(text) => {
pool.completed += 1;
match write_kind {
WriteKind::WorkLog => {
// entry replaces the note; repo files ride along
let (entry, files) = crate::llm::parse_written(&text);
if let Ok(Action::Work { note, files: f, .. }) =
&mut deferred.action
{
*note = if entry.is_empty() {
short(&text, 500)
} else {
entry
};
*f = files;
}
}
WriteKind::Look => {
// eyes-on note: what the VLM SAW becomes the
// look action's content (apply speaks it)
let body = clean_email_body(&text);
if let Ok(Action::Look { note, .. }) = &mut deferred.action {
*note = if body.is_empty() { short(&text, 400) } else { body };
}
}
WriteKind::Email => {
// the decide draft becomes a real, in-character email
let body = clean_email_body(&text);
if let Ok(Action::Email { body: b, .. }) = &mut deferred.action {
if !body.trim().is_empty() {
*b = body;
}
}
}
WriteKind::Plan => {
if let Ok(Action::Kickoff { plan }) = &mut deferred.action {
*plan = text;
}
}
}
if !endpoint.is_empty() {
deferred.endpoint = endpoint;
}
round.results.push(deferred);
}
Err(err) => {
pool.failed += 1;
warn!("[llm] coder failed for {person}/{task}: {err} — keeping decide draft");
if !endpoint.is_empty() {
deferred.endpoint = endpoint;
}
round.results.push(deferred);
}
}
}
}
}
// Deadman: never hang the scene on a dead endpoint.
let overdue = time.elapsed_secs() - round.computing_since > COMPUTE_TIMEOUT;
if overdue && !round.pending.is_empty() {
let stuck: Vec<String> = round.pending.drain().collect();
warn!("[round] timeout waiting for {stuck:?} — force-completing");
for person in stuck {
round.results.push(DecisionResult {
person,
action: Err("timeout".into()),
elapsed: COMPUTE_TIMEOUT,
endpoint: "dead".into(),
dialogue: None,
});
}
}
if overdue && round.pending_writes > 0 {
warn!(
"[round] timeout waiting for {} coder writes — using decide drafts",
round.pending_writes
);
let orphans: Vec<(String, DecisionResult)> = round.deferred.drain().collect();
for (person, mut deferred) in orphans {
deferred.endpoint = deferred.endpoint.clone(); // keep decide endpoint
round.results.push(deferred);
}
round.pending_writes = 0;
}
if !round.done() {
return;
}
// --- Barrier lifts: apply every decision of this round at once. ---
for result in round.results.drain(..) {
apply_result(
result,
&mut board,
&workspace,
&cfg,
&spots,
&names,
&mut people,
&mut company_events,
&clock,
&mut bonds,
);
}
board.day = clock.day();
board.sim_minutes = clock.0; // restore point for the next session
board.save(&workspace.root.join("tasks.json"));
round.rounds += 1;
// --- Advance the sim clock one step, handle day boundaries. ---
let before = clock.0;
clock.0 += STEP_MINUTES;
// 17:00 → next day 09:00.
if before % 1440.0 < DAY_END && (clock.0 % 1440.0) >= DAY_END {
// Build gate: verify once per day; failures get mailed to file owners
// overnight so the team wakes up to them at standup.
let gate = crate::verify::run_full(&cfg, &workspace);
if gate.ran {
let mailed = crate::verify::apply(&gate, &mut board, &workspace, clock.day(), &cfg.manager().name);
info!(
"[day {}] build gate: {}{}",
clock.day(),
gate.summary(),
if mailed > 0 { format!(", {mailed} failure mail(s) sent") } else { String::new() }
);
}
let done = board
.tasks
.iter()
.filter(|t| t.status == TaskStatus::Done)
.count();
info!(
"[day {}] end of day — {}/{} tasks done",
clock.day(),
done,
board.tasks.len()
);
let day = clock.day();
clock.0 = day as f32 * 1440.0 + DAY_START;
}
// 09:00 standup: everyone to the meeting room.
if clock.minute_of_day() == DAY_START {
company_events.meeting = Some(Meeting {
topic: "Morning standup".into(),
until: clock.0 + 30.0,
});
crate::shared::start_meeting_log(&workspace, &clock, "Morning standup");
let mut seat = 0;
for (_, _, _, mut walking, _, _, _) in people.iter_mut() {
let target = spots
.meeting_seats
.get(seat % spots.meeting_seats.len().max(1))
.copied()
.unwrap_or(spots.meeting);
walking.0 = Some(target);
seat += 1;
}
info!("[day {}] 09:00 — standup, everyone to the meeting room", clock.day());
}
// Standup ends at its until-time.
if let Some(m) = &company_events.meeting {
if clock.0 >= m.until {
info!("[{}] meeting \"{}\" ended", clock.hhmm(), m.topic);
crate::shared::end_meeting_log(&workspace, &clock, &m.topic);
company_events.meeting = None;
}
}
round.step_until = time.elapsed_secs() + STEP_ANIM;
*phase = Phase::Stepping;
}
#[allow(clippy::too_many_arguments)]
fn apply_result(
result: DecisionResult,
board: &mut TaskBoard,
workspace: &Workspace,
cfg: &CompanyCfg,
spots: &Spots,
names: &Names,
people: &mut PeopleQuery,
company_events: &mut CompanyEvents,
clock: &SimClock,
bonds: &mut crate::shared::Bonds,
) {
let Some(&entity) = names.0.get(&result.person.to_lowercase()) else {
warn!("[apply] unknown person '{}'", result.person);
return;
};
// Read-only context first, so later mutable borrows never overlap.
let Ok((_, person, _, _, _, _, transform)) = people.get(entity) else {
warn!("[apply] person '{}' not found in query", result.person);
return;
};
let me = person.name.clone();
let manager = person.manager;
let idx = person.idx;
let my_pos = flat(transform.translation);
let action = match result.action {
Ok(a) => {
info!("[apply] {me}: {a:?}");
a
}
Err(err) => {
warn!("[apply] {me} had no decision: {err}");
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("(could not decide: {err})"));
}
return;
}
};
match action {
Action::GoTo { place, reason } => match spots.lookup(&place, idx) {
Some(target) => {
if let Ok((_, mut person, mut memory, mut walking, _, _, _)) = people.get_mut(entity) {
walking.0 = Some(target);
person.location = match place.to_lowercase().as_str() {
"meeting" | "conference" | "standup" | "table" => "meeting",
"break" | "kitchen" | "coffee" | "lunch" => "break",
_ => "desk",
};
memory.push(format!("Went to {place}: {reason}"));
}
info!("[{}] {me} → {place}", clock.hhmm());
}
None => {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("(unknown place '{place}')"));
}
}
},
Action::Work { task, note, files } => {
let task = task.trim().to_string();
let mut outcome = String::new();
let mut log_line = None;
let mut wrote_files = Vec::new();
match board
.tasks
.iter_mut()
.find(|t| t.id.eq_ignore_ascii_case(&task) && t.assignee.as_deref() == Some(me.as_str()))
{
Some(t) if t.status == TaskStatus::Done => {
outcome = format!(
"{} is already done — nothing to work on.",
t.id
);
}
Some(t) if t.status != TaskStatus::Done && !t.acceptance.is_empty() => {
// Criteria mode: progress is derived from the repo, not a
// counter. Working = creating the files that satisfy it.
t.status = TaskStatus::InProgress;
t.notes
.push(format!("Day {} {}: {}", clock.day(), clock.hhmm(), short(¬e, 140)));
let path = workspace
.deliverable_dir(&me)
.join(format!("{}.md", t.id.to_lowercase()));
let entry = format!(
"\n## Day {} {} — {}\n{}\n",
clock.day(),
clock.hhmm(),
t.title,
note
);
let prev = std::fs::read_to_string(&path).unwrap_or_default();
std::fs::write(&path, prev + &entry).ok();
let project = workspace.project_root();
t.progress = crate::tasks::criteria_progress_of(t, &project)
.unwrap_or(t.progress);
let unmet = crate::tasks::unmet_criteria_of(t, &project);
let next = unmet
.first()
.map(|f| format!(" Next thing to create: {f}."))
.unwrap_or_else(|| String::new());
outcome = format!(
"Worked on {} — {}% (files on disk are the truth).{next}",
t.id, t.progress
);
log_line = Some(format!(
"worked on {} → {}% of criteria ({})",
t.id, t.progress, result.endpoint
));
if t.progress >= 100 {
outcome.push_str(" — criteria met, submit it for review!");
}
}
Some(t) => {
let step = 100 / t.effort_days.max(1);
t.progress = (t.progress + step).min(100);
if matches!(t.status, TaskStatus::Assigned | TaskStatus::ChangesRequested) {
t.status = TaskStatus::InProgress;
}
t.notes
.push(format!("Day {} {}: {}", clock.day(), clock.hhmm(), short(¬e, 140)));
// Append the work log into the shared document server.
let path = workspace
.deliverable_dir(&me)
.join(format!("{}.md", t.id.to_lowercase()));
let entry = format!(
"\n## Day {} {} — {}\n{}\n",
clock.day(),
clock.hhmm(),
t.title,
note
);
let prev = std::fs::read_to_string(&path).unwrap_or_default();
std::fs::write(&path, prev + &entry).ok();
outcome = format!("Worked on {} (now {}%)", t.id, t.progress);
log_line = Some(format!(
"worked on {} → {}% ({})",
t.id, t.progress, result.endpoint
));
if t.progress >= 100 {
outcome.push_str(" — it's complete, submit it for review!");
}
}
None => outcome = format!("(no assigned task '{task}' to work on)"),
}
// Write real repo files into the project tree (split mode).
let mut committed_paths: Vec<String> = Vec::new();
for (rel, content) in files.iter().take(5) {
// Clobber guard: a file last-committed by someone else is
// THEIRS until they hand it over — email/coordinate instead
// of last-writer-wins destroying the build.
if let Some(clean_probe) = crate::company::sanitize_repo_path(rel) {
let lower = clean_probe.to_lowercase();
if !rel.trim_start().starts_with("~/") {
if let Some(owner) = board.file_owners.get(&lower) {
if !owner.eq_ignore_ascii_case(&me) {
outcome.push_str(&format!(
" blocked: {} is {}'s file — email them before editing.",
clean_probe, owner
));
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!(
"{} is {}'s file. Coordinate first.",
clean_probe, owner
));
}
continue;
}
}
}
}
// "~/file" = personal scratch → lands on THEIR computer.
let personal = rel.trim_start().starts_with("~/");
let target = if personal {
workspace
.computer_dir(&me)
.join(rel.trim_start_matches("~").trim_start_matches('/'))
} else {
workspace.project_root().join(rel)
};
let written = if personal {
std::fs::create_dir_all(target.parent().unwrap_or(&target)).ok();
std::fs::write(&target, content).is_ok();
Some(rel.trim_start_matches("~/").trim_start_matches('/').to_string())
} else {
workspace
.write_project_file(rel, content)
.map(|clean| {
board.file_owners.insert(clean.to_lowercase(), me.clone());
committed_paths.push(clean.clone());
clean.clone()
})
};
if let Some(name) = written {
if wrote_files.len() < 4 {
wrote_files.push(name);
}
}
}
// Version control: everything they touched becomes an authored
// commit — history, blame, diffs. The work note is the message.
if !committed_paths.is_empty() {
let msg = format!("{}: {}", task, short(¬e, 70));
let repo = workspace.project_root();
let email = crate::gitrepo::person_email(&me, &cfg.company.name);
match crate::gitrepo::commit(&repo, &me, &email, &msg, &committed_paths) {
Some(text) if !text.starts_with("(git") => {
info!("[git] {} → commit: {}", me, short(&msg, 60));
}
_ => {} // no git, or index empty — fine
}
// CI-flavored gate: criteria tasks get judged immediately,
// while the author is still mentally in the code.
let has_criteria = board
.tasks
.iter()
.any(|t| t.id.eq_ignore_ascii_case(&task) && !t.acceptance.is_empty());
// The reflex gate: if the company configures a fast check
// (cargo check), IT runs before every full judge — cargo
// check lands in seconds and the author reads the result
// while the session still has context.
if cfg.verify_fast.is_some() {
let fast = crate::verify::run_fast(&cfg, workspace);
if fast.ran {
crate::verify::apply(
&fast,
board,
workspace,
clock.day(),
&cfg.manager().name,
);
}
}
if has_criteria {
let gate = crate::verify::run_full(&cfg, workspace);
crate::verify::apply(
&gate,
board,
workspace,
clock.day(),
&cfg.manager().name,
);
}
}
if !wrote_files.is_empty() {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("Wrote repo files: {}", wrote_files.join(", ")));
}
log_line = log_line.take().map(|l| {
format!("{l}, wrote {}", wrote_files.join(", "))
});
info!("[{}] {me} committed: {}", clock.hhmm(), wrote_files.join(", "));
}
if let Ok((_, mut person, mut memory, mut walking, _, _, _)) = people.get_mut(entity) {
memory.push(outcome);
// Work happens at their desk.
walking.0 = spots.desks.get(idx).copied();
person.location = "desk";
}
if let Some(line) = log_line {
info!("[{}] {me} {line}", clock.hhmm());
}
}
Action::Email { to, subject, body } => {
let target = resolve_target(cfg, &to);
match target {
Some(target_name) => {
let file = format!(
"day{:02}-{}_from-{}.md",
clock.day(),
clock.hhmm().replace(':', ""),
crate::people::person_dir(&me)
);
let content = format!(
"From: {me} ({})\nTo: {target_name}\nSubject: {subject}\nDay {} {}\n\n{body}\n",
person_role(cfg, &me),
clock.day(),
clock.hhmm()
);
std::fs::write(workspace.mail_inbox(&target_name).join(&file), content).ok();
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("Emailed {target_name}: \"{subject}\""));
}
info!("[{}] {me} emailed {target_name}: {subject}", clock.hhmm());
}
None => {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("(no one called '{to}' to email)"));
}
}
}
}
Action::Discuss { to, topic } => {
let Some(dlg) = result.dialogue else {
// safety: worker fell back at send time, but apply both ways
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("Wanted to discuss {topic} but the chat fizzled."));
}
return;
};
// Verbatim transcript → desk log (and the meeting record if live)
for (who, line) in &dlg.turns {
crate::shared::desk_log(workspace, clock, who.as_str(), to.as_str(), line.as_str());
if let Some(m) = &company_events.meeting {
if clock.0 < m.until {
crate::shared::note_meeting(
workspace,
clock,
&format!("{who} → {to}: {line}"),
);
}
}
}
crate::shared::desk_log(workspace, clock, &me, to.as_str(), &format!("— agreed: {}", dlg.summary));
// BOTH parties remember the whole exchange, not just a stub.
let transcript = dlg
.turns
.iter()
.map(|(w, l)| format!("{w}: {l}"))
.collect::<Vec<_>>()
.join(" / ");
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!(
"Discussed {} with {to} — transcript: {transcript} — agreed: {}",
topic, dlg.summary
));
}
if let Some(&t) = names.0.get(&to.to_lowercase()) {
if let Ok((_, _, mut partner_memory, _, _, _, _)) = people.get_mut(t) {
partner_memory.push(format!(
"{me} talked through {} with you — transcript: {transcript} — agreed: {}",
topic, dlg.summary
));
}
}
// Trust up for both, since they worked something out together.
bonds.adjust(&me, to.as_str(), 2, Some(format!("talked through {topic}")));
bonds.adjust(to.as_str(), &me, 2, Some(format!("talked through {topic}")));
}
Action::Look { about, note } => {
let report: String = if note.is_empty() {
"(could not look — no frame or no eyes)".into()
} else {
note.clone()
};
crate::shared::desk_log(workspace, clock, &me, "SCREEN", &report);
info!("[{}] {me} → looked at the scene: {:.240}", clock.hhmm(), report);
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!(
"Looked at the live frame (ask: \"{about}\"). Saw: {report}"
));
}
if let Some(m) = &company_events.meeting {
if clock.0 < m.until {
crate::shared::note_meeting(
workspace,
clock,
&format!("{me} (eyes on the screen): {report}"),
);
}
}
}
Action::Talk { to, message } => {
let addressed = resolve_target(cfg, &to).unwrap_or_else(|| to.clone());
if addressed == "__boss__" {
info!("[{}] → BOSS (you): {message}", clock.hhmm());
crate::shared::desk_log(workspace, clock, &me, "BOSS (you)", &message);
if let Some(m) = &company_events.meeting {
if clock.0 < m.until {
crate::shared::note_meeting(workspace, clock, &format!("{me} → BOSS (you): {message}"));
}
}
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("Told the boss: \"{message}\""));
}
return;
}
// 1) speaker: bubble + memory (scoped borrow).
if let Ok((_, _, mut memory, _, mut bubble, _, _)) = people.get_mut(entity) {
bubble.text = message.clone();
bubble.expires = clock.0 + 15.0;
memory.push(format!("Said to {addressed}: \"{message}\""));
}
info!("[{}] {me} → {addressed}: {message}", clock.hhmm());
crate::shared::desk_log(workspace, clock, &me, &addressed, &message);
if let Some(m) = &company_events.meeting {
if clock.0 < m.until {
crate::shared::note_meeting(
workspace,
clock,
&format!("{me} → {addressed}: {message}"),
);
}
}
// 2) listeners (fresh borrow, no overlap).
let everyone =
addressed.eq_ignore_ascii_case("everyone") || addressed.eq_ignore_ascii_case("all");
for (_, other, mut other_memory, _, mut other_bubble, _, otransform) in people.iter_mut() {
if other.name == me {
continue;
}
let addressed = everyone || other.name.eq_ignore_ascii_case(&addressed);
if addressed && flat(otransform.translation).distance(my_pos) < 160.0 {
other_memory.push(format!("{me} said: \"{message}\""));
if everyone {
let _ = &mut other_bubble;
}
}
}
}
Action::Submit { task, summary } => {
let mut outcome = String::new();
let mut notify = None;
let mut log_line = None;
match board.tasks.iter_mut().find(|t| t.id.eq_ignore_ascii_case(&task)) {
Some(t) if t.assignee.as_deref() == Some(me.as_str()) => {
if t.progress < 100 {
outcome = format!(
"{} is only {}% done — keep working before submitting.",
t.id, t.progress
);
} else if t.status == TaskStatus::Done {
outcome = format!("{} is already done.", t.id);
} else if t.status == TaskStatus::InReview {
outcome = format!("{} is already awaiting review.", t.id);
} else if !t.acceptance.is_empty() {
// Criteria gate: no submission without the files.
let unmet = crate::tasks::unmet_criteria_of(t, &workspace.project_root());
outcome = format!(
"{} can't be submitted — still missing: {}",
t.id,
unmet.join(", ")
);
} else {
t.status = TaskStatus::InReview;
t.notes.push(format!("Day {} submitted: {}", clock.day(), summary));
outcome = format!("Submitted {} for review.", t.id);
log_line = Some(format!("submitted {} for review", t.id));
if let Some(mgr) = cfg.people.iter().find(|p| p.is_manager) {
notify = Some((
mgr.name.clone(),
format!("{} ready for review", t.id),
format!("{summary}\n"),
));
}
}
}
_ => outcome = format!("(cannot submit '{task}' — not yours or unknown)"),
}
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(outcome);
}
if let Some(line) = log_line {
info!("[{}] {me} {line}", clock.hhmm());
}
if let Some((mgr, subject, body)) = notify {
send_mail(workspace, clock, &mgr, &me, &subject, &body);
}
}
Action::Assign { task, to, due_day } => {
if !manager {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push("(only the manager can assign tasks)".into());
}
return;
}
let target = resolve_target(cfg, &to);
let mut outcome = format!("(could not assign '{task}' — unknown task or person)");
let mut notify: Option<(String, String, String)> = None;
let mut assigned_id = None;
if let (Some(due), Some(target_name)) = (board.task_mut(&task), target) {
if due.assignee.is_none() {
let final_due =
due_day.unwrap_or_else(|| clock.day() + due.effort_days);
due.assignee = Some(target_name.clone());
due.status = TaskStatus::Assigned;
due.due_day = Some(final_due.max(clock.day()));
outcome =
format!("Assigned {} to {target_name}, due day {final_due}.", due.id);
assigned_id = Some((
due.id.clone(),
due.title.clone(),
target_name,
final_due,
));
} else {
outcome = format!(
"{} is already assigned.",
due.id
);
}
}
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(outcome);
}
if let Some((id, title, target_name, due_day)) = assigned_id {
info!("[{}] {me} assigned {id} to {target_name} (due day {due_day})", clock.hhmm());
notify = Some((
target_name.clone(),
format!("You have {id} ({title})"),
format!("Please take this on. Due day {due_day}.\n"),
));
if let Some(&e) = names.0.get(&target_name.to_lowercase()) {
if let Ok((_, _, mut m, _, _, _, _)) = people.get_mut(e) {
m.push(format!("Assigned {id}: {title} — due day {due_day}."));
}
}
}
if let Some((to_name, subject, body)) = notify {
send_mail(workspace, clock, &to_name, &me, &subject, &body);
}
}
Action::Review {
task,
approve,
feedback,
topic,
} => {
if !manager {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push("(only the manager can review)".into());
}
return;
}
// The decide model sometimes puts the verdict in feedback/topic
// instead of the boolean — recover it. Verdict-less reviews are
// SKIPPED, never auto-approved.
let mut fb = feedback.clone();
if fb.is_empty() {
fb = topic.clone();
}
let approve = approve.unwrap_or_else(|| {
let hay = format!("{} {}", fb, topic).to_lowercase();
let no = ["changes", "bounce", "reject", "fail", "again", "redo", "missing"]
.iter()
.any(|w| hay.contains(w.trim()));
let yes = ["approved", " approve", "yes", "pass", "nice work", "looks good"]
.iter()
.any(|w| hay.contains(w.trim()));
yes && !no
});
// approve was an Option in the raw action; the closure above
// already consumed it — re-derive "was it explicit?" from inputs.
let clear_verdict = feedback.trim().is_empty() && topic.trim().is_empty();
if clear_verdict {
let outcome = format!("Review of {} skipped — no verdict given.", task);
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(outcome.clone());
}
info!("[{}] {me} review skipped: {}", clock.hhmm(), task);
return;
}
let mut outcome = String::new();
let mut log_line = None;
let mut notify: Option<(String, String, String)> = None;
match board.task_mut(&task) {
Some(t) if t.status == TaskStatus::InReview => {
let assignee = t.assignee.clone().unwrap_or_default();
// Criteria tasks get re-checked against the real files
// before anything can be marked done — approving a
// criteria task whose files are absent is impossible.
let mut approve = approve;
if approve {
let project = workspace.project_root();
if let Some(met) = crate::tasks::criteria_progress_of(t, &project) {
if met < 100 {
let unmet = crate::tasks::unmet_criteria_of(t, &project).join(", ");
approve = false;
fb = format!("not approved — files still missing: {unmet}");
}
}
}
if approve {
t.status = TaskStatus::Done;
outcome = format!("Approved {}.", t.id);
log_line = Some(format!("approved {} (done)", t.id));
} else {
t.status = TaskStatus::ChangesRequested;
t.progress = (t.progress - 15).max(0);
t.notes.push(format!("Review: {fb}"));
outcome = format!("Sent {} back: {fb}", t.id);
log_line = Some(format!("bounced {} — {fb}", t.id));
}
if !assignee.is_empty() {
bonds.adjust(
&assignee,
&me,
if approve { 2 } else { -1 },
Some(fb.clone()),
);
bonds.adjust(
&me,
&assignee,
if approve { 1 } else { 0 },
Some(fb.chars().take(50).collect()),
);
notify = Some((
assignee.clone(),
format!("Review of {}", t.id),
format!(
"{}\n\n{}\n",
if approve {
"APPROVED — nice work."
} else {
"CHANGES REQUESTED."
},
fb
)
));
}
}
Some(t) => {
outcome = format!("{} is not awaiting review ({}).", t.id, t.status.label());
log_line = Some(format!(
"review skipped — {} is {}",
t.id,
t.status.label()
));
}
None => outcome = format!("(unknown task '{task}')"),
}
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(outcome);
}
if let Some(line) = log_line {
info!("[{}] {me} {line}", clock.hhmm());
}
if let Some((to_name, subject, body)) = notify {
send_mail(workspace, clock, &to_name, &me, &subject, &body);
}
}
Action::Kickoff { plan } => {
if !manager {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push("(only the manager can write the kickoff plan)".into());
}
} else if workspace.plan().is_some() {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(
"(the plan already exists — check the task board)".to_string(),
);
}
} else {
let plan_text = if plan.trim().is_empty() {
format!(
"## Product\n{}\n\n## Approach\n{}\n\n## Rule of engagement\nWork only tasks on the board; flag unrealistic scope early.",
cfg.company.product,
cfg.company.mission
)
} else {
plan
};
std::fs::write(workspace.root.join("plan.md"), &plan_text).ok();
info!(
"[{}] {me} kicked off the sprint: {:.140}",
clock.hhmm(),
plan_text
);
// Everyone wakes up knowing what they're building.
for (_, other, mut other_memory, _, _, _, _) in people.iter_mut() {
other_memory.push(format!(
"Company plan published: {}",
short(&plan_text, 110)
));
}
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push("Wrote the company kickoff plan.".into());
}
}
}
Action::Meeting { topic } => {
if !manager {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push("(only the manager can call meetings)".into());
}
return;
}
company_events.meeting = Some(Meeting {
topic: topic.clone(),
until: clock.0 + 60.0,
});
crate::shared::start_meeting_log(workspace, clock, &topic);
info!("[{}] {me} called a meeting: {topic}", clock.hhmm());
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("Called meeting: {topic}"));
}
// Everyone heads to the meeting room.
let mut seat = 0;
for (_, mut person, _, mut walking, _, _, _) in people.iter_mut() {
let target = spots
.meeting_seats
.get(seat % spots.meeting_seats.len().max(1))
.copied()
.unwrap_or(spots.meeting);
walking.0 = Some(target);
person.location = "meeting";
seat += 1;
}
}
Action::BossChat { .. } => {
// handled directly by human_commands; should never reach the world.
}
Action::RaiseConcern { task, message } => {
let mut outcome = format!("(cannot raise concern on '{task}' — not yours)");
let mut notify = None;
let mut log_line = None;
if let Some(t) = board.task_mut(&task) {
if t.assignee.as_deref() == Some(me.as_str()) {
t.concern = Some(message.clone());
outcome = format!("Raised a concern on {}: {message}", t.id);
log_line = Some(format!("concern on {}: {message}", t.id));
if let Some(mgr) = cfg.people.iter().find(|p| p.is_manager) {
notify = Some((
mgr.name.clone(),
format!("Concern about {}", t.id),
format!("{message}\n"),
));
}
}
}
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(outcome);
}
if let Some(line) = log_line {
info!("[{}] {me} {line}", clock.hhmm());
}
if let Some((to_name, subject, body)) = notify {
send_mail(workspace, clock, &to_name, &me, &subject, &body);
}
}
Action::Rest { reason } => {
if let Ok((_, mut person, mut memory, mut walking, _, _, _)) = people.get_mut(entity) {
walking.0 = Some(spots.break_room);
person.location = "break";
memory.push(format!("Took a break: {reason}"));
}
info!("[{}] {me} on break ({reason})", clock.hhmm());
}
Action::Idle { reason } => {
if let Ok((_, _, mut memory, _, _, _, _)) = people.get_mut(entity) {
memory.push(format!("Idle: {reason}"));
}
info!("[{}] {me} idle ({reason})", clock.hhmm());
}
}
}
fn short(s: &str, n: usize) -> String {
let t = s.trim();
if t.chars().count() <= n {
t.to_string()
} else {
t.chars().take(n).collect::<String>() + "…"
}
}
/// Email text from the coder may carry protocol scraps — strip them.
fn clean_email_body(text: &str) -> String {
let mut body = String::new();
for line in text.lines() {
let t = line.trim();
if t.starts_with("WORKLOG:") || t.starts_with("FILE:") || t == "```" {
continue;
}
body.push_str(line);
body.push('\n');
}
body.trim().to_string()
}
fn person_role(cfg: &CompanyCfg, name: &str) -> String {
cfg.people
.iter()
.find(|p| p.name == name)
.map(|p| p.role.clone())
.unwrap_or_default()
}
fn send_mail(
workspace: &Workspace,
clock: &SimClock,
to: &str,
from: &str,
subject: &str,
body: &str,
) {
let file = format!(
"day{:02}-{}_from-{}.md",
clock.day(),
clock.hhmm().replace(':', ""),
crate::people::person_dir(from)
);
let content = format!(
"From: {from}\nTo: {to}\nSubject: {subject}\nDay {} {}\n\n{body}",
clock.day(),
clock.hhmm()
);
std::fs::write(workspace.mail_inbox(to).join(file), content).ok();
}
// ---------------------------------------------------------------------------
// Manager console: the human's commands are applied ON TOP of the manager
// LLM's autonomous behavior. The sim never waits for input — when nobody
// types, nothing here runs.
// ---------------------------------------------------------------------------
pub fn human_commands(
inbox: Res<crate::HumanInbox>,
clock: Res<SimClock>,
cfg: Res<CompanyCfg>,
mut board: ResMut<TaskBoard>,
workspace: Res<Workspace>,
spots: Res<Spots>,
names: Res<Names>,
mut company_events: ResMut<CompanyEvents>,
mut people: PeopleQuery,
mut bonds: ResMut<crate::shared::Bonds>,
) {
// Collect command lines from stdin AND the TUI bridge file
// (work/commands.txt, one command per line, consumed + truncated here).
let mut lines: Vec<String> = Vec::new();
while let Ok(line) = inbox.try_recv() {
lines.push(line);
}
if let Ok(text) = std::fs::read_to_string(workspace.root.join("commands.txt")) {
lines.extend(text.lines().map(str::to_string));
std::fs::write(workspace.root.join("commands.txt"), "").ok();
}
for line in lines {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
// Instant console actions that don't consume a round action:
// gitlog — read the repo's commit history (the sim's other transcript)
// revert — undo the latest commit as the boss, leaving a real scar
if parts[0].eq_ignore_ascii_case("gitlog") {
match crate::gitrepo::log(&workspace.project_root(), 12) {
Some(lines) => {
for line in lines {
println!(" {line}");
}
}
None => println!(" (no git repo / git unavailable)"),
}
continue;
}
if parts[0].eq_ignore_ascii_case("revert") {
match crate::gitrepo::revert_head(&workspace.project_root(), "The Boss") {
Some(text) => println!(" reverted: {text:.200}"),
None => println!(" (nothing to revert / git unavailable)"),
}
info!("[git] boss reverted HEAD");
continue;
}
if matches!(parts[0].to_lowercase().as_str(), "judge" | "verify") { let gate = crate::verify::run_full(&cfg, &workspace);
let lines: Vec<&str> = gate.output.lines().collect();
for line in lines.iter().rev().take(30).rev() {
println!(" {line}");
}
let mailed = crate::verify::apply(
&gate,
&mut board,
&workspace,
clock.day(),
&cfg.manager().name,
);
if mailed > 0 {
println!("{}, {mailed} failure mail(s) sent to owners", gate.summary());
} else {
println!("{}", gate.summary());
}
continue;
}
let action = match parts[0].to_lowercase().as_str() {
"assign" => parse_assign(&parts),
"review" => parse_review(&parts),
"meeting" if parts.len() > 1 => Ok(Action::Meeting {
topic: parts[1..].join(" "),
}),
"chat" => {
if parts.len() < 2 {
Err(
"usage: chat <person> <message> — the reply comes next round"
.into(),
)
} else {
// chat <person...> <message...> — deliver to their next prompt.
// Exact-name matching only (full name or first name), longest
// window first — fuzzy contains matching was eating whole
// messages as "names" (empty-body bug).
fn resolve_name_exact(
cfg: &crate::company::CompanyCfg,
given: &str,
) -> Option<String> {
let g = given.trim().to_lowercase();
cfg.people
.iter()
.find(|p| {
let n = p.name.to_lowercase();
let first = n.split_whitespace().next().unwrap_or("");
n == g || first == g
})
.map(|p| p.name.clone())
}
let mut target = None;
let mut split_at = 0usize;
for end in (1..parts.len()).rev() {
// full-name/first-name exact on the leading window;
// fall back to the loose resolver only for a bare
// "boss"/"me-style" alias on a one-token window.
if end <= 2 {
if let Some(hit) = resolve_name_exact(&cfg, &parts[1..=end].join(" ")) {
target = Some(hit);
split_at = end;
break;
}
let one = parts[1..=end].join(" ").to_lowercase();
if end == 1 && (one == "boss" || one == "user" || one == "me") {
target = Some("__boss__".into());
split_at = end;
break;
}
}
}
if let Some(target) = target {
Ok(Action::BossChat {
to: target,
message: parts[split_at + 1..].join(" "),
})
} else {
Err(format!(
"chat: no one matches '{}' — roster: {}",
parts[1],
cfg.people
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>()
.join(", ")
))
}
}
}
"help" | "?" => {
println!(
"manager console (runs alongside Dana's own management):\n \
assign <task> <person...> [due_day] e.g. assign T7 Priya Nair 3\n \
review <task> approve|bounce <feedback...>\n \
meeting <topic...> e.g. meeting sprint planning\n \
judge run the build gate now, mail failures\n \
assign overrides an existing holder; everything else is additive."
);
continue;
}
other => Err(format!("unknown command '{other}' — type help")),
};
let action = match action {
Ok(a) => a,
Err(msg) => {
eprintln!("console: {msg}");
continue;
}
};
// Override: a human assign re-assigns even if someone holds the task.
if let Action::Assign { task, .. } = &action {
if let Some(t) = board.task_mut(task) {
if let Some(old) = t.assignee.clone() {
info!("[user] overriding {}: was {}", t.id, old);
if let Some(&e) = names.0.get(&old.to_lowercase()) {
if let Ok((_, _, mut m, _, _, _, _)) = people.get_mut(e) {
m.push(format!(
"{} was reassigned away from you (manager override).",
t.id
));
}
}
t.assignee = None;
}
}
}
if let Action::BossChat { to, message } = &action {
if let Some(&entity) = names.0.get(&to.to_lowercase()) {
if let Ok((_, _, _, _, _, mut boss_notes, _)) = people.get_mut(entity) {
boss_notes.0.push(message.clone());
info!("{}[boss] you → {}: {}", clock.hhmm(), to, message);
let _ = workspace;
}
} else {
eprintln!("console: no one called '{to}'");
}
board.save(&workspace.root.join("tasks.json"));
continue;
}
let manager = cfg
.people
.iter()
.find(|p| p.is_manager)
.map(|p| p.name.clone())
.unwrap_or_else(|| "manager".into());
info!("[user] {} (as {manager})", line);
let result = DecisionResult {
person: manager,
action: Ok(action),
elapsed: 0.0,
endpoint: "user".into(),
dialogue: None,
};
apply_result(
result,
&mut board,
&workspace,
&cfg,
&spots,
&names,
&mut people,
&mut company_events,
&clock,
&mut bonds,
);
board.save(&workspace.root.join("tasks.json"));
}
}
fn parse_assign(parts: &[&str]) -> Result<Action, String> {
if parts.len() < 3 {
return Err("usage: assign <task> <person...> [due_day]".into());
}
let task = parts[1].to_string();
let due: Option<u32> = parts.last().and_then(|s| s.parse().ok());
let person_tokens = if due.is_some() {
&parts[2..parts.len() - 1]
} else {
&parts[2..]
};
if person_tokens.is_empty() {
return Err("usage: assign <task> <person...> [due_day]".into());
}
Ok(Action::Assign {
task,
to: person_tokens.join(" "),
due_day: due,
})
}
fn parse_review(parts: &[&str]) -> Result<Action, String> {
if parts.len() < 3 {
return Err("usage: review <task> approve|bounce <feedback...>".into());
}
let verdict = parts[2].to_lowercase();
let approve = match verdict.as_str() {
"approve" | "approved" | "ok" | "pass" | "yes" => true,
"bounce" | "reject" | "changes" | "no" | "fail" => false,
other => return Err(format!("'{other}' — use approve or bounce")),
};
Ok(Action::Review {
task: parts[1].to_string(),
approve: Some(approve),
feedback: parts[3..].join(" "),
topic: String::new(),
})
}