//! 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, pub backlog: Vec, /// Optional automated verifier ("build gate") run at end of day + on the /// boss's `judge` console command. #[serde(default)] pub verify: Option, /// 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, } #[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, } #[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, } fn one() -> u32 { 1 } impl CompanyCfg { pub fn load(company_dir: &Path) -> Result { 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 { 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 /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 { 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 { 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 { 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 { 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, }