//! 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,
}