From 6f5cb52a900e27b0cdde61a6e6eb7d80f4de2aff Mon Sep 17 00:00:00 2001 From: Samuel <184254+smota@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:15:59 +0200 Subject: [PATCH 1/3] feat(planner): select the planner harness or tier from [planner] Planning was routed as a hardcoded Tier3 task, so only model_tier = "top" fits; low-cost setups had to mislabel a mid-tier model as top. - [planner] harness = "" plans with that harness whatever its tier (still probed, dispatch-checked and quota-gated); [planner] tier routes planning at Tier1/2/3. Default unchanged (Tier3). Validated on load. - plan --json reports data.planner (harness, model_ref, real model_tier, selected_by); NoCandidate names the override. - doctor --json reports data.planner.eligible and warns when no dispatchable harness can plan. Implements #54 Closes #54 Co-Authored-By: Claude Opus 5.5 --- config/meshloop.example.toml | 6 ++ crates/meshloop-cli/src/compose.rs | 10 +- crates/meshloop-cli/src/config.rs | 126 ++++++++++++++++++++++ crates/meshloop-cli/src/main.rs | 75 ++++++++++++- crates/meshloop-cli/src/report.rs | 3 + crates/meshloop-cli/tests/plan_and_run.rs | 124 +++++++++++++++++++++ crates/meshloop-engine/src/run_loop.rs | 63 ++++++++--- docs/install.md | 14 +++ xtask/src/main.rs | 1 + 9 files changed, 404 insertions(+), 18 deletions(-) diff --git a/config/meshloop.example.toml b/config/meshloop.example.toml index b45c55c..10200cf 100644 --- a/config/meshloop.example.toml +++ b/config/meshloop.example.toml @@ -23,6 +23,12 @@ probe_timeout_seconds = 15 # Optional structured argv run in the attempt worktree after git-diff. Empty = git-diff only. verify_command = [] +# Optional. The planner is routed as a Tier3 task, which only model_tier = "top" fits. +# Name a harness to plan with whatever its tier, or lower the routing tier; not both. +# [planner] +# harness = "claude" +# tier = "Tier2" + [harnesses.grok] kind = "grok" executable = "grok" diff --git a/crates/meshloop-cli/src/compose.rs b/crates/meshloop-cli/src/compose.rs index a9c16f3..83a0258 100644 --- a/crates/meshloop-cli/src/compose.rs +++ b/crates/meshloop-cli/src/compose.rs @@ -16,7 +16,7 @@ use meshloop_engine::agent::AgentSpec; use meshloop_engine::origin::Origin; use meshloop_engine::ports::{HarnessCapabilities, HarnessHandle, HarnessOutcome, StoreError}; use meshloop_engine::router::Candidate; -use meshloop_engine::run_loop::RunLimits; +use meshloop_engine::run_loop::{PlannerSelection, RunLimits}; use crate::config::{Config, HarnessConfig, Limits, resolve_executable}; @@ -79,9 +79,10 @@ pub struct Composed { pub limits: RunLimits, pub verify_command: Vec, pub worktree_base: PathBuf, + pub planner: PlannerSelection, } -fn parse_model_tier(s: &str) -> ModelCapabilityTier { +pub(crate) fn parse_model_tier(s: &str) -> ModelCapabilityTier { match s { "top" => ModelCapabilityTier::TopTier, "mid" => ModelCapabilityTier::MidTier, @@ -177,6 +178,11 @@ pub fn compose(req: ComposeRequest<'_>) -> Result { .clamped(), verify_command: req.config.verify.verify_command.clone(), worktree_base: wt, + planner: req + .config + .planner + .selection() + .map_err(|e| format!("{e:?}"))?, }) } diff --git a/crates/meshloop-cli/src/config.rs b/crates/meshloop-cli/src/config.rs index d2188df..9e1520b 100644 --- a/crates/meshloop-cli/src/config.rs +++ b/crates/meshloop-cli/src/config.rs @@ -4,6 +4,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use meshloop_domain::task_graph::Tier; +use meshloop_engine::run_loop::PlannerSelection; use serde::Deserialize; #[derive(Debug, Deserialize, Default, Clone)] @@ -25,6 +27,8 @@ pub struct Config { pub harnesses: HashMap, #[serde(default)] pub verify: VerifyConfig, + #[serde(default)] + pub planner: PlannerConfig, /// When true, Meshloop runs standalone without Herdr daemon, dispatching direct CLI harnesses into Git worktrees. #[serde(default)] #[allow(dead_code)] @@ -56,6 +60,44 @@ pub struct VerifyConfig { pub verify_command: Vec, } +/// `[planner]`: which harness runs `meshloop:planner`. Without either key, planning is +/// routed as a Tier3 task, so it needs a harness with `model_tier = "top"`. +#[derive(Debug, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct PlannerConfig { + /// A selected harness to plan with, whatever its `model_tier`. + #[serde(default)] + pub harness: Option, + /// The tier planning is routed at: "Tier1", "Tier2" or "Tier3" (default). + #[serde(default)] + pub tier: Option, +} + +impl PlannerConfig { + pub fn selection(&self) -> Result { + if let Some(name) = &self.harness { + if self.tier.is_some() { + return Err(ConfigError::Parse( + "planner: set either harness or tier, not both".into(), + )); + } + return Ok(PlannerSelection::Harness(name.clone())); + } + match self.tier.as_deref() { + None => Ok(PlannerSelection::default()), + Some(t) => Tier::ALL + .into_iter() + .find(|v| v.name() == t) + .map(PlannerSelection::Route) + .ok_or_else(|| { + ConfigError::Parse(format!( + "planner.tier '{t}' must be one of Tier1, Tier2, Tier3" + )) + }), + } + } +} + #[derive(Debug, Deserialize, Clone)] pub struct HarnessConfig { pub executable: String, @@ -118,6 +160,14 @@ pub fn load(path: &Path) -> Result { ))); } } + if let Some(name) = &config.planner.harness + && !config.selected_harnesses.contains(name) + { + return Err(ConfigError::Parse(format!( + "planner.harness '{name}' is not in selected_harnesses" + ))); + } + config.planner.selection()?; if config.limits.probe_timeout_seconds == 0 { return Err(ConfigError::Parse( "limits.probe_timeout_seconds must be greater than 0".into(), @@ -241,6 +291,82 @@ mod tests { model_tier = "top" "#; + fn load_with(tag: &str, extra: &str) -> Result { + load(&write_temp( + tag, + &format!( + "{MINIMAL} +{extra}" + ), + )) + } + + #[test] + fn planner_section_selects_harness_or_tier_and_defaults_to_tier3() { + // Issue #54: planning was hardcoded to Tier3, which only model_tier = "top" fits. + let c = load_with("planner-none", "").expect("no [planner]"); + assert_eq!( + c.planner.selection().unwrap(), + PlannerSelection::Route(Tier::Tier3) + ); + let c = load_with( + "planner-harness", + "[planner] +harness = \"grok\"", + ) + .expect("harness"); + assert_eq!( + c.planner.selection().unwrap(), + PlannerSelection::Harness("grok".into()) + ); + let c = load_with( + "planner-tier", + "[planner] +tier = \"Tier2\"", + ) + .expect("tier"); + assert_eq!( + c.planner.selection().unwrap(), + PlannerSelection::Route(Tier::Tier2) + ); + } + + #[test] + fn planner_section_rejects_bad_values() { + for (tag, extra, needle) in [ + ( + "p-unsel", + "[planner] +harness = \"codex\"", + "not in selected_harnesses", + ), + ( + "p-tier", + "[planner] +tier = \"top\"", + "must be one of Tier1, Tier2, Tier3", + ), + ( + "p-both", + "[planner] +harness = \"grok\" +tier = \"Tier1\"", + "either harness or tier", + ), + ( + "p-key", + "[planner] +model = \"x\"", + "unknown field", + ), + ] { + match load_with(tag, extra) { + Err(ConfigError::Parse(msg)) => assert!(msg.contains(needle), "{tag}: {msg}"), + other => panic!("{tag}: expected a parse error, got {other:?}"), + } + } + } + #[test] fn omitted_version_args_default_to_version_flag_and_probe_timeout_is_bounded() { // Issue #9: the minimal repro config used to probe the bare executable forever. diff --git a/crates/meshloop-cli/src/main.rs b/crates/meshloop-cli/src/main.rs index 3357f8c..d8a3806 100644 --- a/crates/meshloop-cli/src/main.rs +++ b/crates/meshloop-cli/src/main.rs @@ -18,7 +18,7 @@ use std::path::PathBuf; use std::process::ExitCode; use meshloop_domain::state::{PlanDecision, PlanState, TaskState}; -use meshloop_domain::task_graph::{TaskGraph, TaskId}; +use meshloop_domain::task_graph::{TaskGraph, TaskId, Tier}; use meshloop_engine::ports::{HarnessCapabilities, RunStore, WorkspacePort}; use meshloop_engine::router::Router; use meshloop_engine::run_loop::{OrchestratorError, RunLoop, idle_exit_code}; @@ -307,6 +307,16 @@ fn load_cfg(config: Option) -> Result<(crate::config::Config, PathBuf), } } +/// The harness that ran (or would run) the planner, with its configured `model_tier`. +fn planner_json(c: &meshloop_engine::router::Candidate, configured: bool) -> serde_json::Value { + serde_json::json!({ + "harness": c.harness, + "model_ref": c.model_ref, + "model_tier": format!("{:?}", c.model_tier), + "selected_by": if configured { "config" } else { "routing" }, + }) +} + fn repo_root() -> PathBuf { env::current_dir().expect("current directory") } @@ -374,22 +384,26 @@ fn cmd_plan( verify_command: composed.verify_command.clone(), worktree_base: composed.worktree_base.clone(), active_graph: None, + planner: composed.planner.clone(), }; eprintln!( "meshloop:plan probing {} harness(es) in parallel (timeout {}s)", cfg.selected_harnesses.len(), cfg.limits.probe_timeout_seconds ); - let graph = match saga.plan( + let (graph, planner) = match saga.plan( &objective, scope.as_deref().unwrap_or("scope: this repository only"), ) { - Ok(g) => g, + Ok(p) => (p.graph, p.planner), Err(OrchestratorError::NoCandidate(rejections)) => { eprintln!("Decomposition failed: no harness can run the planner."); for r in &rejections { eprintln!(" {}: {}", r.harness, r.reason); } + if cfg.planner.harness.is_none() { + eprintln!("{}", report::PLANNER_ROUTING_HINT); + } eprintln!("Run `meshloop doctor --config ` for per-harness readiness."); return ExitCode::from(1); } @@ -425,6 +439,7 @@ fn cmd_plan( origin, serde_json::json!({ "role": "meshloop:planner", + "planner": planner_json(&planner, cfg.planner.harness.is_some()), "path": out_path, "graph": graph, "loop_space": loop_space, @@ -537,6 +552,7 @@ fn cmd_review_plan( verify_command: composed.verify_command.clone(), worktree_base: composed.worktree_base.clone(), active_graph: None, + planner: composed.planner.clone(), }; let mut graph = graph; @@ -567,7 +583,7 @@ fn cmd_review_plan( &prompt, scope.as_deref().unwrap_or("scope: this repository only"), ) { - Ok(g) => graph = g, + Ok(p) => graph = p.graph, Err(e) => { match &e { OrchestratorError::Plan(p) => { @@ -664,6 +680,7 @@ fn cmd_review_plan( } const EMPTY_VERIFY_WARNING: &str = "verify_command is empty: nodes are not checked beyond git diff"; +const NO_PLANNER_WARNING: &str = "no dispatchable harness can run the planner; see data.planner and set [planner] harness or tier"; #[allow(clippy::too_many_arguments)] fn cmd_run( @@ -749,6 +766,7 @@ fn cmd_run( verify_command: composed.verify_command.clone(), worktree_base: composed.worktree_base.clone(), active_graph: None, + planner: composed.planner.clone(), }; if json { eprintln!( @@ -968,6 +986,7 @@ fn with_saga( verify_command: composed.verify_command.clone(), worktree_base: composed.worktree_base.clone(), active_graph: None, + planner: composed.planner.clone(), }; f(&mut saga) } @@ -1736,6 +1755,7 @@ fn cmd_doctor( let origin_harness = origin.harness.clone(); let mut warnings: Vec<&str> = Vec::new(); + let mut planner = serde_json::Value::Null; let (config_path, harnesses, error) = match crate::config::discover(config.as_deref()) { Err(_) => ( None, @@ -1753,6 +1773,10 @@ fn cmd_doctor( warnings.push(EMPTY_VERIFY_WARNING); } let rows = doctor_probe(&cfg); + planner = doctor_planner(&cfg, &rows); + if planner["eligible"].as_array().is_some_and(|e| e.is_empty()) { + warnings.push(NO_PLANNER_WARNING); + } let error = (!rows.iter().any(|r| r["dispatchable"] == true)).then(|| { "no selected harness is dispatchable; see data.harnesses[].reason".to_string() }); @@ -1779,6 +1803,7 @@ fn cmd_doctor( "harnesses_ready": harnesses_ready, "harnesses": harnesses, "store_ignored": store_ignored, + "planner": planner, "warnings": warnings, "note": "Doctor loads the config and runs each selected harness's bounded version probe. Live workers run as direct CLI subprocesses in Git worktrees.", }); @@ -1806,6 +1831,19 @@ fn cmd_doctor( }; println!(" harness {}: {status}", h["name"].as_str().unwrap_or("?")); } + if let Some(eligible) = planner["eligible"].as_array() { + let names: Vec<&str> = eligible.iter().filter_map(|v| v.as_str()).collect(); + println!( + " planner: {} at {} -> {}", + planner["selected_by"].as_str().unwrap_or("?"), + planner["tier"].as_str().unwrap_or("?"), + if names.is_empty() { + "(none)".to_string() + } else { + names.join(", ") + } + ); + } println!(" store_ignored: {store_ignored}"); for w in &warnings { println!(" warning: {w}"); @@ -1827,6 +1865,34 @@ fn cmd_doctor( } } +/// Which harnesses `plan` could use, from the config and the probe rows. Routing scores +/// the eligible ones at plan time, so doctor lists them rather than predicting the pick. +fn doctor_planner(cfg: &crate::config::Config, rows: &[serde_json::Value]) -> serde_json::Value { + use meshloop_engine::run_loop::PlannerSelection; + let selection = cfg.planner.selection().unwrap_or_default(); + let (selected_by, tier) = match &selection { + PlannerSelection::Harness(_) => ("config", Tier::Tier1), + PlannerSelection::Route(t) => ("routing", *t), + }; + let eligible: Vec<&str> = rows + .iter() + .filter(|r| r["dispatchable"] == true) + .filter_map(|r| r["name"].as_str()) + .filter(|name| match &selection { + PlannerSelection::Harness(h) => h == name, + PlannerSelection::Route(_) => cfg.harnesses.get(*name).is_some_and(|hc| { + meshloop_domain::policy::tier_fits(tier, compose::parse_model_tier(&hc.model_tier)) + }), + }) + .collect(); + serde_json::json!({ + "selected_by": selected_by, + "harness": cfg.planner.harness, + "tier": tier.name(), + "eligible": eligible, + }) +} + /// Runs every selected harness's bounded probe concurrently and reports per-harness /// readiness in configuration order. Opens no store and dispatches nothing. fn doctor_probe(cfg: &crate::config::Config) -> Vec { @@ -2102,6 +2168,7 @@ fn cmd_mutate_plan( verify_command: composed.verify_command.clone(), worktree_base: composed.worktree_base.clone(), active_graph: None, + planner: composed.planner.clone(), }; let graph_id = match graph_arg { diff --git a/crates/meshloop-cli/src/report.rs b/crates/meshloop-cli/src/report.rs index 0428e66..10e8ee2 100644 --- a/crates/meshloop-cli/src/report.rs +++ b/crates/meshloop-cli/src/report.rs @@ -7,6 +7,9 @@ use meshloop_engine::run_loop::{AttemptView, IdleReason, RunStatus}; pub const ACCEPTANCE_RULE: &str = "R1: every node stops for meshloop accept; tier selects harness/model only"; +/// Shown when routing finds no planner and `[planner]` names no harness. +pub const PLANNER_ROUTING_HINT: &str = "The planner is routed as a Tier3 task by default, which needs a harness with model_tier = \"top\". To plan with another harness, set `[planner] harness = \"\"` (or `[planner] tier`) in meshloop.toml."; + pub fn format_plan(graph: &TaskGraph) -> String { let mut out = format!( "Plan '{}': {} task(s) — awaiting-plan-review (ADR 0009).\n\ diff --git a/crates/meshloop-cli/tests/plan_and_run.rs b/crates/meshloop-cli/tests/plan_and_run.rs index 7ad5b5b..8a10fb5 100644 --- a/crates/meshloop-cli/tests/plan_and_run.rs +++ b/crates/meshloop-cli/tests/plan_and_run.rs @@ -2140,3 +2140,127 @@ fn watch_times_out_when_the_run_does_not_change() { ); fs::remove_dir_all(&dir).ok(); } + +/// A fixture config whose only harness is mid-tier, with optional extra TOML (issue #54). +fn write_mid_tier_config(dir: &Path, extra: &str) -> PathBuf { + let config_path = dir.join("meshloop.toml"); + fs::write( + &config_path, + format!( + r#" +selected_harnesses = ["fixture"] + +[limits] +max_concurrent_workers = 1 +max_retries = 1 +task_timeout_seconds = 30 + +[verify] +verify_command = [] + +[harnesses.fixture] +executable = "{}" +version_args = ["--version"] +invoke_args_template = ["--emit-graph"] +model_ref = "fixture-model" +model_tier = "mid" + +{extra} +"#, + fixture_path().replace('\\', "\\\\") + ), + ) + .unwrap(); + config_path +} + +#[test] +fn plan_with_only_a_mid_tier_harness_names_the_planner_override() { + let dir = disposable_repo("plan-mid-default"); + let config_path = write_mid_tier_config(&dir, ""); + let output = meshloop() + .current_dir(&dir) + .args(["plan", "--objective", "x", "--config"]) + .arg(&config_path) + .output() + .expect("plan"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "{stderr}"); + assert!(stderr.contains("does not fit task Tier3"), "{stderr}"); + assert!(stderr.contains("[planner] harness"), "{stderr}"); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn plan_uses_the_configured_planner_harness_and_reports_its_real_tier() { + let dir = disposable_repo("plan-mid-configured"); + let config_path = write_mid_tier_config( + &dir, + "[planner] +harness = \"fixture\"", + ); + let out_path = dir.join("plan.json"); + let output = meshloop() + .current_dir(&dir) + .args(["plan", "--objective", "x", "--json", "--config"]) + .arg(&config_path) + .arg("--out") + .arg(&out_path) + .output() + .expect("plan"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "{stdout} +{}", + String::from_utf8_lossy(&output.stderr) + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("plan json"); + let planner = &v["data"]["planner"]; + assert_eq!(planner["harness"], "fixture", "{stdout}"); + assert_eq!(planner["model_tier"], "MidTier", "{stdout}"); + assert_eq!(planner["selected_by"], "config", "{stdout}"); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn doctor_reports_which_harnesses_can_plan() { + let dir = disposable_repo("doctor-planner"); + for (extra, selected_by, eligible, warns) in [ + ("", "routing", serde_json::json!([]), true), + ( + "[planner] +harness = \"fixture\"", + "config", + serde_json::json!(["fixture"]), + false, + ), + ( + "[planner] +tier = \"Tier2\"", + "routing", + serde_json::json!(["fixture"]), + false, + ), + ] { + let config_path = write_mid_tier_config(&dir, extra); + let output = meshloop() + .current_dir(&dir) + .args(["doctor", "--json", "--config"]) + .arg(&config_path) + .output() + .expect("doctor"); + let stdout = String::from_utf8_lossy(&output.stdout); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("doctor json"); + let planner = &v["data"]["planner"]; + assert_eq!(planner["selected_by"], selected_by, "{extra}: {stdout}"); + assert_eq!(planner["eligible"], eligible, "{extra}: {stdout}"); + let warned = v["data"]["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w.as_str().unwrap_or("").contains("planner")); + assert_eq!(warned, warns, "{extra}: {stdout}"); + } + fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/meshloop-engine/src/run_loop.rs b/crates/meshloop-engine/src/run_loop.rs index 5913edd..e1b809d 100644 --- a/crates/meshloop-engine/src/run_loop.rs +++ b/crates/meshloop-engine/src/run_loop.rs @@ -84,6 +84,29 @@ impl RunLimits { } } +/// How `plan` picks the harness that runs `meshloop:planner`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlannerSelection { + /// Route across every candidate as a task of this tier would be routed. + Route(Tier), + /// Use this configured harness regardless of its `model_tier`. It must still pass its + /// probe, be dispatchable and be out of quota cooldown. + Harness(String), +} + +impl Default for PlannerSelection { + fn default() -> Self { + Self::Route(Tier::Tier3) + } +} + +/// A produced graph and the candidate that planned it, as configured (real `model_tier`). +#[derive(Debug, Clone)] +pub struct Planned { + pub graph: TaskGraph, + pub planner: Candidate, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IdleReason { GraphComplete, @@ -121,6 +144,7 @@ pub struct RunLoop<'a> { pub verify_command: Vec, pub worktree_base: PathBuf, pub active_graph: Option, + pub planner: PlannerSelection, } /// Why a node is not progressing on its own (R1: every node stops for `meshloop accept`). @@ -349,34 +373,49 @@ impl<'a> RunLoop<'a> { self.store.graph_from_run(graph_id).map_err(Into::into) } - pub fn plan(&mut self, objective: &str, scope: &str) -> Result { + /// The candidates planning may use and the tier they are routed at. An explicit planner + /// harness is routed as Tier1, which every model tier fits, so only its probe, dispatch + /// readiness and quota decide. + fn planner_pool(&self) -> Result<(Vec, Tier), OrchestratorError> { + match &self.planner { + PlannerSelection::Route(tier) => Ok((self.candidates.clone(), *tier)), + PlannerSelection::Harness(name) => { + let c = self + .candidates + .iter() + .find(|c| &c.harness == name) + .ok_or_else(|| OrchestratorError::UnknownHarness(name.clone()))?; + Ok((vec![c.clone()], Tier::Tier1)) + } + } + } + + pub fn plan(&mut self, objective: &str, scope: &str) -> Result { let scratch = "planning"; + let (pool, tier) = self.planner_pool()?; let profiles = self.probe_all(); let quotas = self.load_quotas(); let feedback_store = &*self.store; let ctx = RoutingContext { - task_tier: Tier::Tier3, + task_tier: tier, coupling_penalty: CouplingPenalty(0), preferred_harness: None, headroom: &HashMap::new(), feedback: feedback_store, }; - let selected = self.router.select( - &self.candidates, - &profiles, - "as, - SystemTime::now(), - &ctx, - ); + let selected = self + .router + .select(&pool, &profiles, "as, SystemTime::now(), &ctx); let top = selected.first().ok_or_else(|| { OrchestratorError::NoCandidate(Router::rejections( - &self.candidates, + &pool, &profiles, "as, SystemTime::now(), - Tier::Tier3, + tier, )) })?; + let planner = (*top).clone(); self.require_dispatch_ok(&top.harness)?; let harness = *self .harnesses @@ -416,7 +455,7 @@ impl<'a> RunLoop<'a> { decompose_with_retry(harness, &spec, self.limits.max_retries, &rejected_dir) .map_err(OrchestratorError::Plan)?; assign_tiers(&mut graph, &DefaultTierAssigner); - Ok(graph) + Ok(Planned { graph, planner }) } pub fn start( diff --git a/docs/install.md b/docs/install.md index 3bd58de..7b75ff3 100644 --- a/docs/install.md +++ b/docs/install.md @@ -109,6 +109,20 @@ sample is **not runnable as-is**. Every probe is bounded by `[limits] probe_time (default 15); a version call that opens an interactive UI is reported as a probe timeout. Run `meshloop doctor --config meshloop.toml` to see each harness's readiness. +- **Planner harness:** `plan` routes the planner as a Tier3 task, so by default only a + harness with `model_tier = "top"` can plan. To plan with a cheaper model, name it, or + lower the routing tier (set one key, not both): + + ```toml + [planner] + harness = "codex" # a selected harness, whatever its model_tier + # tier = "Tier2" # or: route planning as Tier1, Tier2 or Tier3 (default) + ``` + + Keep `model_tier` honest: it is recorded with the plan (`plan --json` reports + `data.planner`), and `doctor --json` lists the harnesses that can plan under + `data.planner.eligible`. + - **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 diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b920a74..ae8d39e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1615,6 +1615,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { verify_command: vec![], worktree_base: tmp_repo.join("worktrees"), active_graph: None, + planner: Default::default(), }; #[derive(PartialEq, Eq, Debug)] From 228c7b66d64bb49e96751d5257f2ca145803d697 Mon Sep 17 00:00:00 2001 From: Samuel <184254+smota@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:24:33 +0200 Subject: [PATCH 2/3] feat(integrate): land one deliverable's nodes per branch A multi-issue graph could only be integrated as a whole, so one PR per issue meant hand cherry-picking from the integrate branch. - TaskNode gains optional `deliverable` (graph_id charset, validated); the planner contract lists it. - integrate --deliverable --into : without --accept-integrate, build /integrate- from run_base for review; with it, land the reviewed worktree (same dirty/ff-or-no-ff rules). - Attempts start from the shared integrate head, so only each node's own commits (..) are replayed, tagged nodes plus transitive dependencies in topological order. Unknown tag, unaccepted node and replay conflict each get a named error. - status/inspect/plan output show each node's deliverable. Implements #55 Closes #55 Co-Authored-By: Claude Opus 5.5 --- crates/meshloop-adapters/src/git.rs | 20 ++ crates/meshloop-cli/src/args.rs | 5 +- crates/meshloop-cli/src/main.rs | 116 +++++++++++ crates/meshloop-cli/src/report.rs | 15 +- crates/meshloop-cli/tests/plan_and_run.rs | 232 +++++++++++++++++++++ crates/meshloop-domain/src/task_graph.rs | 14 ++ crates/meshloop-engine/src/agent.rs | 6 + crates/meshloop-engine/src/planner.rs | 4 + crates/meshloop-engine/src/ports.rs | 8 + crates/meshloop-engine/src/run_loop.rs | 173 ++++++++++++++- docs/architecture/r1-closed-loop-design.md | 18 ++ docs/engineering/agent-workflow.md | 6 +- xtask/src/main.rs | 10 + 13 files changed, 618 insertions(+), 9 deletions(-) diff --git a/crates/meshloop-adapters/src/git.rs b/crates/meshloop-adapters/src/git.rs index 03e3b27..7873cf9 100644 --- a/crates/meshloop-adapters/src/git.rs +++ b/crates/meshloop-adapters/src/git.rs @@ -188,6 +188,26 @@ impl WorkspacePort for GitWorktreeAdapter { } } + fn cherry_pick_range( + &self, + worktree: &Path, + base: &str, + head: &str, + ) -> Result<(), WorkspaceError> { + let range = format!("{base}..{head}"); + let count = self.run_in(worktree, &["rev-list", "--count", &range])?; + if count.trim() == "0" { + return Ok(()); + } + match self.run_in(worktree, &["cherry-pick", &range]) { + Ok(_) => Ok(()), + Err(_) => { + let _ = self.run_in(worktree, &["cherry-pick", "--abort"]); + Err(WorkspaceError::Conflict) + } + } + } + fn branch_exists(&self, branch: &str) -> Result { match self.run(&["rev-parse", "--verify", branch]) { Ok(_) => Ok(true), diff --git a/crates/meshloop-cli/src/args.rs b/crates/meshloop-cli/src/args.rs index fd37015..ab174f4 100644 --- a/crates/meshloop-cli/src/args.rs +++ b/crates/meshloop-cli/src/args.rs @@ -104,6 +104,7 @@ pub enum Command { graph: String, into: String, accept_integrate: bool, + deliverable: Option, config: Option, db: Option, worktree_base: Option, @@ -266,6 +267,7 @@ fn verb_flags(verb: &str) -> Option<&'static [(bool, &'static str)]> { (true, "--graph"), (true, "--into"), (false, "--accept-integrate"), + (true, "--deliverable"), (true, "--config"), (true, "--db"), (true, "--worktree-base"), @@ -528,6 +530,7 @@ fn parse_command(args: &[String]) -> Result { graph, into, accept_integrate: has_flag(rest, "--accept-integrate"), + deliverable: flag_value(rest, "--deliverable"), config: flag_value(rest, "--config").map(PathBuf::from), db: flag_value(rest, "--db").map(PathBuf::from), worktree_base: flag_value(rest, "--worktree-base").map(PathBuf::from), @@ -671,7 +674,7 @@ mod tests { "inspect --session-id s --graph g --task 1 --config c --db d --attempts", "watch --session-id s --graph g --interval 2 --timeout 9 --config c --db d", "accept --task 1 --as me --graph g --config c --db d --worktree-base w", - "integrate --graph g --into main --accept-integrate --config c --db d --worktree-base w", + "integrate --graph g --into main --accept-integrate --deliverable t --config c --db d --worktree-base w", "orchestrate --task 1 --graph g --config c --db d --worktree-base w --model-a a --model-b b --fixture-only", "bundle --dest d --gitignore", "ast-skeleton --path p", diff --git a/crates/meshloop-cli/src/main.rs b/crates/meshloop-cli/src/main.rs index d8a3806..f51f873 100644 --- a/crates/meshloop-cli/src/main.rs +++ b/crates/meshloop-cli/src/main.rs @@ -225,6 +225,7 @@ fn dispatch(inv: Invocation) -> ExitCode { graph, into, accept_integrate, + deliverable, config, db, worktree_base, @@ -232,9 +233,11 @@ fn dispatch(inv: Invocation) -> ExitCode { graph, into, accept_integrate, + deliverable, config, db, worktree_base, + inv.json, inv.origin, ), Command::Orchestrate { @@ -1070,6 +1073,7 @@ fn cmd_status( "worktree": n.worktree.as_ref().map(|p| p.display().to_string()), "note": n.note, "waiting_for": n.waiting_for.map(|w| w.as_str()), + "deliverable": n.deliverable, "blocked_by": n.blocked_by.iter().map(|d| d.0).collect::>(), })).collect::>(), }), @@ -1284,6 +1288,7 @@ fn cmd_inspect( "live": n.live, "note": n.note, "waiting_for": n.waiting_for.map(|w| w.as_str()), + "deliverable": n.deliverable, "blocked_by": n.blocked_by.iter().map(|d| d.0).collect::>(), "attempts": attempts.iter().map(report::attempt_json).collect::>(), }), @@ -1328,6 +1333,7 @@ fn cmd_inspect( "worktree": n.worktree.as_ref().map(|p| p.display().to_string()), "note": n.note, "waiting_for": n.waiting_for.map(|w| w.as_str()), + "deliverable": n.deliverable, "blocked_by": n.blocked_by.iter().map(|d| d.0).collect::>(), })).collect::>(), }); @@ -1555,15 +1561,31 @@ fn cmd_accept( }) } +#[allow(clippy::too_many_arguments)] fn cmd_integrate( graph: String, into: String, accept_integrate: bool, + deliverable: Option, config: Option, db: Option, worktree_base: Option, + json: bool, origin: meshloop_engine::origin::Origin, ) -> ExitCode { + if let Some(tag) = deliverable { + return cmd_integrate_deliverable( + graph, + tag, + into, + accept_integrate, + config, + db, + worktree_base, + json, + origin, + ); + } if !accept_integrate { eprintln!("integrate requires --accept-integrate after reviewing the integrate worktree"); return ExitCode::from(2); @@ -1582,6 +1604,99 @@ fn cmd_integrate( }) } +/// `integrate --deliverable`: without `--accept-integrate`, (re)build the deliverable +/// worktree for review; with it, land the reviewed worktree (building it if absent). +#[allow(clippy::too_many_arguments)] +fn cmd_integrate_deliverable( + graph: String, + tag: String, + into: String, + accept_integrate: bool, + config: Option, + db: Option, + worktree_base: Option, + json: bool, + origin: meshloop_engine::origin::Origin, +) -> ExitCode { + let cmd = "meshloop:integrate"; + with_saga(config, db, worktree_base, false, origin.clone(), |saga| { + let result = if accept_integrate { + saga.integrate_deliverable_into(&graph, &tag, &into) + .map(|()| None) + } else { + saga.prepare_deliverable(&graph, &tag).map(Some) + }; + match result { + Ok(Some(view)) => { + let ids = |v: &[TaskId]| v.iter().map(|t| t.0).collect::>(); + if json { + let data = serde_json::json!({ + "graph_id": graph, + "deliverable": view.tag, + "worktree": view.worktree.display().to_string(), + "branch": view.branch, + "head": view.head, + "nodes": ids(&view.nodes), + "outside_dependencies": ids(&view.outside_dependencies), + "next": format!( + "review the worktree, then: meshloop integrate --graph {graph} --deliverable {tag} --into {into} --accept-integrate" + ), + }); + println!("{}", json_out::ok(cmd, origin.clone(), data)); + } else { + println!( + "deliverable {tag}: nodes {:?} replayed onto {} ({})", + ids(&view.nodes), + view.worktree.display(), + view.head + ); + if !view.outside_dependencies.is_empty() { + println!( + " includes dependencies outside {tag}: {:?}", + ids(&view.outside_dependencies) + ); + } + println!("review it, then rerun with --accept-integrate to land it on {into}"); + } + ExitCode::SUCCESS + } + Ok(None) => { + if json { + let data = serde_json::json!({ + "graph_id": graph, "deliverable": tag, "into": into, + }); + println!("{}", json_out::ok(cmd, origin.clone(), data)); + } else { + println!("integrated deliverable {tag} of graph {graph} into {into}"); + } + ExitCode::SUCCESS + } + Err(e) => { + let msg = match &e { + OrchestratorError::UnknownDeliverable(t) => { + format!("no node in graph {graph} has deliverable '{t}'") + } + OrchestratorError::DeliverableNotReady { task, state } => format!( + "deliverable {tag} needs task {} accepted first (it is {state:?})", + task.0 + ), + OrchestratorError::DeliverableConflict(task) => format!( + "replaying task {}'s commits onto deliverable {tag} conflicted; its changes depend on work outside this deliverable", + task.0 + ), + other => format!("{other:?}"), + }; + if json { + println!("{}", json_out::err(cmd, origin.clone(), &msg)); + } else { + eprintln!("{msg}"); + } + ExitCode::from(1) + } + } + }) +} + fn workspace_root() -> Result { env::current_dir().map_err(|e| format!("cannot read current directory: {e}")) } @@ -2264,6 +2379,7 @@ mod watch_exit_tests { revision: None, pane_id: None, live: None, + deliverable: None, } } diff --git a/crates/meshloop-cli/src/report.rs b/crates/meshloop-cli/src/report.rs index 10e8ee2..58a6e4c 100644 --- a/crates/meshloop-cli/src/report.rs +++ b/crates/meshloop-cli/src/report.rs @@ -21,11 +21,15 @@ pub fn format_plan(graph: &TaskGraph) -> String { for node in &graph.nodes { let deps: Vec = node.depends_on.iter().map(|d| d.0.to_string()).collect(); out += &format!( - " [{}] {} (tier: {:?}, depends_on: [{}])\n", + " [{}] {} (tier: {:?}, depends_on: [{}]{})\n", node.id.0, node.description, node.tier, - deps.join(", ") + deps.join(", "), + node.deliverable + .as_deref() + .map(|d| format!(", deliverable: {d}")) + .unwrap_or_default() ); } out += "Next: `meshloop review-plan --plan --accept|--decline|--adjust`.\n\ @@ -79,6 +83,12 @@ pub fn format_status(status: &RunStatus) -> String { n.pane_id.as_deref().unwrap_or("-"), n.live.as_deref().unwrap_or("-") ); + if let Some(d) = &n.deliverable { + out += &format!( + " deliverable: {d} +" + ); + } if let Some(w) = n.waiting_for { if n.blocked_by.is_empty() { out += &format!(" waiting: {}\n", w.as_str()); @@ -157,6 +167,7 @@ mod tests { tier: Some(Tier::Tier1), allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }; let rendered = format_plan(&graph); diff --git a/crates/meshloop-cli/tests/plan_and_run.rs b/crates/meshloop-cli/tests/plan_and_run.rs index 8a10fb5..c9327e6 100644 --- a/crates/meshloop-cli/tests/plan_and_run.rs +++ b/crates/meshloop-cli/tests/plan_and_run.rs @@ -2264,3 +2264,235 @@ tier = \"Tier2\"", } fs::remove_dir_all(&dir).ok(); } + +/// Subjects of the commits on `rev` that are not on `base`, in `repo`. +fn commit_subjects(repo: &Path, base: &str, rev: &str) -> Vec { + let out = Command::new("git") + .current_dir(repo) + .args(["log", "--format=%s", &format!("{base}..{rev}")]) + .output() + .expect("git log"); + String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect() +} + +fn tasks_in(subjects: &[String]) -> Vec { + let mut ids: Vec = subjects + .iter() + .filter_map(|s| s.rsplit("for task ").next()?.trim().parse().ok()) + .collect(); + ids.sort(); + ids.dedup(); + ids +} + +#[test] +fn integrate_deliverable_replays_only_that_group_and_its_dependencies() { + // Issue #55: one PR per issue from a multi-issue graph. Attempts start from the shared + // integrate head, so the deliverable must replay each node's own commits only. + let dir = disposable_repo("deliverable"); + // Keep config, plan, store and worktrees outside the repo so it stays clean for landing. + let aux = dir.with_file_name(format!( + "{}-aux", + dir.file_name().unwrap().to_string_lossy() + )); + let _ = fs::remove_dir_all(&aux); + fs::create_dir_all(&aux).unwrap(); + let config_path = write_config(&aux, r#"["--prompt-file", "{prompt_file}"]"#); + let plan_path = aux.join("plan.json"); + let db_path = aux.join("state.sqlite"); + let worktree_base = aux.join("worktrees"); + fs::write( + &plan_path, + r#"{"graph_id":"gd","nodes":[ + {"id":1,"description":"a1","depends_on":[],"tier":null,"deliverable":"issue-a"}, + {"id":2,"description":"b1","depends_on":[],"tier":null,"deliverable":"issue-b"}, + {"id":3,"description":"b2","depends_on":[1],"tier":null,"deliverable":"issue-b"}, + {"id":4,"description":"c1","depends_on":[],"tier":null,"deliverable":"issue-c"}]}"#, + ) + .unwrap(); + // `run` writes .meshloop/plan.json into the repo; consuming repos ignore it (#44). + fs::write(dir.join(".gitignore"), "/.meshloop/\n").unwrap(); + for args in [ + &["add", ".gitignore"][..], + &["commit", "-q", "-m", "ignore store"], + ] { + Command::new("git") + .current_dir(&dir) + .args(args) + .status() + .unwrap(); + } + let seed = String::from_utf8_lossy( + &Command::new("git") + .current_dir(&dir) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap() + .stdout, + ) + .trim() + .to_string(); + let common = |cmd: &mut Command| { + cmd.arg("--config") + .arg(&config_path) + .arg("--db") + .arg(&db_path) + .arg("--worktree-base") + .arg(&worktree_base); + }; + + let mut run = meshloop(); + run.current_dir(&dir) + .args(["run", "--plan"]) + .arg(&plan_path) + .arg("--accept-plan"); + common(&mut run); + let out = run.output().expect("run"); + assert!( + out.status.success(), + "run: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // Accept whatever awaits review and resume until every node is accepted. + for _ in 0..12 { + let status = status_json(&dir, &config_path, &db_path); + let nodes = status["data"]["nodes"].as_array().unwrap().clone(); + if nodes + .iter() + .all(|n| n["state"] == "Accepted" || n["state"] == "Integrated") + { + break; + } + for n in nodes.iter().filter(|n| n["state"] == "AwaitingReview") { + let mut accept = meshloop(); + accept.current_dir(&dir).args([ + "accept", + "--task", + &n["id"].to_string(), + "--as", + "tester", + ]); + common(&mut accept); + assert!(accept.output().unwrap().status.success()); + } + let mut resume = meshloop(); + resume.current_dir(&dir).arg("resume"); + common(&mut resume); + resume.output().expect("resume"); + } + let status = status_json(&dir, &config_path, &db_path); + let nodes = status["data"]["nodes"].as_array().unwrap(); + assert!( + nodes + .iter() + .all(|n| n["state"] == "Accepted" || n["state"] == "Integrated"), + "{status}" + ); + assert_eq!(nodes[2]["deliverable"], "issue-b", "{status}"); + + let prepare = |tag: &str| { + let mut cmd = meshloop(); + cmd.current_dir(&dir).args([ + "integrate", + "--graph", + "gd", + "--deliverable", + tag, + "--into", + "main", + "--json", + ]); + common(&mut cmd); + let out = cmd.output().expect("integrate --deliverable"); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + (out.status.success(), stdout) + }; + + let (ok, stdout) = prepare("issue-a"); + assert!(ok, "{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(v["data"]["nodes"], serde_json::json!([1]), "{stdout}"); + assert_eq!(v["data"]["outside_dependencies"], serde_json::json!([])); + let wt_a = PathBuf::from(v["data"]["worktree"].as_str().unwrap()); + assert_eq!(tasks_in(&commit_subjects(&wt_a, &seed, "HEAD")), vec![1]); + + let (ok, stdout) = prepare("issue-b"); + assert!(ok, "{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(v["data"]["outside_dependencies"], serde_json::json!([1])); + let wt_b = PathBuf::from(v["data"]["worktree"].as_str().unwrap()); + assert_eq!( + tasks_in(&commit_subjects(&wt_b, &seed, "HEAD")), + vec![1, 2, 3], + "issue-b must not carry issue-c's node 4" + ); + + let (ok, stdout) = prepare("issue-z"); + assert!(!ok, "{stdout}"); + assert!(stdout.contains("issue-z"), "{stdout}"); + + // Land issue-a on its own branch, cut from the seed. + Command::new("git") + .current_dir(&dir) + .args(["branch", "work/issue-a", &seed]) + .status() + .unwrap(); + let mut land = meshloop(); + land.current_dir(&dir).args([ + "integrate", + "--graph", + "gd", + "--deliverable", + "issue-a", + "--into", + "work/issue-a", + "--accept-integrate", + ]); + common(&mut land); + let out = land.output().expect("land"); + assert!( + out.status.success(), + "land: {}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + tasks_in(&commit_subjects(&dir, &seed, "work/issue-a")), + vec![1] + ); + + fs::remove_dir_all(&dir).ok(); + fs::remove_dir_all(&aux).ok(); +} + +#[test] +fn plan_rejects_an_illegal_deliverable_tag() { + let dir = disposable_repo("deliverable-illegal"); + let config_path = write_config(&dir, r#"["--prompt-file", "{prompt_file}"]"#); + let plan_path = dir.join("plan.json"); + fs::write( + &plan_path, + r#"{"graph_id":"gi","nodes":[{"id":1,"description":"x","depends_on":[],"tier":null,"deliverable":"issue a/b"}]}"#, + ) + .unwrap(); + let out = meshloop() + .current_dir(&dir) + .args(["review-plan", "--plan"]) + .arg(&plan_path) + .args(["--accept", "--config"]) + .arg(&config_path) + .output() + .expect("review-plan"); + let all = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!(!out.status.success(), "{all}"); + assert!(all.contains("IllegalDeliverable"), "{all}"); + fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/meshloop-domain/src/task_graph.rs b/crates/meshloop-domain/src/task_graph.rs index 670870f..168f132 100644 --- a/crates/meshloop-domain/src/task_graph.rs +++ b/crates/meshloop-domain/src/task_graph.rs @@ -39,6 +39,10 @@ pub struct TaskNode { pub allowed_paths: Vec, #[serde(default)] pub empty_diff_ok: bool, + /// Groups nodes that ship together (for example one issue's PR). `integrate + /// --deliverable ` lands only that group. Same character set as `graph_id`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deliverable: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -55,6 +59,7 @@ pub enum GraphError { Empty, IllegalGraphId(String), ReservedTaskId(TaskId), + IllegalDeliverable { node: TaskId, tag: String }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -116,6 +121,14 @@ impl TaskGraph { let mut seen = HashSet::new(); for node in &self.nodes { + if let Some(tag) = &node.deliverable + && !graph_id_is_legal(tag) + { + return Err(GraphError::IllegalDeliverable { + node: node.id, + tag: tag.clone(), + }); + } if node.id.0 == 0 { return Err(GraphError::ReservedTaskId(node.id)); } @@ -289,6 +302,7 @@ mod tests { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, } } diff --git a/crates/meshloop-engine/src/agent.rs b/crates/meshloop-engine/src/agent.rs index ecaa019..f972fb4 100644 --- a/crates/meshloop-engine/src/agent.rs +++ b/crates/meshloop-engine/src/agent.rs @@ -193,6 +193,7 @@ fn planning_output_contract() -> String { tier: Some(Tier::Tier1), allowed_paths: vec!["crates/widgets/src/".into()], empty_diff_ok: false, + deliverable: Some("issue-12".into()), }], }; let example = serde_json::to_string_pretty(&example).unwrap_or_default(); @@ -210,6 +211,9 @@ fn planning_output_contract() -> String { (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\ + - deliverable: optional string matching [A-Za-z0-9._-]+ naming what the node ships \ + with, e.g. \"issue-12\" when one graph delivers several issues. Nodes that share it \ + are integrated together. Omit it when the graph has one deliverable.\n\ Example of a valid one-node graph:\n{example}\n\ No other files unless required to produce that graph. Do not print prose." ) @@ -256,6 +260,7 @@ mod tests { tier: Some(Tier::Tier1), allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, } } @@ -363,6 +368,7 @@ mod tests { } assert!(spec.prompt.contains("[A-Za-z0-9._-]+")); assert!(spec.prompt.contains("empty_diff_ok")); + assert!(spec.prompt.contains("deliverable")); assert!(spec.prompt.contains("\"graph_id\": \"example-graph\"")); } diff --git a/crates/meshloop-engine/src/planner.rs b/crates/meshloop-engine/src/planner.rs index 54beac3..f829b55 100644 --- a/crates/meshloop-engine/src/planner.rs +++ b/crates/meshloop-engine/src/planner.rs @@ -465,6 +465,7 @@ mod tests { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }, meshloop_domain::task_graph::TaskNode { id: TaskId(2), @@ -473,6 +474,7 @@ mod tests { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }, meshloop_domain::task_graph::TaskNode { id: TaskId(3), @@ -481,6 +483,7 @@ mod tests { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }, ], }; @@ -500,6 +503,7 @@ mod tests { tier: Some(Tier::Tier3), allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }; assign_tiers(&mut graph, &DefaultTierAssigner); diff --git a/crates/meshloop-engine/src/ports.rs b/crates/meshloop-engine/src/ports.rs index 4b63da9..5e68582 100644 --- a/crates/meshloop-engine/src/ports.rs +++ b/crates/meshloop-engine/src/ports.rs @@ -157,6 +157,14 @@ pub trait WorkspacePort { fn status_porcelain(&self, worktree: &Path) -> Result; fn commit_all(&self, worktree: &Path, message: &str) -> Result; fn merge_in_worktree(&self, worktree: &Path, from_ref: &str) -> Result<(), WorkspaceError>; + /// Replays the commits in `base..head` onto `worktree`'s HEAD, oldest first. An empty + /// range is a no-op. On conflict the cherry-pick is aborted and `Conflict` returned. + fn cherry_pick_range( + &self, + worktree: &Path, + base: &str, + head: &str, + ) -> Result<(), WorkspaceError>; fn branch_exists(&self, branch: &str) -> Result; fn worktree_exists(&self, path: &Path) -> bool; fn prune(&self) -> Result<(), WorkspaceError>; diff --git a/crates/meshloop-engine/src/run_loop.rs b/crates/meshloop-engine/src/run_loop.rs index e1b809d..2b05095 100644 --- a/crates/meshloop-engine/src/run_loop.rs +++ b/crates/meshloop-engine/src/run_loop.rs @@ -53,6 +53,15 @@ pub enum OrchestratorError { SnapshotMismatch, /// No configured harness passed routing; one entry per configured candidate. NoCandidate(Vec), + /// No node in the graph carries this `deliverable` tag. + UnknownDeliverable(String), + /// A node a deliverable needs is not accepted yet. + DeliverableNotReady { + task: TaskId, + state: TaskState, + }, + /// Replaying this node's commits onto the deliverable worktree conflicted. + DeliverableConflict(TaskId), } impl From for OrchestratorError { @@ -100,6 +109,19 @@ impl Default for PlannerSelection { } } +/// What `integrate --deliverable` assembled for review. +#[derive(Debug, Clone)] +pub struct DeliverableView { + pub tag: String, + pub worktree: PathBuf, + pub branch: String, + pub head: String, + /// Every replayed node, in the order its commits were applied. + pub nodes: Vec, + /// Replayed nodes that the tagged nodes depend on but that carry another tag or none. + pub outside_dependencies: Vec, +} + /// A produced graph and the candidate that planned it, as configured (real `model_tier`). #[derive(Debug, Clone)] pub struct Planned { @@ -179,6 +201,7 @@ pub struct NodeStatus { pub revision: Option, pub pane_id: Option, pub live: Option, + pub deliverable: Option, } #[derive(Debug, Clone)] @@ -317,6 +340,14 @@ impl<'a> RunLoop<'a> { format!("meshloop/{graph_id}/task-{}/attempt-{}", task.0, attempt.0) } + fn deliverable_path(&self, graph_id: &str, tag: &str) -> PathBuf { + self.graph_dir(graph_id).join(format!("integrate-{tag}")) + } + + fn deliverable_branch(&self, graph_id: &str, tag: &str) -> String { + format!("meshloop/{graph_id}/integrate-{tag}") + } + fn plan_branch(&self, graph_id: &str) -> String { format!("meshloop/{graph_id}/plan") } @@ -2042,23 +2073,154 @@ impl<'a> RunLoop<'a> { graph_id: &str, git_ref: &str, ) -> Result<(), OrchestratorError> { + let integrate = self.integrate_path(graph_id); + self.land_worktree(&integrate, git_ref) + } + + /// Merges `worktree`'s HEAD into `git_ref` in the repository checkout. + fn land_worktree(&self, worktree: &Path, git_ref: &str) -> Result<(), OrchestratorError> { let dirty = self .workspace .status_porcelain(self.workspace.repo_root())?; if !dirty.trim().is_empty() { return Err(OrchestratorError::Workspace(WorkspaceError::Dirty)); } - let integrate = self.integrate_path(graph_id); - let integrate_head = self.workspace.head(&integrate)?; + let head = self.workspace.head(worktree)?; self.workspace.checkout_ref(git_ref)?; - if self.workspace.is_ancestor(git_ref, &integrate_head)? { - self.workspace.merge_ff_only(&integrate_head)?; + if self.workspace.is_ancestor(git_ref, &head)? { + self.workspace.merge_ff_only(&head)?; } else { - self.workspace.merge_no_ff(&integrate_head)?; + self.workspace.merge_no_ff(&head)?; } Ok(()) } + /// The nodes tagged `tag` plus everything they transitively depend on, in topological + /// order, and which of those carry a different tag. + fn deliverable_closure( + graph: &TaskGraph, + tag: &str, + ) -> Result<(Vec, Vec), OrchestratorError> { + let tagged: HashSet = graph + .nodes + .iter() + .filter(|n| n.deliverable.as_deref() == Some(tag)) + .map(|n| n.id) + .collect(); + if tagged.is_empty() { + return Err(OrchestratorError::UnknownDeliverable(tag.into())); + } + let deps: HashMap> = + graph.nodes.iter().map(|n| (n.id, &n.depends_on)).collect(); + let mut needed = tagged.clone(); + let mut stack: Vec = tagged.iter().copied().collect(); + while let Some(id) = stack.pop() { + for d in deps.get(&id).copied().into_iter().flatten() { + if needed.insert(*d) { + stack.push(*d); + } + } + } + let order: Vec = graph + .try_topological_order() + .map_err(|e| OrchestratorError::Illegal(format!("{e:?}")))? + .into_iter() + .filter(|id| needed.contains(id)) + .collect(); + let outside = order + .iter() + .copied() + .filter(|id| !tagged.contains(id)) + .collect(); + Ok((order, outside)) + } + + /// Builds a fresh worktree from the run base holding only the work of the nodes tagged + /// `tag` and their dependencies. Attempts start from the shared integrate head, so an + /// attempt branch also carries the commits of nodes integrated before it; only each + /// node's own commits (`..`) are replayed. Replaces any + /// earlier build of the same tag. + pub fn prepare_deliverable( + &mut self, + graph_id: &str, + tag: &str, + ) -> Result { + if !meshloop_domain::task_graph::graph_id_is_legal(tag) { + return Err(OrchestratorError::UnknownDeliverable(tag.into())); + } + let run = self + .store + .load_run(graph_id)? + .ok_or_else(|| OrchestratorError::MissingGraph(graph_id.into()))?; + let graph = self.graph(graph_id)?; + let (order, outside_dependencies) = Self::deliverable_closure(&graph, tag)?; + let tasks = self.tasks(graph_id)?; + for id in &order { + let state = tasks.get(id).copied().unwrap_or(TaskState::Pending); + if !matches!(state, TaskState::Accepted | TaskState::Integrated) { + return Err(OrchestratorError::DeliverableNotReady { task: *id, state }); + } + } + + let wt = self.deliverable_path(graph_id, tag); + let branch = self.deliverable_branch(graph_id, tag); + self.workspace.remove_worktree(&wt)?; + self.workspace.prune()?; + self.workspace.delete_branch(&branch)?; + let _ = std::fs::create_dir_all(self.graph_dir(graph_id)); + self.workspace + .add_worktree_from(&wt, &branch, &run.run_base)?; + + for id in &order { + let attempt = self + .store + .latest_attempt_for_task(graph_id, *id)? + .ok_or_else(|| { + OrchestratorError::Illegal(format!("task {} has no attempt", id.0)) + })?; + let base = attempt + .outcome + .as_deref() + .and_then(|o| o.strip_prefix("base:")) + .ok_or_else(|| { + OrchestratorError::Illegal(format!( + "task {} attempt {} has no recorded base revision", + id.0, attempt.attempt_id.0 + )) + })?; + let head = self.attempt_branch(graph_id, *id, attempt.attempt_id); + self.workspace + .cherry_pick_range(&wt, base, &head) + .map_err(|e| match e { + WorkspaceError::Conflict => OrchestratorError::DeliverableConflict(*id), + other => OrchestratorError::Workspace(other), + })?; + } + + Ok(DeliverableView { + tag: tag.into(), + head: self.workspace.head(&wt)?, + worktree: wt, + branch, + nodes: order, + outside_dependencies, + }) + } + + /// Lands the reviewed deliverable worktree into `git_ref`, building it first if absent. + pub fn integrate_deliverable_into( + &mut self, + graph_id: &str, + tag: &str, + git_ref: &str, + ) -> Result<(), OrchestratorError> { + let wt = self.deliverable_path(graph_id, tag); + if !self.workspace.worktree_exists(&wt) { + self.prepare_deliverable(graph_id, tag)?; + } + self.land_worktree(&wt, git_ref) + } + /// Attempts for a task, oldest first, with their (redacted) evidence rows. pub fn attempts( &self, @@ -2200,6 +2362,7 @@ impl<'a> RunLoop<'a> { revision: None, pane_id, live, + deliverable: n.deliverable.clone(), }); } Ok(RunStatus { diff --git a/docs/architecture/r1-closed-loop-design.md b/docs/architecture/r1-closed-loop-design.md index b64bcc9..ad46387 100644 --- a/docs/architecture/r1-closed-loop-design.md +++ b/docs/architecture/r1-closed-loop-design.md @@ -450,6 +450,24 @@ flowchart LR IW -.->|meshloop integrate --into --accept-integrate| Op ``` +**Per-deliverable integrate (#55).** A node may carry an optional `deliverable` tag (same +character set as `graph_id`). `meshloop integrate --graph --deliverable --into ` +lands only that group, for one PR per issue from a multi-issue graph: + +- Without `--accept-integrate` it builds a fresh worktree `/integrate-` (branch + `meshloop//integrate-`) from `run_base` and prints it for review. It never touches + `repo_root`. Rebuilding replaces the previous build of that tag. +- The worktree holds the tagged nodes plus their transitive dependencies (reported as + `outside_dependencies` when they carry another tag), in topological order. Every one must be + `Accepted` or `Integrated`. +- Attempts start from the shared integrate head, so an attempt branch also carries the commits + of nodes integrated before it. Only each node's own commits are replayed: + `git cherry-pick ..`, with the base taken from the attempt's + recorded `base:`. A conflict aborts the cherry-pick and names the node; it means that + node's change depends on work outside the deliverable without declaring it. +- With `--accept-integrate` it lands the reviewed worktree (building it first if absent) with + the same dirty check and fast-forward-or-`--no-ff` rule as the graph-level integrate. + **One writer.** At most one `Running` attempt. `max_concurrent_workers > 1` logs a warning and is clamped to 1. **Leftover Git state after crash:** diff --git a/docs/engineering/agent-workflow.md b/docs/engineering/agent-workflow.md index 0c60662..eca5c80 100644 --- a/docs/engineering/agent-workflow.md +++ b/docs/engineering/agent-workflow.md @@ -93,7 +93,11 @@ When an issue splits into independent nodes, the developer role drives Meshloop: `meshloop accept --task --as `, naming whoever actually accepted. Tier 3 nodes always need human acceptance. 4. Integrate explicitly with `meshloop integrate --graph --into work/`, then - rerun the workspace check on the combined candidate. + rerun the workspace check on the combined candidate. When one graph delivers several + issues, tag each node with `"deliverable": "issue-"` in the plan and land each issue on + its own branch with `meshloop integrate --graph --deliverable issue- --into + work/`: run it once without `--accept-integrate` to build the worktree for review, + then again with it. Meshloop owns its technical plan, worktrees, workers, and technical acceptance. AgentFlow owns SDLC acceptance. A Meshloop completion receipt (`git_export`) is evidence, not a merge diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae8d39e..8f0a444 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1460,6 +1460,7 @@ fn run_mutation_benchmarks(root: &Path, verbose: bool) -> Result<(f64, bool), St tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }; let mutation = GraphMutation::InsertPrerequisite { target_task, @@ -1485,6 +1486,7 @@ fn run_mutation_benchmarks(root: &Path, verbose: bool) -> Result<(f64, bool), St tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }; let mutation = GraphMutation::InsertPrerequisite { target_task, @@ -1556,6 +1558,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }, TaskNode { id: TaskId(2), @@ -1564,6 +1567,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }, ], }; @@ -1668,6 +1672,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1683,6 +1688,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1698,6 +1704,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1713,6 +1720,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1728,6 +1736,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1783,6 +1792,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }; let res = saga.mutate_plan(graph_id, active_mutation, false); From 5d637ab972dfd49ef7ef3dcd16814b89db5541c5 Mon Sep 17 00:00:00 2001 From: Samuel <184254+smota@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:27:56 +0200 Subject: [PATCH 3/3] docs(skills): say who starts workers, give paste-safe commands Agents under a permission classifier were blocked from plan/run, retried, then handed users commands with that broke PowerShell. - meshloop-plan, meshloop-run, meshloop-review-plan (+ session-bundle copies): plan/run/resume/review-plan --adjust start unattended agents; a refusal is correct, so the user runs the command or adds a permission rule. Agents may run doctor/status/inspect/watch/roles, and record a person's decision only after they answer. - Example commands are one line with values up front, PowerShell and POSIX forms, no <...> placeholders. - Fix the claim that doctor detects the origin session: it only echoes --origin-* or MESHLOOP_ORIGIN_*. - docs/start.md: "Supervising from an Agent Session", and deliverable integrate (#55) in step 5. Implements #58 Closes #58 Co-Authored-By: Claude Opus 5.5 --- .../skills/meshloop-plan/SKILL.md | 43 +++++++++++--- .../skills/meshloop-review-plan/SKILL.md | 25 +++++--- .../skills/meshloop-run/SKILL.md | 59 +++++++++++++++---- docs/start.md | 37 ++++++++++++ skills/meshloop-plan/SKILL.md | 43 +++++++++++--- skills/meshloop-review-plan/SKILL.md | 25 +++++--- skills/meshloop-run/SKILL.md | 59 +++++++++++++++---- 7 files changed, 235 insertions(+), 56 deletions(-) diff --git a/crates/meshloop-cli/session-bundle/skills/meshloop-plan/SKILL.md b/crates/meshloop-cli/session-bundle/skills/meshloop-plan/SKILL.md index 298c673..ede1e78 100644 --- a/crates/meshloop-cli/session-bundle/skills/meshloop-plan/SKILL.md +++ b/crates/meshloop-cli/session-bundle/skills/meshloop-plan/SKILL.md @@ -7,17 +7,46 @@ description: Turn origin-session intent into a Meshloop task graph via meshloop: You are the **meshloop:origin** supervisor. Do not implement the work. -1. If `MESHLOOP_ORIGIN_SESSION` is unset, run `meshloop doctor --json` and export - `MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION` from the JSON. -2. Write conversation-scoped intent (techniques, scope, exclusions). -3. Invoke the binary (no saga in this skill): +1. Know your origin: your harness name (`claude`, `codex`, `pi`, `grok`, `agy`) and this + session's id. Meshloop takes them from `--origin-harness` / `--origin-session`, or from + `MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION`. `meshloop doctor --json` only + echoes them back (`origin_harness`, `origin_session`); it does not detect them, so pass + them explicitly. +2. Write conversation-scoped intent (techniques, scope, exclusions) to a file. +3. `meshloop plan` starts a planner worker. Follow **Starting workers** below. The command, + with the values at the start of the line replaced: -```text -meshloop plan --objective "" --intent-file --json --origin-harness --origin-session +```powershell +$h = "claude"; $s = "0f1e2d3c-session-id"; $d = "C:\Users\me\runs\issue-12"; meshloop plan --objective "Deliver issue 12" --intent-file "$d\intent.md" --config "$d\meshloop.toml" --out "$d\meshloop-plan.json" --json --origin-harness $h --origin-session $s +``` + +```bash +h=claude; s=0f1e2d3c-session-id; d="/home/me/runs/issue-12"; meshloop plan --objective "Deliver issue 12" --intent-file "$d/intent.md" --config "$d/meshloop.toml" --out "$d/meshloop-plan.json" --json --origin-harness "$h" --origin-session "$s" ``` Equivalent slash: `/meshloop:plan`. MCP: `meshloop_plan`. 4. Show the graph. Then `/meshloop:review-plan`: ask **Accept**, **Decline**, or **Adjust**. Do not call `meshloop run --accept-plan` until they accept. -5. Never launch a worker in this session. Never use unprefixed `plan` / `planner` tools from other harnesses for this job. +5. Never use unprefixed `plan` / `planner` tools from other harnesses for this job. + +## Starting workers + +`plan`, `run`, `resume` and `review-plan --adjust` start unattended agents that write +files. Your harness's permission check may refuse to let you run them. That refusal is +correct: do not retry through another command, a script, or a different tool. Either: + +- the user runs the command in their own terminal, or +- the user adds a permission rule that allows these Meshloop commands, then asks you to + continue. + +When the user runs it, hand over **one line they can paste**: + +- Substitute every value. Use absolute paths in double quotes. +- No `<...>` placeholders: PowerShell parses `<` as a redirection operator. +- Match their shell. PowerShell sets values with `$d = "C:\..."; meshloop ...`; POSIX + shells with `d="/..."; meshloop ...`. + +You can always run the commands that start nothing: `doctor`, `status`, `inspect`, +`watch` and `roles`. `review-plan --accept|--decline` and `accept` record a person's +decision: run them only after that person answers, and name them with `--as`. diff --git a/crates/meshloop-cli/session-bundle/skills/meshloop-review-plan/SKILL.md b/crates/meshloop-cli/session-bundle/skills/meshloop-review-plan/SKILL.md index a741a63..81ac189 100644 --- a/crates/meshloop-cli/session-bundle/skills/meshloop-review-plan/SKILL.md +++ b/crates/meshloop-cli/session-bundle/skills/meshloop-review-plan/SKILL.md @@ -7,7 +7,9 @@ description: Ask the origin user to accept, decline, or adjust a Meshloop graph. You are `meshloop:origin`. Do not implement. Do not start workers yet. -If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first. +Pass your origin explicitly (`--origin-harness`, `--origin-session`, or +`MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION`); `/meshloop:doctor` only echoes +them back. **Ask once, in English**, then wait: @@ -16,19 +18,24 @@ If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first. Treat Portuguese `aceitar` / `recusar` / `ajustar` (and close synonyms) as the same three intents. Do **not** show CLI flags until they have chosen. Then run -**exactly one**: +**exactly one**, with the values at the start of the line replaced and `--as` naming +the person who answered: -```text -meshloop review-plan --plan meshloop-plan.json --accept --as --json \ - --origin-harness --origin-session +```bash +h=claude; s=0f1e2d3c-session-id; p="/home/me/runs/issue-12/meshloop-plan.json"; meshloop review-plan --plan "$p" --accept --as "Sam" --json --origin-harness "$h" --origin-session "$s" -meshloop review-plan --plan meshloop-plan.json --decline --reason "" --json \ - --origin-harness --origin-session +h=claude; s=0f1e2d3c-session-id; p="/home/me/runs/issue-12/meshloop-plan.json"; meshloop review-plan --plan "$p" --decline --reason "Splits issue 12 too finely" --json --origin-harness "$h" --origin-session "$s" -meshloop review-plan --plan meshloop-plan.json --adjust --objective "" --json \ - --origin-harness --origin-session +h=claude; s=0f1e2d3c-session-id; p="/home/me/runs/issue-12/meshloop-plan.json"; meshloop review-plan --plan "$p" --adjust --objective "Merge nodes 2 and 3" --json --origin-harness "$h" --origin-session "$s" ``` +Accept and Decline start nothing, so you can run them yourself once the user has +answered. **Adjust runs the planner again**, which starts a worker: your harness may +refuse to let you run it, and that refusal is correct. Do not route around it. Hand the +user one line to paste instead (PowerShell form: `$p = "C:\..."; meshloop review-plan +--plan "$p" ...`), with every value substituted and no `<...>` placeholders, which +PowerShell parses as redirection. + | Reply | Stored | `run` | |---|---|---| | Accept | `PlanAccepted` | allowed (no `--accept-plan`) | diff --git a/crates/meshloop-cli/session-bundle/skills/meshloop-run/SKILL.md b/crates/meshloop-cli/session-bundle/skills/meshloop-run/SKILL.md index 00a940d..9d3d33e 100644 --- a/crates/meshloop-cli/session-bundle/skills/meshloop-run/SKILL.md +++ b/crates/meshloop-cli/session-bundle/skills/meshloop-run/SKILL.md @@ -5,32 +5,67 @@ description: Execute an accepted Meshloop graph. Supervisor-only origin. Slash / # /meshloop:run -If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first. +Pass your origin explicitly (`--origin-harness`, `--origin-session`, or +`MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION`); `/meshloop:doctor` only echoes +them back. **Only after** the user Accepted via `/meshloop:review-plan` (`PlanAccepted`). -If the engine says the plan is not accepted, ask `/meshloop:review-plan` — do +If the engine says the plan is not accepted, ask `/meshloop:review-plan`. Do **not** add `--accept-plan`. -```text -meshloop run --plan --json --origin-harness --origin-session +`run` starts workers. Follow **Starting workers** below. The command, with the values at +the start of the line replaced (`--detach` returns at once and keeps workers running): + +```powershell +$h = "claude"; $s = "0f1e2d3c-session-id"; $d = "C:\Users\me\runs\issue-12"; meshloop run --plan "$d\meshloop-plan.json" --config "$d\meshloop.toml" --detach --json --origin-harness $h --origin-session $s +``` + +```bash +h=claude; s=0f1e2d3c-session-id; d="/home/me/runs/issue-12"; meshloop run --plan "$d/meshloop-plan.json" --config "$d/meshloop.toml" --detach --json --origin-harness "$h" --origin-session "$s" ``` A **worker executes via direct CLI in an ephemeral git worktree**. This origin pane must not implement the work. Current branch stays put. -Do not poll `inspect`. Block with `meshloop watch --graph --json` (NDJSON -state changes; last line `{"exit":,"nodes":[…]}`; exit 0 = needs a human -or completed, 1 = failed/cancelled, 3 = `--timeout`). Then `/meshloop:status` or -`/meshloop:accept`. `--fixture-only` is CI only. +Do not poll `inspect`. Block with `watch`, which starts nothing, so you can run it +yourself (NDJSON state changes; last line such as `{"exit":"awaiting_review","nodes":[2]}`; exit 0 = +needs a human or completed, 1 = failed/cancelled, 3 = `--timeout`): + +```bash +meshloop watch --graph issue-12 --config "/home/me/runs/issue-12/meshloop.toml" --json +``` + +Then `/meshloop:status` or `/meshloop:accept`. `--fixture-only` is CI only. `--accept-plan` on `run` is a debug shortcut that skips the 3-way question. Do not use it from this skill. If the engine says the graph already exists / FailedTerminal, do **not** invent a -new `graph_id`. The operator can re-run this accepted plan with: +new `graph_id`. Re-run the accepted plan with `resume --restart` (it starts workers, +so the same rules apply), or `run --plan ... --reset`. `--restart` and `--retry` are +exclusive: -```text -meshloop resume --restart --json --origin-harness --origin-session +```bash +h=claude; s=0f1e2d3c-session-id; d="/home/me/runs/issue-12"; meshloop resume --restart --config "$d/meshloop.toml" --json --origin-harness "$h" --origin-session "$s" ``` -or `meshloop run --plan --reset`. `--restart` and `--retry` are exclusive. +## Starting workers + +`plan`, `run`, `resume` and `review-plan --adjust` start unattended agents that write +files. Your harness's permission check may refuse to let you run them. That refusal is +correct: do not retry through another command, a script, or a different tool. Either: + +- the user runs the command in their own terminal, or +- the user adds a permission rule that allows these Meshloop commands, then asks you to + continue. + +When the user runs it, hand over **one line they can paste**: + +- Substitute every value. Use absolute paths in double quotes. +- No `<...>` placeholders: PowerShell parses `<` as a redirection operator. +- Match their shell. PowerShell sets values with `$d = "C:\..."; meshloop ...`; POSIX + shells with `d="/..."; meshloop ...`. + +You can always run the commands that start nothing: `doctor`, `status`, `inspect`, +`watch` and `roles`. `review-plan --accept|--decline` and `accept` record a person's +decision: run them only after that person answers, and name them with `--as`. diff --git a/docs/start.md b/docs/start.md index 995e47e..ba5a4a7 100644 --- a/docs/start.md +++ b/docs/start.md @@ -104,6 +104,43 @@ meshloop integrate --graph --into main --accept-integrate ``` Without `--accept-integrate`, integration is refused. +When one graph delivers several issues, tag each node with `"deliverable": "issue-12"` in +the plan and land each issue on its own branch. Without `--accept-integrate` this builds the +deliverable's worktree for review instead of refusing: +```bash +meshloop integrate --graph sprint-4 --deliverable issue-12 --into work/issue-12 +meshloop integrate --graph sprint-4 --deliverable issue-12 --into work/issue-12 --accept-integrate +``` + +--- + +## Supervising from an Agent Session + +The origin agent (Claude Code, Codex, ...) supervises; it never implements the work. + +- **Origin identity.** Pass `--origin-harness` and `--origin-session`, or set + `MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION`. `meshloop doctor --json` only echoes + these back as `origin_harness` / `origin_session`; it cannot detect them. +- **Commands that start workers:** `plan`, `run`, `resume`, `review-plan --adjust`, and + `orchestrate` with an origin session (live reviewers). They launch unattended agents that + write files, so an agent harness's permission check may refuse them. That is the intended + default. Either you run the command yourself, or you add a permission rule that allows + these Meshloop commands for the agent. The agent must not route around a refusal. +- **Commands an agent can always run:** `doctor`, `status`, `inspect`, `watch` and `roles` + start nothing. `review-plan --accept|--decline` and `accept` record a person's decision, so + the agent runs them only after that person answers, naming them with `--as`. +- **Paste-ready commands.** When the agent hands you a command, it should be one line with + every value filled in and absolute paths quoted. A `<...>` placeholder breaks PowerShell, + which parses `<` as redirection: + ```powershell + $d = "C:\Users\me\runs\issue-12"; meshloop run --plan "$d\meshloop-plan.json" --config "$d\meshloop.toml" --detach --json --origin-harness claude --origin-session 0f1e2d3c-session-id + ``` + ```bash + d="/home/me/runs/issue-12"; meshloop run --plan "$d/meshloop-plan.json" --config "$d/meshloop.toml" --detach --json --origin-harness claude --origin-session 0f1e2d3c-session-id + ``` +- **Waiting.** After a detached `run`, the agent blocks on `meshloop watch --graph` with the graph id + instead of polling `inspect`. + --- ## CI / Fixture Mode (Offline Testing) diff --git a/skills/meshloop-plan/SKILL.md b/skills/meshloop-plan/SKILL.md index 298c673..ede1e78 100644 --- a/skills/meshloop-plan/SKILL.md +++ b/skills/meshloop-plan/SKILL.md @@ -7,17 +7,46 @@ description: Turn origin-session intent into a Meshloop task graph via meshloop: You are the **meshloop:origin** supervisor. Do not implement the work. -1. If `MESHLOOP_ORIGIN_SESSION` is unset, run `meshloop doctor --json` and export - `MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION` from the JSON. -2. Write conversation-scoped intent (techniques, scope, exclusions). -3. Invoke the binary (no saga in this skill): +1. Know your origin: your harness name (`claude`, `codex`, `pi`, `grok`, `agy`) and this + session's id. Meshloop takes them from `--origin-harness` / `--origin-session`, or from + `MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION`. `meshloop doctor --json` only + echoes them back (`origin_harness`, `origin_session`); it does not detect them, so pass + them explicitly. +2. Write conversation-scoped intent (techniques, scope, exclusions) to a file. +3. `meshloop plan` starts a planner worker. Follow **Starting workers** below. The command, + with the values at the start of the line replaced: -```text -meshloop plan --objective "" --intent-file --json --origin-harness --origin-session +```powershell +$h = "claude"; $s = "0f1e2d3c-session-id"; $d = "C:\Users\me\runs\issue-12"; meshloop plan --objective "Deliver issue 12" --intent-file "$d\intent.md" --config "$d\meshloop.toml" --out "$d\meshloop-plan.json" --json --origin-harness $h --origin-session $s +``` + +```bash +h=claude; s=0f1e2d3c-session-id; d="/home/me/runs/issue-12"; meshloop plan --objective "Deliver issue 12" --intent-file "$d/intent.md" --config "$d/meshloop.toml" --out "$d/meshloop-plan.json" --json --origin-harness "$h" --origin-session "$s" ``` Equivalent slash: `/meshloop:plan`. MCP: `meshloop_plan`. 4. Show the graph. Then `/meshloop:review-plan`: ask **Accept**, **Decline**, or **Adjust**. Do not call `meshloop run --accept-plan` until they accept. -5. Never launch a worker in this session. Never use unprefixed `plan` / `planner` tools from other harnesses for this job. +5. Never use unprefixed `plan` / `planner` tools from other harnesses for this job. + +## Starting workers + +`plan`, `run`, `resume` and `review-plan --adjust` start unattended agents that write +files. Your harness's permission check may refuse to let you run them. That refusal is +correct: do not retry through another command, a script, or a different tool. Either: + +- the user runs the command in their own terminal, or +- the user adds a permission rule that allows these Meshloop commands, then asks you to + continue. + +When the user runs it, hand over **one line they can paste**: + +- Substitute every value. Use absolute paths in double quotes. +- No `<...>` placeholders: PowerShell parses `<` as a redirection operator. +- Match their shell. PowerShell sets values with `$d = "C:\..."; meshloop ...`; POSIX + shells with `d="/..."; meshloop ...`. + +You can always run the commands that start nothing: `doctor`, `status`, `inspect`, +`watch` and `roles`. `review-plan --accept|--decline` and `accept` record a person's +decision: run them only after that person answers, and name them with `--as`. diff --git a/skills/meshloop-review-plan/SKILL.md b/skills/meshloop-review-plan/SKILL.md index a741a63..81ac189 100644 --- a/skills/meshloop-review-plan/SKILL.md +++ b/skills/meshloop-review-plan/SKILL.md @@ -7,7 +7,9 @@ description: Ask the origin user to accept, decline, or adjust a Meshloop graph. You are `meshloop:origin`. Do not implement. Do not start workers yet. -If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first. +Pass your origin explicitly (`--origin-harness`, `--origin-session`, or +`MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION`); `/meshloop:doctor` only echoes +them back. **Ask once, in English**, then wait: @@ -16,19 +18,24 @@ If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first. Treat Portuguese `aceitar` / `recusar` / `ajustar` (and close synonyms) as the same three intents. Do **not** show CLI flags until they have chosen. Then run -**exactly one**: +**exactly one**, with the values at the start of the line replaced and `--as` naming +the person who answered: -```text -meshloop review-plan --plan meshloop-plan.json --accept --as --json \ - --origin-harness --origin-session +```bash +h=claude; s=0f1e2d3c-session-id; p="/home/me/runs/issue-12/meshloop-plan.json"; meshloop review-plan --plan "$p" --accept --as "Sam" --json --origin-harness "$h" --origin-session "$s" -meshloop review-plan --plan meshloop-plan.json --decline --reason "" --json \ - --origin-harness --origin-session +h=claude; s=0f1e2d3c-session-id; p="/home/me/runs/issue-12/meshloop-plan.json"; meshloop review-plan --plan "$p" --decline --reason "Splits issue 12 too finely" --json --origin-harness "$h" --origin-session "$s" -meshloop review-plan --plan meshloop-plan.json --adjust --objective "" --json \ - --origin-harness --origin-session +h=claude; s=0f1e2d3c-session-id; p="/home/me/runs/issue-12/meshloop-plan.json"; meshloop review-plan --plan "$p" --adjust --objective "Merge nodes 2 and 3" --json --origin-harness "$h" --origin-session "$s" ``` +Accept and Decline start nothing, so you can run them yourself once the user has +answered. **Adjust runs the planner again**, which starts a worker: your harness may +refuse to let you run it, and that refusal is correct. Do not route around it. Hand the +user one line to paste instead (PowerShell form: `$p = "C:\..."; meshloop review-plan +--plan "$p" ...`), with every value substituted and no `<...>` placeholders, which +PowerShell parses as redirection. + | Reply | Stored | `run` | |---|---|---| | Accept | `PlanAccepted` | allowed (no `--accept-plan`) | diff --git a/skills/meshloop-run/SKILL.md b/skills/meshloop-run/SKILL.md index 00a940d..9d3d33e 100644 --- a/skills/meshloop-run/SKILL.md +++ b/skills/meshloop-run/SKILL.md @@ -5,32 +5,67 @@ description: Execute an accepted Meshloop graph. Supervisor-only origin. Slash / # /meshloop:run -If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first. +Pass your origin explicitly (`--origin-harness`, `--origin-session`, or +`MESHLOOP_ORIGIN_HARNESS` / `MESHLOOP_ORIGIN_SESSION`); `/meshloop:doctor` only echoes +them back. **Only after** the user Accepted via `/meshloop:review-plan` (`PlanAccepted`). -If the engine says the plan is not accepted, ask `/meshloop:review-plan` — do +If the engine says the plan is not accepted, ask `/meshloop:review-plan`. Do **not** add `--accept-plan`. -```text -meshloop run --plan --json --origin-harness --origin-session +`run` starts workers. Follow **Starting workers** below. The command, with the values at +the start of the line replaced (`--detach` returns at once and keeps workers running): + +```powershell +$h = "claude"; $s = "0f1e2d3c-session-id"; $d = "C:\Users\me\runs\issue-12"; meshloop run --plan "$d\meshloop-plan.json" --config "$d\meshloop.toml" --detach --json --origin-harness $h --origin-session $s +``` + +```bash +h=claude; s=0f1e2d3c-session-id; d="/home/me/runs/issue-12"; meshloop run --plan "$d/meshloop-plan.json" --config "$d/meshloop.toml" --detach --json --origin-harness "$h" --origin-session "$s" ``` A **worker executes via direct CLI in an ephemeral git worktree**. This origin pane must not implement the work. Current branch stays put. -Do not poll `inspect`. Block with `meshloop watch --graph --json` (NDJSON -state changes; last line `{"exit":,"nodes":[…]}`; exit 0 = needs a human -or completed, 1 = failed/cancelled, 3 = `--timeout`). Then `/meshloop:status` or -`/meshloop:accept`. `--fixture-only` is CI only. +Do not poll `inspect`. Block with `watch`, which starts nothing, so you can run it +yourself (NDJSON state changes; last line such as `{"exit":"awaiting_review","nodes":[2]}`; exit 0 = +needs a human or completed, 1 = failed/cancelled, 3 = `--timeout`): + +```bash +meshloop watch --graph issue-12 --config "/home/me/runs/issue-12/meshloop.toml" --json +``` + +Then `/meshloop:status` or `/meshloop:accept`. `--fixture-only` is CI only. `--accept-plan` on `run` is a debug shortcut that skips the 3-way question. Do not use it from this skill. If the engine says the graph already exists / FailedTerminal, do **not** invent a -new `graph_id`. The operator can re-run this accepted plan with: +new `graph_id`. Re-run the accepted plan with `resume --restart` (it starts workers, +so the same rules apply), or `run --plan ... --reset`. `--restart` and `--retry` are +exclusive: -```text -meshloop resume --restart --json --origin-harness --origin-session +```bash +h=claude; s=0f1e2d3c-session-id; d="/home/me/runs/issue-12"; meshloop resume --restart --config "$d/meshloop.toml" --json --origin-harness "$h" --origin-session "$s" ``` -or `meshloop run --plan --reset`. `--restart` and `--retry` are exclusive. +## Starting workers + +`plan`, `run`, `resume` and `review-plan --adjust` start unattended agents that write +files. Your harness's permission check may refuse to let you run them. That refusal is +correct: do not retry through another command, a script, or a different tool. Either: + +- the user runs the command in their own terminal, or +- the user adds a permission rule that allows these Meshloop commands, then asks you to + continue. + +When the user runs it, hand over **one line they can paste**: + +- Substitute every value. Use absolute paths in double quotes. +- No `<...>` placeholders: PowerShell parses `<` as a redirection operator. +- Match their shell. PowerShell sets values with `$d = "C:\..."; meshloop ...`; POSIX + shells with `d="/..."; meshloop ...`. + +You can always run the commands that start nothing: `doctor`, `status`, `inspect`, +`watch` and `roles`. `review-plan --accept|--decline` and `accept` record a person's +decision: run them only after that person answers, and name them with `--as`.