swarm / office/src/round.rs

raw file 74955 bytes
//! 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(&note, 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(&note, 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(&note, 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(),
    })
}