swarm / office/src/company.rs

raw file 11895 bytes
//! Company definition: everything a copied company folder needs to change.
//!
//!   companies/starter/company.toml   ← copy the folder, edit this file

use bevy::prelude::Resource;
use serde::Deserialize;
use std::path::{Path, PathBuf};

#[derive(Deserialize, Clone, Resource)]
pub struct CompanyCfg {
    pub company: Company,
    pub people: Vec<Person>,
    pub backlog: Vec<BacklogItem>,
    /// Optional automated verifier ("build gate") run at end of day + on the
    /// boss's `judge` console command.
    #[serde(default)]
    pub verify: Option<VerifyCfg>,
    /// Cheap per-commit check (e.g. `cargo check`) — runs the moment a
    /// commit lands, so the team feels the compiler before anyone reads.
    #[serde(default)]
    pub verify_fast: Option<VerifyCfg>,
}

#[derive(Deserialize, Clone)]
pub struct VerifyCfg {
    pub command: String,
    #[serde(default = "verify_timeout")]
    pub timeout_secs: u32,
}

fn verify_timeout() -> u32 {
    120
}

#[derive(Deserialize, Clone)]
pub struct Company {
    pub name: String,
    pub mission: String,
    pub product: String,
    #[serde(default)]
    pub stack: String,
    /// The team's model has eyes (llama.cpp mmproj / VLM): enables the
    /// `look` action — agents capture a frame and ask the model about it.
    #[serde(default)]
    pub vision: bool,
}

#[derive(Deserialize, Clone)]
pub struct Person {
    pub name: String,
    pub role: String,
    pub personality: String,
    #[serde(default)]
    pub is_manager: bool,
    #[serde(default)]
    pub skills: Vec<String>,
}

#[derive(Deserialize, Clone)]
pub struct BacklogItem {
    pub id: String,
    pub title: String,
    pub description: String,
    pub area: String,
    #[serde(default = "one")]
    pub effort_days: u32,
    /// Success criteria (see tasks::Criterion). Empty → counter mode.
    #[serde(default)]
    pub acceptance: Vec<crate::tasks::Criterion>,
}

fn one() -> u32 {
    1
}

impl CompanyCfg {
    pub fn load(company_dir: &Path) -> Result<Self, String> {
        let path = company_dir.join("company.toml");
        let text = std::fs::read_to_string(&path)
            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
        let cfg: CompanyCfg =
            toml::from_str(&text).map_err(|e| format!("bad {}: {e}", path.display()))?;
        if cfg.people.is_empty() {
            return Err("company.toml defines no people".into());
        }
        if !cfg.people.iter().any(|p| p.is_manager) {
            return Err("nobody has is_manager = true — the manager assigns work".into());
        }
        Ok(cfg)
    }

    pub fn manager(&self) -> &Person {
        self.people.iter().find(|p| p.is_manager).expect("checked in load")
    }
}

/// Recursive copy; never overwrites files that already exist at the destination.
fn copy_tree(src: &Path, dst: &Path) {
    let Ok(entries) = std::fs::read_dir(src) else { return };
    std::fs::create_dir_all(dst).ok();
    for e in entries.flatten() {
        let from = e.path();
        let to = dst.join(e.file_name());
        if from.is_dir() {
            copy_tree(&from, &to);
        } else if !to.exists() {
            std::fs::copy(&from, &to).ok();
        }
    }
}

/// Filesystem-safe folder name: "Dana Reyes" -> "dana-reyes".
pub fn slug(name: &str) -> String {
    let mut out = String::new();
    for ch in name.to_lowercase().chars() {
        if ch.is_alphanumeric() {
            out.push(ch);
        } else if !out.ends_with('-') {
            out.push('-');
        }
    }
    out.trim_matches('-').to_string()
}

/// The shared "document server": mail, deliverables, standups, task board.
#[derive(Resource)]
pub struct Workspace {
    pub root: PathBuf,
}

impl Workspace {
    pub fn new(company_dir: &Path) -> std::io::Result<Self> {
        let root = company_dir.join("work");
        for sub in ["mail", "deliverables", "standups", "meetings", "project"] {
            std::fs::create_dir_all(root.join(sub))?;
        }
        // Seed the day-1 product repo from <company>/seed — existing files are
        // never overwritten (re-running or resuming keeps agent edits), then
        // make it a real git repo so everything after is authored history.
        copy_tree(&company_dir.join("seed"), &root.join("project"));
        crate::gitrepo::init(&root.join("project"));
        crate::activity::set_root(root.join("log").join("llm.json"));
        Ok(Self { root })
    }

    pub fn mail_inbox(&self, person_name: &str) -> PathBuf {
        let dir = self.root.join("mail").join(slug(person_name));
        std::fs::create_dir_all(&dir).ok();
        dir
    }

    pub fn deliverable_dir(&self, person_name: &str) -> PathBuf {
        let dir = self.root.join("deliverables").join(slug(person_name));
        std::fs::create_dir_all(&dir).ok();
        dir
    }

    /// Per-person "computer": personal scratch files (paths starting "~/").
    pub fn computer_dir(&self, person_name: &str) -> PathBuf {
        let dir = self.root.join("computers").join(slug(person_name));
        std::fs::create_dir_all(&dir).ok();
        dir
    }

    /// All not-yet-read mail files for a person (read = renamed with .read suffix).
    pub fn unread_mail(&self, person_name: &str) -> Vec<String> {
        let dir = self.mail_inbox(person_name);
        let mut out = Vec::new();
        if let Ok(entries) = std::fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let name = entry.file_name().to_string_lossy().to_string();
                if name.ends_with(".md") {
                    if let Ok(text) = std::fs::read_to_string(entry.path()) {
                        out.push(text);
                    }
                }
            }
        }
        out.sort();
        out
    }

    pub fn mark_read(&self, person_name: &str) {
        let dir = self.mail_inbox(person_name);
        if let Ok(entries) = std::fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                let name = path.file_name().unwrap().to_string_lossy().to_string();
                if name.ends_with(".md") {
                    std::fs::rename(&path, path.with_extension("md.read")).ok();
                }
            }
        }
    }

    /// Every mail file for a person: (filename, content, unread) sorted by name.
    fn md_files(&self, dir: &Path, unread_suffix: &str) -> Vec<(String, String, bool)> {
        let mut out = Vec::new();
        if let Ok(entries) = std::fs::read_dir(dir) {
            for entry in entries.flatten() {
                let name = entry.file_name().to_string_lossy().to_string();
                let unread = name.ends_with(unread_suffix);
                if !(name.ends_with(".md") || name.ends_with(".md.read")) {
                    continue;
                }
                if let Ok(text) = std::fs::read_to_string(entry.path()) {
                    out.push((name, text, unread));
                }
            }
        }
        out.sort();
        out
    }

    /// All mail for a person: (name, content, unread?) — unread first when sorted.
    pub fn mail_files(&self, person_name: &str) -> Vec<(String, String, bool)> {
        self.md_files(&self.mail_inbox(person_name), ".md")
    }

    /// Deliverable files: (filename, content).
    pub fn deliverable_files(&self, person_name: &str) -> Vec<(String, String)> {
        let dir = self.deliverable_dir(person_name);
        let mut out = Vec::new();
        if let Ok(entries) = std::fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let name = entry.file_name().to_string_lossy().to_string();
                if name.ends_with(".md") {
                    if let Ok(text) = std::fs::read_to_string(entry.path()) {
                        out.push((name, text));
                    }
                }
            }
        }
        out.sort();
        out
    }

    /// Every deliverable across all people: (person, filename).
    pub fn all_deliverables(&self, names: &[String]) -> Vec<(String, String)> {
        let mut out = Vec::new();
        for name in names {
            for (file, _) in self.deliverable_files(name) {
                out.push((slug(name), file));
            }
        }
        out
    }
}


impl Workspace {
    /// The simulated product repo — starts EMPTY; agents bootstrap it.
    pub fn project_root(&self) -> PathBuf {
        let dir = self.root.join("project");
        std::fs::create_dir_all(&dir).ok();
        dir
    }

    /// The company plan (work/plan.md), if the manager has kicked off.
    pub fn plan(&self) -> Option<String> {
        std::fs::read_to_string(self.root.join("plan.md"))
            .ok()
            .map(|t| t.trim().to_string())
    }

    /// All files in the project tree: (relative path, bytes).
    pub fn project_files(&self) -> Vec<(String, usize)> {
        fn walk(dir: &Path, base: &Path, out: &mut Vec<(String, usize)>) {
            if let Ok(entries) = std::fs::read_dir(dir) {
                for e in entries.flatten() {
                    let p = e.path();
                    if p.is_dir() {
                        walk(&p, base, out);
                    } else if let Ok(rel) = p.strip_prefix(base) {
                        out.push((rel.to_string_lossy().to_string(), e.metadata().map(|m| m.len()).unwrap_or(0) as usize));
                    }
                }
            }
        }
        let root = self.project_root();
        let mut out = Vec::new();
        walk(&root, &root, &mut out);
        out.sort();
        out
    }

    /// Write a repo file. Returns the sanitized relative path actually used.
    pub fn write_project_file(&self, rel: &str, content: &str) -> Option<String> {
        let clean = sanitize_repo_path(rel)?;
        let full = self.project_root().join(&clean);
        if let Some(parent) = full.parent() {
            std::fs::create_dir_all(parent).ok();
        }
        std::fs::write(full, content).ok()?;
        Some(clean)
    }
}

/// Repo paths must be relative, stay inside the tree, and look like files.
pub fn sanitize_repo_path(given: &str) -> Option<String> {
    let mut out = String::new();
    let mut prev_sep = true;
    for ch in given.trim().trim_matches('`').trim_matches('"').chars() {
        if ch == '/' || ch == '\\' {
            if !prev_sep {
                out.push('/');
            }
            prev_sep = true;
        } else if ch.is_alphanumeric() || "._-+ #".contains(ch) {
            out.push(ch);
            prev_sep = false;
        } else {
            return None; // quotes, backticks, colons → not a path
        }
    }
    let out = out.trim_matches('/').to_string();
    if out.is_empty() || out.contains("..") || !out.contains('.') && !out.ends_with('/') {
        // allow dirs implicitly only when extension-less but plausible
        if out.is_empty() || out.contains("..") {
            return None;
        }
    }
    Some(out)
}

/// All .md files in a (non-person) workspace subfolder: (filename, size).
pub fn list_files(dir: &Path) -> Vec<(String, usize)> {
    let mut out = Vec::new();
    if let Ok(entries) = std::fs::read_dir(dir) {
        for e in entries.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            if name.ends_with(".md") {
                out.push((name, e.metadata().map(|m| m.len()).unwrap_or(0) as usize));
            }
        }
    }
    out.sort();
    out
}

/// One write-expansion task processed by the coder queue.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum WriteKind {
    /// Task work → work log + repo files.
    WorkLog,
    /// Email intent → full in-character email body.
    Email,
    /// Manager day-1 direction → plan.md.
    Plan,
    /// VLM spot-check: look at an actual captured frame and report.
    Look,
}