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-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/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/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/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..f51f873 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}; @@ -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 { @@ -307,6 +310,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 +387,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 +442,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 +555,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 +586,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 +683,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 +769,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 +989,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) } @@ -1051,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::>(), }), @@ -1265,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::>(), }), @@ -1309,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::>(), }); @@ -1536,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); @@ -1563,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}")) } @@ -1736,6 +1870,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 +1888,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 +1918,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 +1946,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 +1980,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 +2283,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 { @@ -2197,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 0428e66..58a6e4c 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\ @@ -18,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\ @@ -76,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()); @@ -154,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 7ad5b5b..c9327e6 100644 --- a/crates/meshloop-cli/tests/plan_and_run.rs +++ b/crates/meshloop-cli/tests/plan_and_run.rs @@ -2140,3 +2140,359 @@ 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(); +} + +/// 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 5913edd..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 { @@ -84,6 +93,42 @@ 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) + } +} + +/// 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 { + pub graph: TaskGraph, + pub planner: Candidate, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IdleReason { GraphComplete, @@ -121,6 +166,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`). @@ -155,6 +201,7 @@ pub struct NodeStatus { pub revision: Option, pub pane_id: Option, pub live: Option, + pub deliverable: Option, } #[derive(Debug, Clone)] @@ -293,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") } @@ -349,34 +404,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 +486,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( @@ -2003,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, @@ -2161,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/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/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`. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b920a74..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, }, ], }; @@ -1615,6 +1619,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)] @@ -1667,6 +1672,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1682,6 +1688,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1697,6 +1704,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1712,6 +1720,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1727,6 +1736,7 @@ fn run_mutation_rollback_tests(root: &Path) -> Result { tier: None, allowed_paths: vec![], empty_diff_ok: false, + deliverable: None, }], }, ), @@ -1782,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);