use std::fs;
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::state;
use crate::util::write_atomic;
pub(crate) const PLAN_TEMPLATE: &str = "\
---
current_phase: \"01\"
---
# Pitboss Plan
Replace this preamble with a description of the work pitboss will orchestrate.
# Phase 01: First phase
**Scope.** Describe what this phase delivers.
**Deliverables.**
- Item
**Acceptance.**
- Criterion
";
const DEFERRED_TEMPLATE: &str = "\
## Deferred items
## Deferred phases
";
const CONFIG_TOML_TEMPLATE: &str = "\
# pitboss configuration
[models]
planner = \"claude-opus-4-7\"
implementer = \"claude-opus-4-7\"
auditor = \"claude-opus-4-7\"
fixer = \"claude-opus-4-7\"
[retries]
fixer_max_attempts = 2
max_phase_attempts = 3
[audit]
enabled = true
small_fix_line_limit = 30
[git]
branch_prefix = \"pitboss/play/\"
create_pr = false
# Caveman mode: opt-in terse-output directive prepended to every agent
# dispatch's system prompt. Cuts output tokens at the cost of slightly
# terser plan/audit/fix prose. Intensity: \"lite\" | \"full\" | \"ultra\".
[caveman]
enabled = false
intensity = \"full\"
# Grind mode: rotating prompt runner.
# - Drop prompt files in .pitboss/grind/prompts/<name>.md (pitboss prompts new
# <name> scaffolds one).
# - Drop rotation files in .pitboss/grind/rotations/<name>.toml; select with
# `pitboss grind --rotation <name>` or set `default_rotation` below.
[grind]
# default_rotation = \"nightly\"
max_parallel = 1
consecutive_failure_limit = 3
";
const GITIGNORE_ENTRY: &str = ".pitboss/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Created,
Skipped,
Updated,
}
#[derive(Debug, Clone)]
pub struct ReportEntry {
pub path: String,
pub action: Action,
}
pub fn run(workspace: impl AsRef<Path>) -> Result<()> {
let workspace = workspace.as_ref();
let mut report: Vec<ReportEntry> = Vec::new();
fs::create_dir_all(workspace)
.with_context(|| format!("init: creating workspace {:?}", workspace))?;
let pitboss_root = workspace.join(".pitboss");
if pitboss_root.exists() && !pitboss_root.is_dir() {
anyhow::bail!(
"init: {:?} exists but is not a directory; refusing to overwrite",
pitboss_root
);
}
write_if_missing(
workspace,
".pitboss/play/plan.md",
PLAN_TEMPLATE.as_bytes(),
&mut report,
)?;
write_if_missing(
workspace,
".pitboss/play/deferred.md",
DEFERRED_TEMPLATE.as_bytes(),
&mut report,
)?;
write_if_missing(
workspace,
".pitboss/config.toml",
CONFIG_TOML_TEMPLATE.as_bytes(),
&mut report,
)?;
ensure_dir(workspace, ".pitboss/play/snapshots", &mut report)?;
ensure_dir(workspace, ".pitboss/play/logs", &mut report)?;
ensure_dir(workspace, ".pitboss/grind/prompts", &mut report)?;
ensure_dir(workspace, ".pitboss/grind/rotations", &mut report)?;
ensure_dir(workspace, ".pitboss/grind/runs", &mut report)?;
init_state_file(workspace, &mut report)?;
update_gitignore(workspace, &mut report)?;
print_summary(&report);
Ok(())
}
fn write_if_missing(
workspace: &Path,
rel: &str,
contents: &[u8],
report: &mut Vec<ReportEntry>,
) -> Result<()> {
let path = workspace.join(rel);
if path.exists() {
warn_skipped(rel);
report.push(ReportEntry {
path: rel.to_string(),
action: Action::Skipped,
});
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("init: creating parent of {:?}", path))?;
}
write_atomic(&path, contents)?;
report.push(ReportEntry {
path: rel.to_string(),
action: Action::Created,
});
Ok(())
}
fn ensure_dir(workspace: &Path, rel: &str, report: &mut Vec<ReportEntry>) -> Result<()> {
let path = workspace.join(rel);
let display = format!("{}/", rel);
if path.is_dir() {
report.push(ReportEntry {
path: display,
action: Action::Skipped,
});
return Ok(());
}
if path.exists() {
anyhow::bail!(
"init: {:?} exists but is not a directory; refusing to overwrite",
path
);
}
fs::create_dir_all(&path).with_context(|| format!("init: creating {:?}", path))?;
report.push(ReportEntry {
path: display,
action: Action::Created,
});
Ok(())
}
fn init_state_file(workspace: &Path, report: &mut Vec<ReportEntry>) -> Result<()> {
let path = state::state_path(workspace);
let rel = ".pitboss/play/state.json".to_string();
if path.exists() {
warn_skipped(&rel);
report.push(ReportEntry {
path: rel,
action: Action::Skipped,
});
return Ok(());
}
state::save(workspace, None)?;
report.push(ReportEntry {
path: rel,
action: Action::Created,
});
Ok(())
}
fn update_gitignore(workspace: &Path, report: &mut Vec<ReportEntry>) -> Result<()> {
let path = workspace.join(".gitignore");
let rel = ".gitignore".to_string();
let existing = match fs::read_to_string(&path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(anyhow::Error::new(e).context(format!("init: reading {:?}", path))),
};
if let Some(ref text) = existing {
if has_pitboss_entry(text) {
report.push(ReportEntry {
path: rel,
action: Action::Skipped,
});
return Ok(());
}
}
let new_contents = append_entry(existing.as_deref());
write_atomic(&path, new_contents.as_bytes())?;
report.push(ReportEntry {
path: rel,
action: if existing.is_some() {
Action::Updated
} else {
Action::Created
},
});
Ok(())
}
fn has_pitboss_entry(text: &str) -> bool {
text.lines().any(|line| {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return false;
}
let canonical = trimmed.trim_start_matches('/').trim_end_matches('/');
canonical == ".pitboss"
})
}
fn append_entry(existing: Option<&str>) -> String {
let mut out = match existing {
Some(s) => s.to_string(),
None => String::new(),
};
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(GITIGNORE_ENTRY);
out.push('\n');
out
}
fn warn_skipped(rel: &str) {
use crate::style::{self, col};
let c = style::use_color_stderr();
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let _ = writeln!(
handle,
"{} {} already exists, leaving it alone",
col(c, style::BOLD_YELLOW, "warning:"),
rel
);
}
fn print_summary(report: &[ReportEntry]) {
use crate::style::{self, col};
let c = style::use_color_stdout();
let stdout = std::io::stdout();
let mut handle = stdout.lock();
for entry in report {
let line = match entry.action {
Action::Created => format!("{} {}", col(c, style::GREEN, "created"), entry.path),
Action::Skipped => format!(
"{} {} {}",
col(c, style::DARK_GRAY, "skipped"),
entry.path,
col(c, style::DIM, "(already exists)")
),
Action::Updated => format!("{} {}", col(c, style::YELLOW, "updated"), entry.path),
};
let _ = writeln!(handle, "{}", line);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn paths_with(report: &[ReportEntry], action: Action) -> Vec<&str> {
report
.iter()
.filter(|e| e.action == action)
.map(|e| e.path.as_str())
.collect()
}
#[test]
fn fresh_workspace_creates_every_artifact() {
let dir = tempdir().unwrap();
run(dir.path()).unwrap();
for rel in [
".pitboss",
".pitboss/config.toml",
".pitboss/play",
".pitboss/play/plan.md",
".pitboss/play/deferred.md",
".pitboss/play/state.json",
".pitboss/play/snapshots",
".pitboss/play/logs",
".pitboss/grind",
".pitboss/grind/prompts",
".pitboss/grind/rotations",
".pitboss/grind/runs",
".gitignore",
] {
assert!(
dir.path().join(rel).exists(),
"expected {:?} to be created",
rel
);
}
let plan_text = fs::read_to_string(dir.path().join(".pitboss/play/plan.md")).unwrap();
let plan = crate::plan::parse(&plan_text).expect("seed plan.md must parse");
assert_eq!(plan.current_phase.as_str(), "01");
let deferred_text =
fs::read_to_string(dir.path().join(".pitboss/play/deferred.md")).unwrap();
crate::deferred::parse(&deferred_text).expect("seed deferred.md must parse");
assert!(state::load(dir.path()).unwrap().is_none());
}
#[test]
fn rerun_is_idempotent_and_skips_everything() {
let dir = tempdir().unwrap();
run(dir.path()).unwrap();
let snapshot_paths = [
".pitboss/config.toml",
".pitboss/play/plan.md",
".pitboss/play/deferred.md",
".pitboss/play/state.json",
".gitignore",
];
let before: Vec<Vec<u8>> = snapshot_paths
.iter()
.map(|p| fs::read(dir.path().join(p)).unwrap())
.collect();
run(dir.path()).unwrap();
let after: Vec<Vec<u8>> = snapshot_paths
.iter()
.map(|p| fs::read(dir.path().join(p)).unwrap())
.collect();
assert_eq!(before, after, "rerun must not modify any artifact");
}
#[test]
fn preexisting_plan_md_survives_byte_for_byte() {
let dir = tempdir().unwrap();
let custom = "---\ncurrent_phase: \"05\"\n---\n\n# Phase 05: Custom\n\nbody.\n";
let plan_path = dir.path().join(".pitboss/play/plan.md");
fs::create_dir_all(plan_path.parent().unwrap()).unwrap();
fs::write(&plan_path, custom).unwrap();
run(dir.path()).unwrap();
let after = fs::read_to_string(&plan_path).unwrap();
assert_eq!(after, custom);
}
#[test]
fn gitignore_is_created_with_pitboss_entry() {
let dir = tempdir().unwrap();
run(dir.path()).unwrap();
let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gi.contains(".pitboss/"));
}
#[test]
fn gitignore_is_appended_when_entry_missing() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".gitignore"), "/target\n").unwrap();
run(dir.path()).unwrap();
let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gi.starts_with("/target\n"));
assert!(gi.contains(".pitboss/"));
}
#[test]
fn gitignore_entry_recognized_in_several_forms() {
for line in [".pitboss", ".pitboss/", "/.pitboss", "/.pitboss/"] {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(".gitignore"),
format!("/target\n{}\n", line),
)
.unwrap();
run(dir.path()).unwrap();
let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let occurrences = gi
.lines()
.filter(|l| {
let t = l.trim().trim_start_matches('/').trim_end_matches('/');
t == ".pitboss"
})
.count();
assert_eq!(occurrences, 1, "input form {:?}, full file: {:?}", line, gi);
}
}
#[test]
fn gitignore_idempotent_across_many_runs() {
let dir = tempdir().unwrap();
for _ in 0..3 {
run(dir.path()).unwrap();
}
let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let occurrences = gi
.lines()
.filter(|l| l.trim().trim_start_matches('/').trim_end_matches('/') == ".pitboss")
.count();
assert_eq!(occurrences, 1);
}
#[test]
fn rejects_non_directory_at_dot_pitboss() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".pitboss"), b"oops").unwrap();
let err = run(dir.path()).unwrap_err();
assert!(err.to_string().contains("is not a directory"));
}
#[test]
fn report_describes_skipped_files() {
let dir = tempdir().unwrap();
let plan_path = dir.path().join(".pitboss/play/plan.md");
fs::create_dir_all(plan_path.parent().unwrap()).unwrap();
fs::write(&plan_path, "preexisting\n").unwrap();
let mut report = Vec::new();
write_if_missing(
dir.path(),
".pitboss/play/plan.md",
PLAN_TEMPLATE.as_bytes(),
&mut report,
)
.unwrap();
write_if_missing(
dir.path(),
".pitboss/play/deferred.md",
DEFERRED_TEMPLATE.as_bytes(),
&mut report,
)
.unwrap();
assert_eq!(
paths_with(&report, Action::Skipped),
vec![".pitboss/play/plan.md"]
);
assert_eq!(
paths_with(&report, Action::Created),
vec![".pitboss/play/deferred.md"]
);
}
}