Skip to content
Merged
24 changes: 24 additions & 0 deletions crates/meshloop-adapters/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,30 @@ impl EvidenceStore for SqliteStore {
}
Ok(out)
}

fn evidence_for_attempt(
&self,
task_id: TaskId,
attempt_id: AttemptId,
) -> Result<Vec<Evidence>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT payload_json FROM evidence WHERE task_id = ?1 AND attempt_id = ?2 ORDER BY rowid",
)
.map_err(|e| StoreError::Io(e.to_string()))?;
let rows = stmt
.query_map(params![task_id.0, attempt_id.0], |r| r.get::<_, String>(0))
.map_err(|e| StoreError::Io(e.to_string()))?;
let mut out = Vec::new();
for row in rows {
let json = row.map_err(|e| StoreError::Io(e.to_string()))?;
let ev: Evidence =
serde_json::from_str(&json).map_err(|e| StoreError::Corrupt(e.to_string()))?;
out.push(ev);
}
Ok(out)
}
}

impl RoutingFeedbackStore for SqliteStore {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ description: Record human acceptance of a Meshloop node. Slash /meshloop:accept.

# /meshloop:accept

R1 rule: every node stops for `meshloop accept`; the tier selects harness/model only.
`status`/`inspect` report `waiting_for: acceptance` for nodes awaiting this step.

Only after the user agrees in this supervisor session:

```text
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ meshloop review-plan --plan meshloop-plan.json --adjust --objective "<changes>"
| Adjust | still `AwaitingPlanReview` | refuses; ask again after the new graph |

Do not call `run --accept-plan` to skip this question.

R1 rule: every node stops for `meshloop accept`; the tier selects harness/model only.
The review-plan JSON lists them under `acceptance_required`.
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ meshloop run --plan <file> --json --origin-harness <h> --origin-session <id>
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. Read the JSON idle reason; then `/meshloop:status` or
Do not poll `inspect`. Block with `meshloop watch --graph <id> --json` (NDJSON
state changes; last line `{"exit":<reason>,"nodes":[…]}`; exit 0 = needs a human
or completed, 1 = failed/cancelled, 3 = `--timeout`). 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first.
```text
meshloop status --json [--graph <id>] --origin-harness <h> --origin-session <id>
```

To wait for a run instead of polling, use
`meshloop watch --graph <id> --json [--interval <s>] [--timeout <s>]`. It reads
the store only (works on `run --detach`), prints one NDJSON line per node state
change, and ends with `{"exit":<reason>,"nodes":[…]}`: `awaiting_review`,
`awaiting_plan_acceptance`, `completed` (exit 0), `failed`/`cancelled` (exit 1),
`timeout` (exit 3).
279 changes: 279 additions & 0 deletions crates/meshloop-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ pub enum Command {
session_id: Option<String>,
config: Option<PathBuf>,
db: Option<PathBuf>,
attempts: bool,
},
Watch {
graph: Option<String>,
session_id: Option<String>,
config: Option<PathBuf>,
db: Option<PathBuf>,
interval_secs: u64,
timeout_secs: u64,
},
Accept {
task: u32,
Expand Down Expand Up @@ -163,7 +172,168 @@ pub fn parse(args: &[String]) -> Result<Invocation, String> {
})
}

/// Flags every subcommand accepts: `(takes_value, name)`.
const GLOBAL_FLAGS: &[(bool, &str)] = &[
(false, "--json"),
(true, "--origin-harness"),
(true, "--origin-session"),
(false, "--help"),
(false, "-h"),
];

/// Per-verb flags beyond the global ones: `(takes_value, name)`. `None` for verbs that are
/// not subcommands (help, version) or unknown. Must mirror what `parse_command` reads.
fn verb_flags(verb: &str) -> Option<&'static [(bool, &'static str)]> {
Some(match verb {
"mcp" | "roles" => &[],
"doctor" => &[(true, "--config"), (true, "--db")],
"plan" => &[
(true, "--objective"),
(true, "--config"),
(true, "--out"),
(true, "--scope"),
(true, "--db"),
(true, "--intent-file"),
],
"review-plan" => &[
(false, "--accept"),
(false, "--decline"),
(false, "--adjust"),
(true, "--plan"),
(true, "--reason"),
(true, "--as"),
(true, "--objective"),
(true, "--intent-file"),
(true, "--config"),
(true, "--db"),
(true, "--out"),
(true, "--scope"),
(false, "--fixture-only"),
],
"run" => &[
(true, "--plan"),
(false, "--accept-plan"),
(false, "--reset"),
(true, "--config"),
(true, "--worktree-base"),
(true, "--db"),
(false, "--fixture-only"),
(false, "--detach"),
],
"status" => &[(true, "--graph"), (true, "--config"), (true, "--db")],
"resume" => &[
(true, "--graph"),
(false, "--retry"),
(false, "--restart"),
(true, "--config"),
(true, "--db"),
(true, "--worktree-base"),
(false, "--fixture-only"),
],
"cancel" => &[
(true, "--session-id"),
(true, "--graph"),
(true, "--task"),
(true, "--config"),
(true, "--db"),
(true, "--worktree-base"),
],
"inspect" => &[
(true, "--session-id"),
(true, "--graph"),
(true, "--task"),
(true, "--config"),
(true, "--db"),
(false, "--attempts"),
],
"watch" => &[
(true, "--session-id"),
(true, "--graph"),
(true, "--interval"),
(true, "--timeout"),
(true, "--config"),
(true, "--db"),
],
"accept" => &[
(true, "--task"),
(true, "--as"),
(true, "--graph"),
(true, "--config"),
(true, "--db"),
(true, "--worktree-base"),
],
"integrate" => &[
(true, "--graph"),
(true, "--into"),
(false, "--accept-integrate"),
(true, "--config"),
(true, "--db"),
(true, "--worktree-base"),
],
"orchestrate" => &[
(true, "--task"),
(true, "--graph"),
(true, "--config"),
(true, "--db"),
(true, "--worktree-base"),
(true, "--model-a"),
(true, "--model-b"),
(false, "--fixture-only"),
],
"bundle" => &[(true, "--dest"), (false, "--gitignore")],
"ast-skeleton" => &[(true, "--path")],
"symbol-lookup" => &[(true, "--query"), (true, "--k"), (true, "--scope")],
"mutate-plan" => &[
(true, "--graph"),
(true, "--mutation-file"),
(true, "--mutation"),
(false, "--require-review"),
(true, "--config"),
(true, "--db"),
(true, "--worktree-base"),
],
_ => return None,
})
}

fn looks_like_flag(token: &str) -> bool {
token.starts_with("--")
|| token
.strip_prefix('-')
.and_then(|r| r.chars().next())
.is_some_and(|c| c.is_alphabetic())
}

/// Reject any flag-like token the verb does not accept. The token after a value flag is
/// that flag's value and is never inspected, even when it starts with `-`.
fn reject_unknown_flags(verb: &str, rest: &[String]) -> Result<(), String> {
let Some(own) = verb_flags(verb) else {
return Ok(());
};
let mut tokens = rest.iter();
while let Some(token) = tokens.next() {
if !looks_like_flag(token) {
continue;
}
match GLOBAL_FLAGS
.iter()
.chain(own.iter())
.find(|(_, name)| name == token)
{
Some((true, _)) => {
tokens.next();
}
Some((false, _)) => {}
None => return Err(format!("meshloop:{verb} does not accept {token}")),
}
}
Ok(())
}

fn parse_command(args: &[String]) -> Result<Command, String> {
if let Some(first) = args.first() {
reject_unknown_flags(&verb(first), &args[1..])?;
}
match args.first().map(|s| verb(s)) {
None => Ok(Command::Status {
graph: None,
Expand Down Expand Up @@ -304,6 +474,32 @@ fn parse_command(args: &[String]) -> Result<Command, String> {
session_id,
config: flag_value(rest, "--config").map(PathBuf::from),
db: flag_value(rest, "--db").map(PathBuf::from),
attempts: has_flag(rest, "--attempts"),
})
}
Some(v) if v == "watch" => {
let rest = &args[1..];
let session_id = flag_value(rest, "--session-id");
let graph = flag_value(rest, "--graph").or_else(|| session_id.clone());
let interval_secs = match flag_value(rest, "--interval") {
Some(raw) => raw.parse::<u64>().ok().filter(|n| *n >= 1).ok_or_else(|| {
format!("meshloop:watch --interval expects whole seconds >= 1, got {raw}")
})?,
None => 5,
};
let timeout_secs = match flag_value(rest, "--timeout") {
Some(raw) => raw.parse::<u64>().map_err(|_| {
format!("meshloop:watch --timeout expects whole seconds (0 = none), got {raw}")
})?,
None => 0,
};
Ok(Command::Watch {
graph,
session_id,
config: flag_value(rest, "--config").map(PathBuf::from),
db: flag_value(rest, "--db").map(PathBuf::from),
interval_secs,
timeout_secs,
})
}
Some(v) if v == "accept" => {
Expand Down Expand Up @@ -414,6 +610,7 @@ pub fn help_text() -> &'static str {
Canonical ids: meshloop:plan | meshloop:review-plan | meshloop:run | meshloop:status | meshloop:accept\n\
\x20 meshloop:resume | meshloop:cancel | meshloop:inspect | meshloop:integrate | meshloop:roles\n\
\x20 meshloop:doctor | meshloop:orchestrate | meshloop:mutate-plan | meshloop:mcp | meshloop:bundle\n\
\x20 meshloop:watch\n\
CLI verbs (binary already namespaces): meshloop plan|run|status|... or meshloop meshloop:plan\n\
Slash: /meshloop:plan MCP tools: meshloop_plan\n\
Usage:\n\
Expand All @@ -422,6 +619,7 @@ pub fn help_text() -> &'static str {
\x20 meshloop run --plan <path> [--accept-plan] [--reset] [--fixture-only] [--json]\n\
\x20 meshloop resume [--graph <id>] [--retry|--restart] [--json]\n\
\x20 meshloop status [--graph <id>] [--config <path>] [--db <path>] [--json]\n\
\x20 meshloop watch --graph <id> [--session-id <id>] [--interval <secs, default 5>] [--timeout <secs, 0 = none>] [--json]\n\
\x20 meshloop roles [--json]\n\
\x20 meshloop doctor [--config <path>] [--db <path>] [--json]\n\
\x20 meshloop orchestrate --task <id> --model-a <ref> --model-b <ref> [--json]\n\
Expand Down Expand Up @@ -453,4 +651,85 @@ mod tests {
_ => panic!("expected status"),
}
}

fn argv(s: &str) -> Vec<String> {
s.split_whitespace().map(ToString::to_string).collect()
}

/// Every documented flag of every verb, with the required ones present.
const DOCUMENTED: &[&str] = &[
"doctor --config c --db d",
"plan --objective o --config c --out x --scope s --db d --intent-file i",
"review-plan --accept --plan p --reason r --as me --objective o --intent-file i --config c --db d --out x --scope s --fixture-only",
"review-plan --decline --plan p",
"review-plan --adjust --reason r",
"run --plan p --accept-plan --reset --config c --worktree-base w --db d --fixture-only --detach",
"status --graph g --config c --db d",
"resume --graph g --retry --config c --db d --worktree-base w --fixture-only",
"resume --restart",
"cancel --session-id s --graph g --task 1 --config c --db d --worktree-base w",
"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",
"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",
"symbol-lookup --query q --k 3 --scope s",
"mutate-plan --graph g --mutation-file f --mutation {} --require-review --config c --db d --worktree-base w",
"mcp",
"roles",
];

#[test]
fn documented_flags_still_parse() {
for line in DOCUMENTED {
parse(&argv(line)).unwrap_or_else(|e| panic!("`{line}` rejected: {e}"));
}
}

#[test]
fn global_flags_accepted_on_every_subcommand() {
for line in DOCUMENTED {
let with_globals =
format!("{line} --json --origin-harness h --origin-session s --help -h");
parse(&argv(&with_globals)).unwrap_or_else(|e| panic!("`{with_globals}`: {e}"));
}
}

#[test]
fn unknown_flag_is_rejected_on_every_subcommand() {
for line in DOCUMENTED {
let verb = line.split_whitespace().next().unwrap();
let err = parse(&argv(&format!("{line} --bogus"))).expect_err(line);
assert_eq!(err, format!("meshloop:{verb} does not accept --bogus"));
}
}

#[test]
fn status_rejects_bogus_flag() {
let err = parse(&argv("status --bogus")).expect_err("must reject");
assert!(err.contains("--bogus") && err.contains("status"), "{err}");
let err = parse(&argv("status -z")).expect_err("must reject short flag");
assert!(err.contains("-z"), "{err}");
// A flag valid for another verb is still unknown here.
assert!(parse(&argv("status --attempts")).is_err());
}

#[test]
fn value_starting_with_dash_is_not_a_flag() {
let inv = parse(&argv("review-plan --adjust --reason -x")).expect("parse");
match inv.command {
Command::ReviewPlan { reason, .. } => assert_eq!(reason.as_deref(), Some("-x")),
_ => panic!("expected review-plan"),
}
assert!(parse(&argv("status --graph --not-a-flag")).is_ok());
// The skipped value does not shield the token after it.
assert!(parse(&argv("status --graph g --bogus")).is_err());
}

#[test]
fn prefixed_verb_form_is_validated_too() {
assert!(parse(&argv("meshloop:status --bogus")).is_err());
}
}
Loading
Loading