diff --git a/crates/meshloop-cli/src/args.rs b/crates/meshloop-cli/src/args.rs index 9003cc4..d98da59 100644 --- a/crates/meshloop-cli/src/args.rs +++ b/crates/meshloop-cli/src/args.rs @@ -21,6 +21,7 @@ pub enum Command { Roles, Doctor { config: Option, + db: Option, }, Status { graph: Option, @@ -108,6 +109,7 @@ pub enum Command { }, Bundle { dest: PathBuf, + gitignore: bool, }, AstSkeleton { path: String, @@ -168,6 +170,7 @@ fn parse_command(args: &[String]) -> Result { Some(v) if v == "roles" => Ok(Command::Roles), Some(v) if v == "doctor" => Ok(Command::Doctor { config: flag_value(&args[1..], "--config").map(PathBuf::from), + db: flag_value(&args[1..], "--db").map(PathBuf::from), }), Some(v) if v == "plan" => { let rest = &args[1..]; @@ -348,6 +351,7 @@ fn parse_command(args: &[String]) -> Result { dest: flag_value(rest, "--dest") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("dist/meshloop-session-bundle")), + gitignore: has_flag(rest, "--gitignore"), }) } Some(v) if v == "ast-skeleton" => { @@ -411,11 +415,11 @@ pub fn help_text() -> &'static str { \x20 meshloop resume [--graph ] [--retry|--restart] [--json]\n\ \x20 meshloop status [--graph ] [--json]\n\ \x20 meshloop roles [--json]\n\ - \x20 meshloop doctor [--config ] [--json]\n\ + \x20 meshloop doctor [--config ] [--db ] [--json]\n\ \x20 meshloop orchestrate --task --model-a --model-b [--json]\n\ \x20 meshloop mutate-plan [--graph ] (--mutation | --mutation-file ) [--require-review] [--json]\n\ \x20 meshloop mcp\n\ - \x20 meshloop bundle [--dest ]\n\ + \x20 meshloop bundle [--dest ] [--gitignore]\n\ Origin (supervisor-only): --origin-harness --origin-session \n\ \x20 or MESHLOOP_ORIGIN_HARNESS / MESHLOOP_ORIGIN_SESSION.\n\ Live workers are the default (Herdr). --fixture-only forces the CI subprocess double." diff --git a/crates/meshloop-cli/src/compose.rs b/crates/meshloop-cli/src/compose.rs index 034727b..116f99c 100644 --- a/crates/meshloop-cli/src/compose.rs +++ b/crates/meshloop-cli/src/compose.rs @@ -151,6 +151,7 @@ pub fn compose(req: ComposeRequest<'_>) -> Result { .map(Path::to_path_buf) .unwrap_or_else(|| default_db(&req.repo_root)); let store = SqliteStore::open(&db).map_err(|e: StoreError| format!("{e:?}"))?; + crate::gitignore::ensure_store_ignored(&db); let wt = req .worktree_base .unwrap_or_else(|| default_worktree_base(&req.repo_root)); diff --git a/crates/meshloop-cli/src/gitignore.rs b/crates/meshloop-cli/src/gitignore.rs new file mode 100644 index 0000000..c943f9d --- /dev/null +++ b/crates/meshloop-cli/src/gitignore.rs @@ -0,0 +1,152 @@ +//! Keeps the local `.meshloop/` store out of consuming repositories' Git status. +//! Probes use `git check-ignore` with structured arguments; nothing here edits a root +//! `.gitignore` except the explicit, opt-in `bundle --gitignore` block. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub const STORE_DIR: &str = ".meshloop"; +pub const STORE_PROBE: &str = ".meshloop/state.sqlite"; +pub const IGNORE_LINE: &str = "/.meshloop/"; +const BLOCK_BEGIN: &str = "# >>> meshloop >>>"; +const BLOCK_END: &str = "# <<< meshloop <<<"; + +/// Result of asking Git whether a path is ignored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ignored { + Yes, + No, + /// Not inside a Git work tree (or Git is unavailable): nothing to flag. + NotApplicable, +} + +fn git_ok(cwd: &Path, args: &[&str]) -> Option<(bool, i32)> { + let status = Command::new("git") + .current_dir(cwd) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok()?; + Some((status.success(), status.code().unwrap_or(-1))) +} + +/// `git check-ignore -q ` run in `cwd`: exit 0 ignored, 1 not ignored, else n/a. +pub fn check_ignored(cwd: &Path, rel: &str) -> Ignored { + match git_ok(cwd, &["check-ignore", "-q", "--", rel]) { + Some((true, _)) => Ignored::Yes, + Some((false, 1)) => Ignored::No, + _ => Ignored::NotApplicable, + } +} + +fn in_work_tree(dir: &Path) -> bool { + let out = Command::new("git") + .current_dir(dir) + .args(["rev-parse", "--is-inside-work-tree"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output(); + matches!(out, Ok(o) if o.status.success() && o.stdout.starts_with(b"true")) +} + +/// Ignore status of a store file. Paths outside any work tree are `NotApplicable`. +pub fn store_status(db: &Path) -> Ignored { + let Ok(db) = std::path::absolute(db) else { + return Ignored::NotApplicable; + }; + let db = db.as_path(); + let Some(parent) = db.parent() else { + return Ignored::NotApplicable; + }; + let Some(base) = parent.ancestors().find(|a| a.is_dir()) else { + return Ignored::NotApplicable; + }; + if !in_work_tree(base) { + return Ignored::NotApplicable; + } + match db.strip_prefix(base) { + Ok(rel) if !rel.as_os_str().is_empty() => check_ignored(base, &rel.to_string_lossy()), + _ => Ignored::NotApplicable, + } +} + +/// `store_ignored` for `doctor`: true when ignored or not applicable. +pub fn store_ignored(db: &Path) -> bool { + store_status(db) != Ignored::No +} + +/// After the store is created: if it sits in `.meshloop/` inside a work tree and is not +/// ignored, write `.meshloop/.gitignore` (`*`) and notify once on stderr. Never touches +/// the root `.gitignore`. +pub fn ensure_store_ignored(db: &Path) { + let Some(dir) = db.parent() else { return }; + if dir.file_name().is_none_or(|n| n != STORE_DIR) || store_status(db) != Ignored::No { + return; + } + let marker = dir.join(".gitignore"); + if marker.exists() { + return; + } + match fs::write(&marker, "*\n") { + Ok(()) => eprintln!( + "meshloop: wrote {} so the local store is not tracked by Git (one-time notice)", + marker.display() + ), + Err(e) => eprintln!("warning: could not write {}: {e}", marker.display()), + } +} + +/// Outcome of the `bundle` ignore check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BundleIgnore { + Present, + Missing, + Added, + NotApplicable, +} + +impl BundleIgnore { + pub fn as_str(&self) -> &'static str { + match self { + Self::Present => "present", + Self::Missing => "missing", + Self::Added => "added", + Self::NotApplicable => "not-applicable", + } + } +} + +fn marked_block() -> String { + format!("{BLOCK_BEGIN}\n{IGNORE_LINE}\n{BLOCK_END}\n") +} + +/// Checks `repo` and, only when `apply` is set, appends the marked block (idempotent). +pub fn bundle_check(repo: &Path, apply: bool) -> Result { + match check_ignored(repo, STORE_PROBE) { + Ignored::Yes => return Ok(BundleIgnore::Present), + Ignored::NotApplicable => return Ok(BundleIgnore::NotApplicable), + Ignored::No => {} + } + if !apply { + return Ok(BundleIgnore::Missing); + } + let path: PathBuf = repo.join(".gitignore"); + let existing = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(format!("cannot read {}: {e}", path.display())), + }; + if existing.contains(BLOCK_BEGIN) { + return Ok(BundleIgnore::Present); + } + let mut next = existing; + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + next.push_str(&marked_block()); + fs::write(&path, next).map_err(|e| format!("cannot write {}: {e}", path.display()))?; + Ok(BundleIgnore::Added) +} diff --git a/crates/meshloop-cli/src/main.rs b/crates/meshloop-cli/src/main.rs index 70a408d..09db308 100644 --- a/crates/meshloop-cli/src/main.rs +++ b/crates/meshloop-cli/src/main.rs @@ -6,6 +6,7 @@ mod args; mod compose; mod config; mod context_tools; +mod gitignore; mod json_out; mod mcp; mod report; @@ -81,7 +82,7 @@ fn dispatch(inv: Invocation) -> ExitCode { } Command::Mcp => ExitCode::from(mcp::run_stdio() as u8), Command::Roles => cmd_roles(inv.json, inv.origin), - Command::Doctor { config } => cmd_doctor(config, inv.json, inv.origin), + Command::Doctor { config, db } => cmd_doctor(config, db, inv.json, inv.origin), Command::Status { graph } => cmd_status(graph, inv.json, inv.origin), Command::Plan { objective, @@ -235,7 +236,7 @@ fn dispatch(inv: Invocation) -> ExitCode { inv.json, inv.origin, ), - Command::Bundle { dest } => cmd_bundle(dest, inv.json, inv.origin), + Command::Bundle { dest, gitignore } => cmd_bundle(dest, gitignore, inv.json, inv.origin), Command::AstSkeleton { path } => cmd_ast_skeleton(&path, inv.json, inv.origin), Command::SymbolLookup { query, k, scope } => { cmd_symbol_lookup(&query, k, scope.as_deref(), inv.json, inv.origin) @@ -370,6 +371,10 @@ fn cmd_plan( eprintln!("Run `meshloop doctor --config ` for per-harness readiness."); return ExitCode::from(1); } + Err(OrchestratorError::Plan(e)) => { + eprintln!("Decomposition failed: {e}"); + return ExitCode::from(1); + } Err(e) => { eprintln!("Decomposition failed: {e:?}"); return ExitCode::from(1); @@ -542,7 +547,12 @@ fn cmd_review_plan( ) { Ok(g) => graph = g, Err(e) => { - eprintln!("Adjust decomposition failed: {e:?}"); + match &e { + OrchestratorError::Plan(p) => { + eprintln!("Adjust decomposition failed: {p}") + } + _ => eprintln!("Adjust decomposition failed: {e:?}"), + } return ExitCode::from(1); } } @@ -1371,9 +1381,16 @@ fn cmd_symbol_lookup( } } -fn cmd_bundle(dest: PathBuf, json: bool, origin: meshloop_engine::origin::Origin) -> ExitCode { - match session_bundle::write_to(&dest) { - Ok(files) => { +fn cmd_bundle( + dest: PathBuf, + gitignore: bool, + json: bool, + origin: meshloop_engine::origin::Origin, +) -> ExitCode { + let written = session_bundle::write_to(&dest) + .and_then(|files| gitignore::bundle_check(&dest, gitignore).map(|g| (files, g))); + match written { + Ok((files, ignore)) => { if json { let paths: Vec = files .iter() @@ -1388,11 +1405,27 @@ fn cmd_bundle(dest: PathBuf, json: bool, origin: meshloop_engine::origin::Origin "dest": dest.to_string_lossy(), "version": env!("CARGO_PKG_VERSION"), "files": paths, + "gitignore": ignore.as_str(), + "gitignore_line": gitignore::IGNORE_LINE, }), ) ); } else { println!("bundled to {}", dest.display()); + println!("gitignore: {}", ignore.as_str()); + if ignore == gitignore::BundleIgnore::Missing { + eprintln!( + "add this line to {}/.gitignore (or rerun with --gitignore): {}", + dest.display(), + gitignore::IGNORE_LINE + ); + } + } + if json && ignore == gitignore::BundleIgnore::Missing { + eprintln!( + "gitignore: missing; add `{}` to .gitignore (or rerun with --gitignore)", + gitignore::IGNORE_LINE + ); } ExitCode::SUCCESS } @@ -1438,6 +1471,7 @@ fn cmd_roles(json: bool, origin: meshloop_engine::origin::Origin) -> ExitCode { fn cmd_doctor( config: Option, + db: Option, json: bool, origin: meshloop_engine::origin::Origin, ) -> ExitCode { @@ -1466,6 +1500,8 @@ fn cmd_doctor( }, }; let harnesses_ready = error.is_none(); + let store_ignored = + gitignore::store_ignored(&db.unwrap_or_else(|| compose::default_db(&repo_root()))); let data = serde_json::json!({ "daemonless": true, @@ -1481,6 +1517,7 @@ fn cmd_doctor( "config": config_path.as_ref().map(|p| p.display().to_string()), "harnesses_ready": harnesses_ready, "harnesses": harnesses, + "store_ignored": store_ignored, "note": "Doctor loads the config and runs each selected harness's bounded version probe. Live workers run as direct CLI subprocesses in Git worktrees.", }); if json { @@ -1507,6 +1544,13 @@ fn cmd_doctor( }; println!(" harness {}: {status}", h["name"].as_str().unwrap_or("?")); } + println!(" store_ignored: {store_ignored}"); + if !store_ignored { + println!( + " fix: add `{}` to .gitignore, or run `meshloop bundle --dest . --gitignore`", + gitignore::IGNORE_LINE + ); + } if let Some(e) = &error { println!(" error: {e}"); } diff --git a/crates/meshloop-cli/tests/store_gitignore.rs b/crates/meshloop-cli/tests/store_gitignore.rs new file mode 100644 index 0000000..8edd28d --- /dev/null +++ b/crates/meshloop-cli/tests/store_gitignore.rs @@ -0,0 +1,224 @@ +//! Issue #44: the local `.meshloop/` store must stay out of consuming repositories' Git +//! status. Uses the fixture harness only. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn fixture_path() -> String { + let exe = format!("fixture_harness{}", std::env::consts::EXE_SUFFIX); + let mut dir = std::env::current_exe().expect("current test executable path"); + dir.pop(); + dir.pop(); + let path = dir.join(&exe); + assert!( + path.exists(), + "expected fixture harness at {}", + path.display() + ); + path.to_string_lossy().to_string() +} + +fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .expect("run git"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn temp_repo(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("meshloop-gi-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + git(&dir, &["init", "-q"]); + git(&dir, &["config", "user.email", "test@test"]); + git(&dir, &["config", "user.name", "test"]); + // Isolate from the developer's global excludes file. + git(&dir, &["config", "core.excludesFile", ""]); + fs::write(dir.join("README.md"), "seed").unwrap(); + git(&dir, &["add", "."]); + git(&dir, &["commit", "-q", "-m", "seed"]); + dir +} + +fn write_config(dir: &Path) -> PathBuf { + let path = dir.join("meshloop.toml"); + let contents = format!( + r#" +selected_harnesses = ["fixture"] + +[limits] +max_concurrent_workers = 1 +max_retries = 2 +task_timeout_seconds = 30 + +[verify] +verify_command = [] + +[harnesses.fixture] +executable = "{}" +version_args = ["--version"] +invoke_args_template = ["--emit-graph"] +model_ref = "fixture-model" +model_tier = "top" +"#, + fixture_path().replace('\\', "\\\\") + ); + fs::write(&path, contents).unwrap(); + path +} + +fn meshloop(dir: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_meshloop")); + c.current_dir(dir); + c +} + +fn doctor_store_ignored(dir: &Path, cfg: &Path, db: Option<&Path>) -> bool { + let mut c = meshloop(dir); + c.args(["doctor", "--json", "--config"]).arg(cfg); + if let Some(db) = db { + c.arg("--db").arg(db); + } + let out = c.output().unwrap(); + let v: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("doctor --json prints JSON"); + v["data"]["store_ignored"] + .as_bool() + .expect("store_ignored is a bool") +} + +#[test] +fn plan_in_fresh_repo_never_shows_store_as_untracked() { + let dir = temp_repo("plan"); + let cfg = write_config(&dir); + let out = meshloop(&dir) + .args(["plan", "--fixture-only", "--objective", "x", "--config"]) + .arg(&cfg) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(dir.join(".meshloop").join("state.sqlite").exists()); + assert_eq!( + fs::read_to_string(dir.join(".meshloop").join(".gitignore")).unwrap(), + "*\n" + ); + assert!( + !dir.join(".gitignore").exists(), + "root .gitignore untouched" + ); + let status = git(&dir, &["status", "--porcelain", "-uall"]); + assert!(!status.contains(".meshloop"), "status: {status}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains(".gitignore"), + "one-time notice expected on stderr" + ); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn bundle_reports_missing_and_only_edits_with_flag() { + let dir = temp_repo("bundle"); + let root_ignore = dir.join(".gitignore"); + fs::write(&root_ignore, "target/").unwrap(); + let before = fs::read(&root_ignore).unwrap(); + + let out = meshloop(&dir) + .args(["bundle", "--dest", ".", "--json"]) + .output() + .unwrap(); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["data"]["gitignore"], "missing"); + assert_eq!(v["data"]["gitignore_line"], "/.meshloop/"); + assert_eq!(fs::read(&root_ignore).unwrap(), before); + + let human = meshloop(&dir) + .args(["bundle", "--dest", "."]) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&human.stdout).contains("gitignore: missing")); + assert_eq!(fs::read(&root_ignore).unwrap(), before); + + let out = meshloop(&dir) + .args(["bundle", "--dest", ".", "--gitignore", "--json"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["data"]["gitignore"], "added"); + let after = fs::read_to_string(&root_ignore).unwrap(); + assert_eq!(after.matches("# >>> meshloop >>>").count(), 1); + assert!(after.contains("/.meshloop/")); + assert!(after.starts_with("target/\n")); + + let out = meshloop(&dir) + .args(["bundle", "--dest", ".", "--gitignore", "--json"]) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["data"]["gitignore"], "present"); + assert_eq!(fs::read_to_string(&root_ignore).unwrap(), after); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn doctor_reports_store_ignored_before_and_after() { + let dir = temp_repo("doctor"); + let cfg = write_config(&dir); + assert!(!doctor_store_ignored(&dir, &cfg, None)); + let human = meshloop(&dir) + .args(["doctor", "--config"]) + .arg(&cfg) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&human.stdout).contains("/.meshloop/")); + + let out = meshloop(&dir) + .args(["plan", "--objective", "x", "--config"]) + .arg(&cfg) + .output() + .unwrap(); + assert!(out.status.success()); + assert!(doctor_store_ignored(&dir, &cfg, None)); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn custom_db_outside_work_tree_is_not_flagged_or_modified() { + let dir = temp_repo("custom"); + let cfg = write_config(&dir); + let outside = std::env::temp_dir().join(format!("meshloop-gi-out-{}", std::process::id())); + let _ = fs::remove_dir_all(&outside); + let db = outside.join(".meshloop").join("state.sqlite"); + assert!(doctor_store_ignored(&dir, &cfg, Some(&db))); + + let out = meshloop(&dir) + .args(["plan", "--objective", "x", "--config"]) + .arg(&cfg) + .arg("--db") + .arg(&db) + .output() + .unwrap(); + assert!(out.status.success()); + assert!(db.exists()); + assert!(!outside.join(".meshloop").join(".gitignore").exists()); + assert!(doctor_store_ignored(&dir, &cfg, Some(&db))); + fs::remove_dir_all(&dir).ok(); + fs::remove_dir_all(&outside).ok(); +} + +#[test] +fn install_docs_list_the_ignore_step() { + let docs = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs"); + for name in ["install.md", "start.md"] { + let text = fs::read_to_string(docs.join(name)).unwrap(); + assert!(text.contains("/.meshloop/"), "{name} missing ignore step"); + assert!(text.contains("--gitignore"), "{name} missing --gitignore"); + } +} diff --git a/crates/meshloop-domain/src/task_graph.rs b/crates/meshloop-domain/src/task_graph.rs index 534a780..670870f 100644 --- a/crates/meshloop-domain/src/task_graph.rs +++ b/crates/meshloop-domain/src/task_graph.rs @@ -14,6 +14,21 @@ pub enum Tier { Tier3, } +impl Tier { + /// Every variant, in declaration order. `name` matches exhaustively, so adding a + /// variant fails to compile there until it is handled and listed here. + pub const ALL: [Tier; 3] = [Tier::Tier1, Tier::Tier2, Tier::Tier3]; + + /// The exact string serde accepts for this variant in a plan file. + pub fn name(self) -> &'static str { + match self { + Tier::Tier1 => "Tier1", + Tier::Tier2 => "Tier2", + Tier::Tier3 => "Tier3", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TaskNode { pub id: TaskId, @@ -277,6 +292,16 @@ mod tests { } } + #[test] + fn tier_all_and_name_match_variant_names() { + for tier in Tier::ALL { + // serde's default representation of a unit variant is its Debug name. + assert_eq!(format!("{tier:?}"), tier.name()); + } + let distinct: HashSet<_> = Tier::ALL.iter().map(|t| t.name()).collect(); + assert_eq!(distinct.len(), Tier::ALL.len()); + } + #[test] fn rejects_empty_graph() { let g = TaskGraph { diff --git a/crates/meshloop-engine/src/agent.rs b/crates/meshloop-engine/src/agent.rs index c6b7efb..ecaa019 100644 --- a/crates/meshloop-engine/src/agent.rs +++ b/crates/meshloop-engine/src/agent.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::time::Duration; use meshloop_domain::evidence::AttemptId; -use meshloop_domain::task_graph::{TaskGraph, TaskId, TaskNode}; +use meshloop_domain::task_graph::{TaskGraph, TaskId, TaskNode, Tier}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentSpec { @@ -175,6 +175,46 @@ pub fn build_repair_spec( } } +/// The planner's output contract: every `TaskNode` field with its allowed values, the +/// accepted tier names (taken from the domain type so they cannot drift), and a one-node +/// example serialized from the domain types. +fn planning_output_contract() -> String { + let tiers = Tier::ALL + .iter() + .map(|t| t.name()) + .collect::>() + .join("|"); + let example = TaskGraph { + graph_id: "example-graph".into(), + nodes: vec![TaskNode { + id: TaskId(1), + description: "Add the parse_widget function with unit tests".into(), + depends_on: vec![], + tier: Some(Tier::Tier1), + allowed_paths: vec!["crates/widgets/src/".into()], + empty_diff_ok: false, + }], + }; + let example = serde_json::to_string_pretty(&example).unwrap_or_default(); + format!( + "Write exactly one JSON TaskGraph to meshloop-plan.json in this worktree. \ + Unknown values are rejected, so use exactly these fields.\n\ + graph_id: string matching [A-Za-z0-9._-]+ (no slashes or spaces).\n\ + nodes: non-empty array; each node has:\n\ + - id: integer >= 1, unique within the graph.\n\ + - description: string, what this task must accomplish.\n\ + - depends_on: array of ids of other nodes that must finish first ([] if none); no cycles.\n\ + - tier: exactly one of {tiers}, or omit it (null) to let Meshloop assign one. \ + Never a role or phase name such as \"analysis\" or \"implementation\".\n\ + - allowed_paths: array of repo-relative path prefixes the task may change \ + (e.g. \"crates/foo/src/\"); omit or [] for no restriction.\n\ + - empty_diff_ok: boolean, default false. Set true only for a node that legitimately \ + produces no diff (e.g. a pure analysis or verification step); otherwise leave false.\n\ + Example of a valid one-node graph:\n{example}\n\ + No other files unless required to produce that graph. Do not print prose." + ) +} + /// The decomposition dispatch itself (ADR 0009/runtime-design.md §5) is an AgentSpec whose /// declared output contract is a task graph, not a worktree diff. pub fn build_planning_spec( @@ -190,11 +230,7 @@ pub fn build_planning_spec( situation: scope_and_exclusions.to_string(), complication: objective.to_string(), question: "Decompose this objective into a versioned task graph.".into(), - output_contract: "Write exactly one JSON TaskGraph to meshloop-plan.json in this worktree \ - (graph_id matching [A-Za-z0-9._-]+, nodes[] with id >= 1, description, \ - depends_on, optional tier, optional allowed_paths, optional empty_diff_ok). \ - No other files unless required to produce that graph. Do not print prose." - .into(), + output_contract: planning_output_contract(), }); AgentSpec { task_id: TaskId(0), @@ -298,6 +334,38 @@ mod tests { assert!(spec.prompt.contains("meshloop-plan.json")); } + #[test] + fn planning_prompt_lists_every_tier_variant_and_task_node_field() { + let spec = build_planning_spec( + "o", + "s", + AttemptId(1), + "claude-code", + "m", + PathBuf::from("/tmp/wt"), + Duration::from_secs(300), + ); + for tier in Tier::ALL { + assert!(spec.prompt.contains(tier.name()), "missing {tier:?}"); + } + let value = serde_json::to_value(node(1, "d", &[])).unwrap(); + let fields: Vec<&String> = value.as_object().unwrap().keys().collect(); + assert_eq!( + fields.len(), + 6, + "TaskNode gained a field; update the contract" + ); + for field in fields { + assert!( + spec.prompt.contains(&format!("- {field}:")), + "contract does not describe `{field}`" + ); + } + assert!(spec.prompt.contains("[A-Za-z0-9._-]+")); + assert!(spec.prompt.contains("empty_diff_ok")); + assert!(spec.prompt.contains("\"graph_id\": \"example-graph\"")); + } + #[test] fn built_spec_with_context_includes_ast_skeletons() { let graph = TaskGraph { diff --git a/crates/meshloop-engine/src/planner.rs b/crates/meshloop-engine/src/planner.rs index c9785bf..54beac3 100644 --- a/crates/meshloop-engine/src/planner.rs +++ b/crates/meshloop-engine/src/planner.rs @@ -3,22 +3,45 @@ //! and assigns tiers — it never judges decomposition *quality* (MECE-ness), which this //! project cannot mechanically verify; that is the `awaiting-plan-review` human gate's job. +use std::path::{Path, PathBuf}; + use meshloop_domain::capability::HarnessError; +use meshloop_domain::evidence::AttemptId; use meshloop_domain::task_graph::{GraphError, TaskGraph, Tier}; use crate::agent::AgentSpec; use crate::ports::{HarnessCapabilities, HarnessOutcome}; -fn parse_graph(spec: &AgentSpec, outcome: &HarnessOutcome) -> Result { - let file_path = spec.worktree_path.join("meshloop-plan.json"); +const PLAN_FILE: &str = "meshloop-plan.json"; + +/// A plan the planner produced but Meshloop rejected, with the text that was rejected. +struct Rejected { + error: PlanError, + raw: Option, +} + +impl From for Rejected { + fn from(error: PlanError) -> Self { + Self { error, raw: None } + } +} + +fn parse_graph(spec: &AgentSpec, outcome: &HarnessOutcome) -> Result { + let file_path = spec.worktree_path.join(PLAN_FILE); let raw = if file_path.is_file() { std::fs::read_to_string(&file_path).map_err(|e| PlanError::Malformed(e.to_string()))? } else { outcome.output_redacted.clone() }; + let reject = |error: PlanError| Rejected { + error, + raw: Some(raw.clone()), + }; let graph: TaskGraph = - serde_json::from_str(&raw).map_err(|e| PlanError::Malformed(e.to_string()))?; - graph.validate().map_err(PlanError::Invalid)?; + serde_json::from_str(&raw).map_err(|e| reject(PlanError::Malformed(e.to_string())))?; + graph + .validate() + .map_err(|e| reject(PlanError::Invalid(e)))?; Ok(graph) } @@ -27,6 +50,94 @@ pub enum PlanError { Dispatch(HarnessError), Malformed(String), Invalid(GraphError), + /// Every allowed attempt produced a rejected plan. `saved_plan` is where the last one + /// was preserved for inspection, when it could be saved. + Exhausted { + attempts: u32, + last: Box, + saved_plan: Option, + }, +} + +impl std::fmt::Display for PlanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PlanError::Dispatch(e) => write!(f, "planner dispatch failed: {e:?}"), + PlanError::Malformed(e) => write!(f, "plan is malformed: {e}"), + PlanError::Invalid(e) => write!(f, "plan is invalid: {e:?}"), + PlanError::Exhausted { + attempts, + last, + saved_plan, + } => { + write!( + f, + "planner produced a rejected plan on all {attempts} attempt(s); last error: {last}" + )?; + match saved_plan { + Some(p) => write!(f, "; last rejected plan saved to {}", p.display()), + None => write!(f, "; the rejected plan could not be saved"), + } + } + } + } +} + +impl std::error::Error for PlanError {} + +/// Dispatches the decomposition agent, retrying a rejected (malformed or structurally +/// invalid) plan as a new attempt. `max_retries` is the total attempt budget, as for tasks +/// (`[limits] max_retries`: the first dispatch counts; 0 is treated as 1). Each retry prompt carries +/// the previous error. Dispatch errors are not retried here. The last rejected plan is +/// copied under `rejected_dir` and its path reported in the final error. +pub fn decompose_with_retry( + harness: &dyn HarnessCapabilities, + spec: &AgentSpec, + max_retries: u32, + rejected_dir: &Path, +) -> Result { + let total = max_retries.max(1); + let mut attempt_spec = spec.clone(); + for n in 0..total { + attempt_spec.attempt_id = AttemptId(spec.attempt_id.0.saturating_add(n)); + // A stale file from a previous attempt must never be mistaken for this one's output. + let _ = std::fs::remove_file(spec.worktree_path.join(PLAN_FILE)); + let rejected = match decompose_inner(harness, &attempt_spec) { + Ok(graph) => return Ok(graph), + Err(r) => r, + }; + if !matches!( + rejected.error, + PlanError::Malformed(_) | PlanError::Invalid(_) + ) { + return Err(rejected.error); + } + if n + 1 == total { + let saved_plan = save_rejected(rejected_dir, rejected.raw.as_deref()); + return Err(PlanError::Exhausted { + attempts: total, + last: Box::new(rejected.error), + saved_plan, + }); + } + attempt_spec.prompt = format!( + "{}\n\nyour previous graph failed: {}\nFix exactly that problem and rewrite {PLAN_FILE}.", + spec.prompt, rejected.error + ); + } + unreachable!("total is at least 1 and the last iteration returns") +} + +fn save_rejected(dir: &Path, raw: Option<&str>) -> Option { + let raw = raw?; + std::fs::create_dir_all(dir).ok()?; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let path = dir.join(format!("rejected-plan-{stamp}-{}.json", std::process::id())); + std::fs::write(&path, raw).ok()?; + Some(path) } /// Dispatches the decomposition agent and returns a structurally-validated graph, or a @@ -36,6 +147,13 @@ pub fn decompose( harness: &dyn HarnessCapabilities, spec: &AgentSpec, ) -> Result { + decompose_inner(harness, spec).map_err(|r| r.error) +} + +fn decompose_inner( + harness: &dyn HarnessCapabilities, + spec: &AgentSpec, +) -> Result { match harness.invoke(spec) { Ok(handle) => { let outcome: HarnessOutcome = harness.collect(&handle).map_err(PlanError::Dispatch)?; @@ -53,7 +171,7 @@ pub fn decompose( return Ok(graph); } } - Err(PlanError::Dispatch(e)) + Err(PlanError::Dispatch(e).into()) } } } @@ -178,6 +296,114 @@ mod tests { )); } + /// Writes each scripted response to the worktree's plan file on invoke and records the + /// prompt it was given. + struct ScriptedPlanner { + responses: Vec, + prompts: std::sync::Mutex>, + } + + impl HarnessCapabilities for ScriptedPlanner { + fn probe(&self) -> Result { + unimplemented!() + } + fn invoke(&self, spec: &AgentSpec) -> Result { + let mut prompts = self.prompts.lock().unwrap(); + let idx = prompts.len().min(self.responses.len() - 1); + prompts.push(spec.prompt.clone()); + std::fs::write(spec.worktree_path.join(PLAN_FILE), &self.responses[idx]).unwrap(); + Ok(HarnessHandle { + attempt_id: spec.attempt_id, + pid: None, + pane_id: None, + }) + } + fn cancel(&self, _handle: &HarnessHandle) -> Result<(), HarnessError> { + Ok(()) + } + fn collect(&self, _handle: &HarnessHandle) -> Result { + Ok(HarnessOutcome { + exit_code: 0, + output_redacted: String::new(), + worktree_changed: true, + }) + } + } + + const VALID: &str = + r#"{"graph_id":"g","nodes":[{"id":1,"description":"d","depends_on":[],"tier":"Tier1"}]}"#; + const BAD_TIER: &str = r#"{"graph_id":"g","nodes":[{"id":1,"description":"d","depends_on":[],"tier":"analysis"}]}"#; + + fn scratch(name: &str) -> (PathBuf, AgentSpec) { + let dir = std::env::temp_dir().join(format!("meshloop-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let spec = crate::agent::build_planning_spec( + "objective", + "scope", + AttemptId(0), + "claude-code", + "m", + dir.clone(), + Duration::from_secs(60), + ); + (dir, spec) + } + + #[test] + fn invalid_tier_then_valid_plan_succeeds_on_second_attempt_with_error_in_prompt() { + let (dir, spec) = scratch("retry-ok"); + let harness = ScriptedPlanner { + responses: vec![BAD_TIER.into(), VALID.into()], + prompts: Default::default(), + }; + let graph = decompose_with_retry(&harness, &spec, 2, &dir.join("rejected")) + .expect("second attempt is valid"); + assert_eq!(graph.nodes.len(), 1); + let prompts = harness.prompts.lock().unwrap(); + assert_eq!(prompts.len(), 2); + assert!(!prompts[0].contains("your previous graph failed")); + assert!(prompts[1].contains("your previous graph failed")); + assert!(prompts[1].contains("unknown variant `analysis`")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn all_attempts_malformed_reports_saved_rejected_plan_that_exists() { + let (dir, spec) = scratch("retry-exhausted"); + let harness = ScriptedPlanner { + responses: vec![BAD_TIER.into()], + prompts: Default::default(), + }; + let err = decompose_with_retry(&harness, &spec, 2, &dir.join("rejected")) + .expect_err("every attempt is rejected"); + assert_eq!(harness.prompts.lock().unwrap().len(), 2); + let PlanError::Exhausted { + attempts, + saved_plan: Some(path), + .. + } = &err + else { + panic!("expected Exhausted with a saved plan, got {err:?}"); + }; + assert_eq!(*attempts, 2); + assert!(path.is_file()); + assert_eq!(std::fs::read_to_string(path).unwrap(), BAD_TIER); + assert!(err.to_string().contains(&path.display().to_string())); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn dispatch_errors_are_not_retried() { + let (dir, spec) = scratch("retry-dispatch"); + let harness = FakeHarness { + response: Err(HarnessError::Timeout), + }; + let err = decompose_with_retry(&harness, &spec, 2, &dir.join("rejected")).unwrap_err(); + assert!(matches!(err, PlanError::Dispatch(_))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn salvage_plan_file_when_invoke_fails() { let dir = std::env::temp_dir().join(format!("meshloop-salvage-{}", std::process::id())); diff --git a/crates/meshloop-engine/src/run_loop.rs b/crates/meshloop-engine/src/run_loop.rs index 41b1e21..80b240b 100644 --- a/crates/meshloop-engine/src/run_loop.rs +++ b/crates/meshloop-engine/src/run_loop.rs @@ -19,7 +19,7 @@ use meshloop_domain::task_graph::{GraphMutation, TaskGraph, TaskId, Tier}; use crate::agent::{build_agent_spec, build_planning_spec, dependency_context}; use crate::converge::{RepairAction, RepairBudget, RepairSession}; -use crate::planner::{DefaultTierAssigner, PlanError, assign_tiers, decompose}; +use crate::planner::{DefaultTierAssigner, PlanError, assign_tiers, decompose_with_retry}; use crate::ports::{ AttemptRow, CheckRunner, FeedbackKey, HarnessCapabilities, HarnessHandle, LiveCheck, ProcessHint, ProcessView, RunRow, RunStore, StoreError, TransitionRecord, WorkspaceError, @@ -297,8 +297,14 @@ impl<'a> RunLoop<'a> { wt.clone(), self.limits.task_timeout, ); - let graph = decompose(harness, &spec).map_err(OrchestratorError::Plan)?; - let mut graph = graph; + let rejected_dir = self + .workspace + .repo_root() + .join(".meshloop") + .join("rejected-plans"); + let mut graph = + decompose_with_retry(harness, &spec, self.limits.max_retries, &rejected_dir) + .map_err(OrchestratorError::Plan)?; assign_tiers(&mut graph, &DefaultTierAssigner); Ok(graph) } diff --git a/docs/install.md b/docs/install.md index 77317fa..3bd58de 100644 --- a/docs/install.md +++ b/docs/install.md @@ -110,6 +110,12 @@ sample is **not runnable as-is**. Every probe is bounded by `[limits] probe_time Run `meshloop doctor --config meshloop.toml` to see each harness's readiness. - **Database:** Local execution state is stored in `.meshloop/state.sqlite` (SQLite WAL mode). + **Ignore it in Git:** add `/.meshloop/` to your repository's `.gitignore`, or run + `meshloop bundle --dest . --gitignore` to append a marked, idempotent block + (`# >>> meshloop >>>` … `# <<< meshloop <<<`). Without `--gitignore`, `bundle` never edits + `.gitignore`; it reports `gitignore: missing` and prints the line to add. On first store + creation `plan`/`run` also write `.meshloop/.gitignore` (`*`) so the store never shows as + untracked. `meshloop doctor --json` reports `store_ignored`. - **Workspaces:** Isolated task executions occur in `.meshloop-worktrees/`. --- diff --git a/docs/start.md b/docs/start.md index 974a9d8..7cf17d7 100644 --- a/docs/start.md +++ b/docs/start.md @@ -54,6 +54,10 @@ meshloop --version meshloop doctor ``` +The local store lives in `.meshloop/state.sqlite`. Keep it out of Git: add `/.meshloop/` to +`.gitignore` or run `meshloop bundle --dest . --gitignore` (opt-in; see +[Install and Setup](install.md)). `meshloop doctor --json` reports `store_ignored`. + ### 1. Doctor — Verify Environment ```text /meshloop:doctor