feat(missions): a human can finally reach the plan/roster review gate

Phase 4 of the plan, plus the PLAN_COMPLETE decision and the gitea_forge
cleanup from Phase 5.

THE REVIEW UI

mission_plan and mission_roster have been complete and reachable by curl
since they shipped, with zero frontend. That matters more than a missing
screen usually would: the decide step is not a convenience, it IS the
safety mechanism. Approving a plan replaces the mission's phases; approving
a roster flips it to the composed engine. A gate nobody can reach is a gate
that is always open or always shut.

MissionProposalDrawer, modelled on LevelUpDrawer which already does
load → review → decide. Reached from a mission's SETUP tab. Verified end to
end against the live backend, not just compiled: a model proposed a roster,
approval flipped the mission to `composed`, and approval on a non-draft
mission was refused.

The plan view shows each phase's done_when, and says plainly when one is
absent — a phase without a completion condition is never judged and reports
completed whatever it did, so its absence is the thing worth seeing.

AND THE DEFECT BUILDING IT FOUND

Every refusal path computed a precise reason — "the mission is running, not
a draft", "no node can boot that backend any more" — logged it to stderr,
and returned a bare {"error":"bad request"}. The person who needed the
sentence was the one clicking Approve; they got two words, and the reason
went to a server log they cannot read.

ApiError::Refused(String) carries it now. Same argument ApiError::Unavailable
was added for ("a 500 with 'internal error' sent them looking for a bug that
was not there"), one status code down. Live: the 400 now reads "this mission
is completed — a roster can only be approved while it is a draft, because
approving one rewrites how the mission will run".

PLAN_COMPLETE, decided

The Skill-Use measurement found that int-xx-marker-protocol documents
PLAN_COMPLETE and task_card_parser never implemented it, so an agent
following the skill exactly was silently ignored. Implemented rather than
removed from the skill: the planner needs a way to say it is done
specifying, and agents already emit it.

Marker ids are now strictly INT-<digits>. `starts_with("INT-")` accepted the
range form `INT-01..02` — observed live — which parsed into an id matching
no real item, so a task card appeared for something that did not exist while
the two items it covered stayed open. Rejecting is right: an ignored marker
is visible, a plausible row is not.

GITEA_FORGE, REMOVED

Named in nine places, defined in none. Harmless while provision_claw ignored
the bundle list; once the list was honoured, an undefined name became a
capability an agent is told it has and does not. Removed from seven team
templates, a workflow recipe, the auto-provision path, and a dropdown a user
could pick it from.

A new test asserts every bundle a template names is defined in the runtime
config — and it immediately found `web_fetch` in two templates I had missed
removing by hand. Same shape as the skill-binding test, one layer up.

Agents reach the forge through git over HTTPS with the ambient GITEA_TOKEN,
which is why nothing ever broke.

Full workspace suite green (106 binaries); frontend builds clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 11:56:54 -07:00
co-authored by Claude Opus 5
parent 91a6b4e304
commit 5c2c63f8e8
22 changed files with 820 additions and 114 deletions
+40 -1
View File
@@ -19,6 +19,17 @@ pub enum ApiError {
Conflict, Conflict,
#[error("{0}")] #[error("{0}")]
Quota(String), Quota(String),
/// A 400 whose REASON the caller needs.
///
/// Same argument as `Unavailable` below, one status code down. The
/// proposal decide handlers each computed a precise refusal — "the mission
/// is running, not a draft", "no node can boot that backend any more" —
/// logged it to stderr, and returned a bare `BadRequest`. The person who
/// needed the sentence was the one clicking Approve, and they got
/// "bad request". `mission_plan::Refusal` exists and is written as
/// human-readable copy; this is how it reaches them.
#[error("{0}")]
Refused(String),
/// A dependency is temporarily refusing work and will accept it later — /// A dependency is temporarily refusing work and will accept it later —
/// today, the Claude Code subscription's rate limit. Distinct from /// today, the Claude Code subscription's rate limit. Distinct from
/// `Internal` because the operator's next action is different: wait and /// `Internal` because the operator's next action is different: wait and
@@ -59,7 +70,7 @@ impl From<cm_auth::AuthError> for ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status = match self { let status = match self {
ApiError::BadRequest => StatusCode::BAD_REQUEST, ApiError::BadRequest | ApiError::Refused(_) => StatusCode::BAD_REQUEST,
ApiError::Unauthorized => StatusCode::UNAUTHORIZED, ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::Forbidden => StatusCode::FORBIDDEN, ApiError::Forbidden => StatusCode::FORBIDDEN,
ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::NotFound => StatusCode::NOT_FOUND,
@@ -71,3 +82,31 @@ impl IntoResponse for ApiError {
(status, Json(json!({ "error": self.to_string() }))).into_response() (status, Json(json!({ "error": self.to_string() }))).into_response()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
/// A refusal must carry its reason into the response body.
///
/// The proposal decide handlers each computed a precise sentence and then
/// returned a bare `BadRequest`, so the person clicking Approve saw
/// "bad request" while the reason went to a server log they cannot read.
#[tokio::test]
async fn a_refusal_reaches_the_caller_and_a_bare_bad_request_does_not_pretend_to() {
let refused = ApiError::Refused("this mission is running, not a draft".into());
let response = refused.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("running, not a draft"),
"the reason must be in the body, not only in the server log: {text}"
);
// The bare variant stays as it was — same status, no invented detail.
let bare = ApiError::BadRequest.into_response();
assert_eq!(bare.status(), StatusCode::BAD_REQUEST);
}
}
+12 -3
View File
@@ -314,8 +314,11 @@ pub async fn decide(
.map_err(|_| ApiError::Internal)? .map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
if mission.status != "draft" { if mission.status != "draft" {
eprintln!("mission {id}: plan approval refused — mission is {}", mission.status); return Err(ApiError::Refused(format!(
return Err(ApiError::BadRequest); "this mission is {} — a {} can only be approved while it is a draft, \
because approving one rewrites how the mission will run",
mission.status, "plan"
)));
} }
let plan: Plan = serde_json::from_value(proposal.plan.clone()).map_err(|e| { let plan: Plan = serde_json::from_value(proposal.plan.clone()).map_err(|e| {
@@ -327,6 +330,7 @@ pub async fn decide(
// before a deploy could name a kind this build no longer dispatches. // before a deploy could name a kind this build no longer dispatches.
if let Err(why) = plan.validate() { if let Err(why) = plan.validate() {
eprintln!("mission {id}: plan {pid} is no longer runnable: {why}"); eprintln!("mission {id}: plan {pid} is no longer runnable: {why}");
let reason = why.to_string();
let _ = cm_db::repo::mission_plan_proposals::decide( let _ = cm_db::repo::mission_plan_proposals::decide(
&state.pool, &state.pool,
pid, pid,
@@ -336,7 +340,12 @@ pub async fn decide(
Some(user.user_id.as_uuid().to_owned()), Some(user.user_id.as_uuid().to_owned()),
) )
.await; .await;
return Err(ApiError::BadRequest); // `Refusal` is already written as human-readable copy — it names the
// constraint and why it exists. It was going to stderr only.
return Err(ApiError::Refused(format!(
"this plan is no longer runnable on the current build, so it was \
rejected: {reason}"
)));
} }
let phases = plan.phases(); let phases = plan.phases();
+13 -3
View File
@@ -253,8 +253,11 @@ pub async fn decide(
.map_err(|_| ApiError::Internal)? .map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
if mission.status != "draft" { if mission.status != "draft" {
eprintln!("mission {id}: roster approval refused — mission is {}", mission.status); return Err(ApiError::Refused(format!(
return Err(ApiError::BadRequest); "this mission is {} — a {} can only be approved while it is a draft, \
because approving one rewrites how the mission will run",
mission.status, "roster"
)));
} }
let roster: Roster = serde_json::from_value(proposal.roster.clone()).map_err(|e| { let roster: Roster = serde_json::from_value(proposal.roster.clone()).map_err(|e| {
@@ -269,6 +272,7 @@ pub async fn decide(
.map_err(|_| ApiError::Internal)?; .map_err(|_| ApiError::Internal)?;
if let Err(why) = roster.validate(&available) { if let Err(why) = roster.validate(&available) {
eprintln!("mission {id}: roster {pid} is no longer applicable: {why}"); eprintln!("mission {id}: roster {pid} is no longer applicable: {why}");
let reason = why.to_string();
let _ = cm_db::repo::mission_team_proposals::decide( let _ = cm_db::repo::mission_team_proposals::decide(
&state.pool, &state.pool,
pid, pid,
@@ -278,7 +282,13 @@ pub async fn decide(
Some(user.user_id.as_uuid().to_owned()), Some(user.user_id.as_uuid().to_owned()),
) )
.await; .await;
return Err(ApiError::BadRequest); // The proposal has just been auto-rejected, so the caller is about to
// re-read a list where it says "rejected" with no visible cause. The
// reason is the whole content of this response.
return Err(ApiError::Refused(format!(
"this roster no longer applies to the fleet as it is now, so it was \
rejected: {reason}"
)));
} }
let graph = roster.graph().map_err(|e| { let graph = roster.graph().map_err(|e| {
+8 -6
View File
@@ -589,7 +589,7 @@ pub struct AutoProvisionRequest {
#[serde(default)] #[serde(default)]
pub risk_profile: Option<String>, pub risk_profile: Option<String>,
/// MCP bundle aliases — same fall-back rule applies (always /// MCP bundle aliases — same fall-back rule applies (always
/// clawmates_door; gitea_forge when a repo is bound; deep-research /// clawmates_door + clawmates_skills; deep-research
/// skill for research profiles). /// skill for research profiles).
#[serde(default)] #[serde(default)]
pub mcp_bundles: Vec<String>, pub mcp_bundles: Vec<String>,
@@ -671,11 +671,13 @@ pub async fn auto_provision(
let mut mcp_bundles = body.mcp_bundles.clone(); let mut mcp_bundles = body.mcp_bundles.clone();
if mcp_bundles.is_empty() { if mcp_bundles.is_empty() {
mcp_bundles.push("clawmates_door".to_string()); mcp_bundles.push("clawmates_door".to_string());
// gitea_forge is scoped to teams that will touch repos; the // No `gitea_forge`: it was named in nine places and defined in none,
// wizard's downstream repo-binding step is what earns it. // and agents reach the forge through `git` over HTTPS with the ambient
// Always safe to add now — the MCP layer no-ops when the token // GITEA_TOKEN (mission_workspace::with_ambient_auth) — which is why
// isn't present in the container env. // nothing ever broke. It was harmless while provision_claw ignored the
mcp_bundles.push("gitea_forge".to_string()); // bundle list; now that the list is honoured, an undefined name is a
// capability an agent is told it has and does not.
mcp_bundles.push("clawmates_skills".to_string());
} }
// 1) LLM plan pass → roster JSON. // 1) LLM plan pass → roster JSON.
+36 -66
View File
@@ -144,11 +144,10 @@ fn marker_compliance(output: &str) -> Verdict {
/// ///
/// `COMPLETED: INT-05, INT-06` — the parser takes the first and drops the /// `COMPLETED: INT-05, INT-06` — the parser takes the first and drops the
/// rest, so an item is silently never closed. /// rest, so an item is silently never closed.
/// `PLAN_COMPLETE: INT-01..02` — the parser ACCEPTS it and yields the id /// `PLAN_COMPLETE: INT-01..02` — the range form. The parser now REJECTS a
/// `INT-01..02`, which matches no real item. /// malformed id, so this is caught by the
/// A task card appears for something that does /// compliance check above as a marker the
/// not exist, and the two items it was meant /// parser does not accept.
/// to cover stay open.
/// ///
/// The second was found by running this measurement against a live mission. It /// The second was found by running this measurement against a live mission. It
/// is the worse of the two, because a dropped marker leaves a gap and a /// is the worse of the two, because a dropped marker leaves a gap and a
@@ -166,28 +165,10 @@ fn marker_boundary(output: &str) -> Verdict {
the rest are silently dropped: {t:?}" the rest are silently dropped: {t:?}"
)); ));
} }
for m in crate::task_card_parser::parse(t) {
if !is_single_int_id(&m.int_id) {
return Verdict::Fail(format!(
"marker id {:?} is not a single INT-<number> — it parses, so \
a task card is created for an item that does not exist, and \
the items it was meant to cover stay open: {t:?}",
m.int_id
));
}
}
} }
Verdict::NotApplicable Verdict::NotApplicable
} }
/// `INT-` followed by digits and nothing else.
fn is_single_int_id(id: &str) -> bool {
match id.strip_prefix("INT-") {
Some(rest) => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()),
None => false,
}
}
fn looks_like_marker_attempt(line: &str) -> bool { fn looks_like_marker_attempt(line: &str) -> bool {
const KINDS: &[&str] = &[ const KINDS: &[&str] = &[
"TASK:", "WORK:", "HANDOFF:", "TEST_PASS:", "TEST_FAIL:", "REVIEW_APPROVE:", "TASK:", "WORK:", "HANDOFF:", "TEST_PASS:", "TEST_FAIL:", "REVIEW_APPROVE:",
@@ -388,58 +369,47 @@ mod tests {
/// Found on a live mission: `PLAN_COMPLETE: INT-01..02`. /// Found on a live mission: `PLAN_COMPLETE: INT-01..02`.
/// ///
/// TWO defects in one line, and the measurement is what surfaced them. /// Two defects in one line, both since fixed in the parser rather than
/// worked around here:
/// ///
/// 1. `PLAN_COMPLETE` is documented in `int-xx-marker-protocol` as part of /// 1. `PLAN_COMPLETE` was documented in `int-xx-marker-protocol` and never
/// the ladder, and `task_card_parser` has never implemented it. An agent /// implemented, so an agent following the skill exactly was ignored. It
/// that follows the skill exactly emits a marker that is silently /// is implemented now.
/// ignored — the skill is teaching a contract the platform does not /// 2. The range form parsed into the id `INT-01..02`, matching no real
/// honour, which is not the agent's failure. /// item — a task card for something that did not exist. Ids are now
/// 2. The range form yields an id matching no real item on the kinds that /// strictly `INT-<digits>`, so the marker is REJECTED instead, which is
/// ARE parsed. /// visible where a plausible-looking row was not.
#[test] #[test]
fn a_documented_marker_the_parser_never_implemented_is_a_compliance_failure() { fn plan_complete_is_now_a_real_marker() {
let parsed = crate::task_card_parser::parse("PLAN_COMPLETE: INT-01");
assert_eq!(parsed.len(), 1, "the skill documents it; the parser must accept it");
assert_eq!(parsed[0].int_id, "INT-01");
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]); let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
assert!( assert_eq!(
crate::task_card_parser::parse("PLAN_COMPLETE: INT-01").is_empty(), score(&prompt, "PLAN_COMPLETE: INT-01", &builtin)[0].compliance,
"PLAN_COMPLETE is in the skill's ladder and not in the parser — if \ Verdict::Pass
this ever starts parsing, the skill and the code have been \
reconciled and this test should be updated to match"
); );
let scored = score(&prompt, "PLAN_COMPLETE: INT-01", &builtin); }
match &scored[0].compliance {
/// The exact line a live planner emitted.
#[test]
fn a_range_marker_is_rejected_rather_than_creating_a_phantom_item() {
assert!(
crate::task_card_parser::parse("PLAN_COMPLETE: INT-01..02").is_empty(),
"a range must not parse — it produced a task card for an item that \
does not exist while INT-01 and INT-02 stayed open"
);
assert!(crate::task_card_parser::parse("COMPLETED: INT-01..02").is_empty());
// And the agent is told, because the marker it emitted did nothing.
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
match &score(&prompt, "PLAN_COMPLETE: INT-01..02", &builtin)[0].compliance {
Verdict::Fail(why) => assert!(why.contains("does not accept")), Verdict::Fail(why) => assert!(why.contains("does not accept")),
other => panic!("an ignored marker must not read as success; got {other:?}"), other => panic!("an ignored marker must not read as success; got {other:?}"),
} }
} }
/// The range form, on a kind the parser DOES accept.
#[test]
fn a_range_marker_crosses_the_boundary_even_though_it_parses() {
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
let parsed = crate::task_card_parser::parse("COMPLETED: INT-01..02");
assert_eq!(parsed.len(), 1, "the parser accepts it");
assert_eq!(parsed[0].int_id, "INT-01..02", "with an id matching no item");
let scored = score(&prompt, "COMPLETED: INT-01..02", &builtin);
match &scored[0].boundary {
Verdict::Fail(why) => assert!(
why.contains("does not exist"),
"the failure must name the consequence, not just the syntax: {why}"
),
other => panic!("a range marker must be caught; got {other:?}"),
}
}
#[test]
fn a_well_formed_id_is_not_flagged() {
assert!(is_single_int_id("INT-07"));
assert!(!is_single_int_id("INT-01..02"));
assert!(!is_single_int_id("INT-"));
assert!(!is_single_int_id("INT-1a"));
}
#[test] #[test]
fn two_int_ids_on_one_marker_line_cross_the_boundary() { fn two_int_ids_on_one_marker_line_cross_the_boundary() {
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]); let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
+29 -3
View File
@@ -39,6 +39,7 @@ pub struct Marker {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkerKind { pub enum MarkerKind {
Task, Task,
PlanComplete,
Work, Work,
Handoff, Handoff,
TestPass, TestPass,
@@ -53,7 +54,11 @@ impl MarkerKind {
/// motion — the UPSERT layer may still overwrite prior states. /// motion — the UPSERT layer may still overwrite prior states.
pub fn status(&self) -> &'static str { pub fn status(&self) -> &'static str {
match self { match self {
MarkerKind::Task => "created", // The planner finished specifying; no work has started, so the item
// is in the same state a fresh TASK leaves it in. A distinct status
// would need a column value the UI does not render, and inventing
// one to look complete is how a status stops meaning anything.
MarkerKind::Task | MarkerKind::PlanComplete => "created",
MarkerKind::Work => "working", MarkerKind::Work => "working",
MarkerKind::Handoff | MarkerKind::TestPass | MarkerKind::ReviewApprove => "validating", MarkerKind::Handoff | MarkerKind::TestPass | MarkerKind::ReviewApprove => "validating",
MarkerKind::TestFail | MarkerKind::ReviewBlock => "failed", MarkerKind::TestFail | MarkerKind::ReviewBlock => "failed",
@@ -75,12 +80,27 @@ pub fn parse(text: &str) -> Vec<Marker> {
out out
} }
/// `INT-` followed by at least one digit and nothing else.
fn is_int_id(id: &str) -> bool {
match id.strip_prefix("INT-") {
Some(rest) => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()),
None => false,
}
}
fn parse_line(line: &str) -> Option<Marker> { fn parse_line(line: &str) -> Option<Marker> {
// Match `<KIND>: INT-NN` (rest optional). Strict on the colon and // Match `<KIND>: INT-NN` (rest optional). Strict on the colon and
// the INT- prefix — anything laxer starts matching prose. // the INT- prefix — anything laxer starts matching prose.
let (kind_str, rest) = line.split_once(':')?; let (kind_str, rest) = line.split_once(':')?;
let kind = match kind_str.trim() { let kind = match kind_str.trim() {
"TASK" => MarkerKind::Task, "TASK" => MarkerKind::Task,
// Documented in `skills/foundation/int-xx-marker-protocol.md` since the
// skill was written, and never implemented here. Agents that followed
// the skill exactly emitted it and were silently ignored — observed on
// a live mission, found by the Skill-Use measurement. Implemented
// rather than removed from the skill: the planner needs a way to say
// it is done specifying, and agents already emit this one.
"PLAN_COMPLETE" => MarkerKind::PlanComplete,
"WORK" => MarkerKind::Work, "WORK" => MarkerKind::Work,
"HANDOFF" => MarkerKind::Handoff, "HANDOFF" => MarkerKind::Handoff,
"TEST_PASS" => MarkerKind::TestPass, "TEST_PASS" => MarkerKind::TestPass,
@@ -95,10 +115,16 @@ fn parse_line(line: &str) -> Option<Marker> {
Some((a, b)) => (a, Some(b.trim())), Some((a, b)) => (a, Some(b.trim())),
None => (rest, None), None => (rest, None),
}; };
if !id_tok.starts_with("INT-") { let int_id = id_tok.trim_end_matches(&[',', ';', '.'][..]).to_string();
// Strictly `INT-<digits>`. `starts_with("INT-")` alone accepted range forms
// like `INT-01..02`, which parse into an id matching no real item — so a
// task card appeared for something that did not exist while the two items
// it was meant to cover stayed open. Observed live. Rejecting is right:
// the marker is ignored, which is visible, instead of creating a plausible
// row, which is not.
if !is_int_id(&int_id) {
return None; return None;
} }
let int_id = id_tok.trim_end_matches(&[',', ';', '.'][..]).to_string();
// Title: after the id + any of ` — / – / - ` separators // Title: after the id + any of ` — / – / - ` separators
let title = tail.and_then(|t| { let title = tail.and_then(|t| {
let t = t.trim_start_matches(['—', '–', '-', ':'].as_slice()).trim(); let t = t.trim_start_matches(['—', '–', '-', ':'].as_slice()).trim();
+83
View File
@@ -350,3 +350,86 @@ mod tests {
); );
} }
} }
#[cfg(test)]
mod bundle_tests {
use std::collections::HashSet;
fn repo() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("repo root")
}
/// Every `mcp_bundles` name a template asks for must be one the runtime
/// config actually defines.
///
/// This was harmless while `provision_claw` wrote a constant bundle list
/// and ignored the templates. It is not harmless now that the list is
/// honoured: an undefined name is a capability the agent is told it has and
/// does not, which is the same failure as an unresolved skill binding one
/// layer down. `gitea_forge` was named by seven team templates, one
/// workflow recipe, the auto-provision path and a user-selectable dropdown,
/// and defined nowhere.
#[test]
fn every_named_mcp_bundle_is_defined_by_the_runtime_config() {
let cfg = std::fs::read_to_string(
repo().join("deploy/clawmates-runtime/agent.config.example.toml"),
)
.expect("runtime config");
let defined: HashSet<String> = cfg
.lines()
.filter_map(|l| l.trim().strip_prefix("[mcp_bundles."))
.filter_map(|r| r.strip_suffix(']'))
.map(|s| s.to_string())
.collect();
assert!(
defined.contains("clawmates_door"),
"parsed no bundles from the runtime config — the parser, not the \
templates, is what broke"
);
let mut missing: Vec<String> = Vec::new();
for dir in ["templates/teams", "templates/workflows"] {
for entry in std::fs::read_dir(repo().join(dir)).expect("template dir") {
let path = entry.expect("entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let body = std::fs::read_to_string(&path).expect("read template");
for line in body.lines() {
let t = line.trim();
// Skip comments: several deliberately NAME a bundle while
// explaining that it is not delivered.
if t.starts_with('#') || !t.starts_with("mcp_bundles") {
continue;
}
let Some(inner) = t.split_once('[').and_then(|(_, r)| r.rsplit_once(']'))
else {
continue;
};
for name in inner.0.split(',') {
let name = name.trim().trim_matches('"');
if !name.is_empty() && !defined.contains(name) {
missing.push(format!(
"{}: {name}",
path.file_name().unwrap().to_string_lossy()
));
}
}
}
}
}
missing.sort();
missing.dedup();
assert!(
missing.is_empty(),
"{} template(s) name an MCP bundle the runtime does not define, so \
the agent is provisioned with a capability that resolves to \
nothing:\n {}",
missing.len(),
missing.join("\n ")
);
}
}
+21 -12
View File
@@ -178,10 +178,17 @@ work and the condition.
## Deferred, with reasons ## Deferred, with reasons
- **`mission_plan` / `mission_roster` have no frontend.** A fully built - ~~**`mission_plan` / `mission_roster` have no frontend.**~~ **Shipped
propose → review → approve gate whose review step *is* the safety mechanism, 2026-08-19.** `MissionProposalDrawer` reaches both flows from a mission's
and which no user can reach. The largest declared-but-unwired feature in the SETUP tab. Verified end to end against the live backend: a model proposed a
product. A UI is its own piece of work. roster, approval flipped the mission to the `composed` engine, and approval on
a non-draft mission is refused.
Building it surfaced a defect in the backend it consumes: every refusal path
computed a precise reason, logged it to stderr, and returned a bare
`{"error":"bad request"}`. The person who needed the sentence was the one
clicking Approve. `ApiError::Refused(String)` now carries it — the same
argument `ApiError::Unavailable` was added for, one status code down.
- **Skill-Use measurement (Trigger / Compliance / Boundary).** Deferred not for - **Skill-Use measurement (Trigger / Compliance / Boundary).** Deferred not for
cost but because it was **unmeasurable until this pass**: with no delivery cost but because it was **unmeasurable until this pass**: with no delivery
channel, trigger rate was structurally zero. It is now worth running, and it channel, trigger rate was structurally zero. It is now worth running, and it
@@ -200,14 +207,16 @@ work and the condition.
more tests, not a different one); 6 of `cm-brain`'s 9 tests are `#[ignore]`d. more tests, not a different one); 6 of `cm-brain`'s 9 tests are `#[ignore]`d.
- **`ZEROCLAW_GATEWAY_URL` / `_TOKEN`** have no default and fail at *first use*, - **`ZEROCLAW_GATEWAY_URL` / `_TOKEN`** have no default and fail at *first use*,
not boot — a deployment looks healthy until someone clicks run. not boot — a deployment looks healthy until someone clicks run.
- **`gitea_forge` resolves to nothing.** Six templates name it; the runtime - ~~**`gitea_forge` resolves to nothing.**~~ **Resolved 2026-08-19 by removing
config defines no such bundle. Left undefined deliberately — inventing a the name.** It was harmless while `provision_claw` ignored the bundle list;
definition pointing at the wrong URL turns a name that resolves to nothing once the list was honoured, an undefined name became a capability an agent is
into a server that fails at call time, which is harder to notice. Agents told it has and does not. Removed from seven team templates, one workflow
reach the forge through `git` over HTTPS with the ambient `GITEA_TOKEN`, recipe, the auto-provision path and a user-selectable dropdown. `web_fetch`
which is why nothing broke. Worth either defining or removing from the went the same way — and a new test
templates; both are small, and the choice needs someone who knows whether a (`team_template_loader::bundle_tests`) now asserts every bundle a template
forge MCP server is wanted. names is defined in the runtime config, which is what found `web_fetch` in
two templates I had missed by hand. Agents reach the forge through `git` over
HTTPS with the ambient `GITEA_TOKEN`, which is why nothing ever broke.
- **The deployed runtime config is not the example.** `[mcp_bundles.clawmates_skills]` - **The deployed runtime config is not the example.** `[mcp_bundles.clawmates_skills]`
is now in `agent.config.example.toml`, but the live local config carries no is now in `agent.config.example.toml`, but the live local config carries no
bundle definitions at all — a fresh deploy needs the example's blocks. The bundle definitions at all — a fresh deploy needs the example's blocks. The
@@ -45,6 +45,7 @@ import { MissionLiveEvents } from "./MissionLiveEvents";
import { MissionLivePane } from "./MissionLivePane"; import { MissionLivePane } from "./MissionLivePane";
import { MissionOutputReader } from "./MissionOutputReader"; import { MissionOutputReader } from "./MissionOutputReader";
import { MissionTeamTab } from "./MissionTeamTab"; import { MissionTeamTab } from "./MissionTeamTab";
import { MissionProposalDrawer } from "./MissionProposalDrawer";
import { MissionWizard } from "./MissionWizard"; import { MissionWizard } from "./MissionWizard";
import { PhaseGoalStrip } from "./PhaseGoalStrip"; import { PhaseGoalStrip } from "./PhaseGoalStrip";
import { PhaseRunsList } from "./PhaseRunsList"; import { PhaseRunsList } from "./PhaseRunsList";
@@ -127,6 +128,9 @@ export function MissionCanvas({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("run"); const [tab, setTab] = useState<Tab>("run");
// The propose→review→approve gate. Backend has been complete since it
// shipped; this is the first way for a human to reach the review step.
const [proposalsOpen, setProposalsOpen] = useState(false);
const [runSub, setRunSub] = useState<RunSub>("phases"); const [runSub, setRunSub] = useState<RunSub>("phases");
const [outputSub, setOutputSub] = useState<OutputSub>("documents"); const [outputSub, setOutputSub] = useState<OutputSub>("documents");
const [setupSub, setSetupSub] = useState<SetupSub>("overview"); const [setupSub, setSetupSub] = useState<SetupSub>("overview");
@@ -977,11 +981,27 @@ export function MissionCanvas({
)} )}
{tab === "setup" && setupSub === "team" && ( {tab === "setup" && setupSub === "team" && (
<MissionTeamTab <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
missionId={mission.id} <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
teamId={mission.team_id} <button
onOpenClaw={onOpenClaw} onClick={() => setProposalsOpen(true)}
/> style={secondaryBtn}
title="Review a model-proposed plan or roster before it is applied"
>
Review proposals
</button>
<span style={{ fontSize: 11, color: "#8b8b96" }}>
{mission.status === "draft"
? "a proposed plan or roster can be approved while this mission is a draft"
: `approval applies to drafts only — this mission is ${mission.status}`}
</span>
</div>
<MissionTeamTab
missionId={mission.id}
teamId={mission.team_id}
onOpenClaw={onOpenClaw}
/>
</div>
)} )}
{tab === "run" && runSub === "live" && ( {tab === "run" && runSub === "live" && (
@@ -1132,6 +1152,15 @@ export function MissionCanvas({
)} )}
</MissionTabScroller> </MissionTabScroller>
)} )}
{proposalsOpen && (
<MissionProposalDrawer
missionId={mission.id}
missionStatus={mission.status}
onClose={() => setProposalsOpen(false)}
onChanged={onChanged}
/>
)}
</div> </div>
); );
} }
@@ -0,0 +1,424 @@
"use client";
// MissionProposalDrawer — the human review step for a mission's proposed
// PLAN (what phases will run) and ROSTER (who will run them).
//
// Both backends have been complete and reachable by curl since they
// shipped; neither had any user interface. That matters more than a
// missing screen usually would, because the decide step is not a
// convenience — it IS the safety mechanism. Approving a plan replaces the
// mission's phases; approving a roster flips it to the composed engine.
// A gate nobody can reach is a gate that is always open or always shut.
//
// Modelled on LevelUpDrawer, which already does load → review → decide.
import { useCallback, useEffect, useState } from "react";
import {
decidePlanProposal,
decideRosterProposal,
listPlanProposals,
listRosterProposals,
suggestPlan,
suggestRoster,
type MissionPlanProposal,
type MissionTeamProposal,
} from "@/lib/api/missions";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export type ProposalTab = "plan" | "roster";
const STATUS_COLOR: Record<string, string> = {
proposed: "#f0c264",
approved: "#7fd0a0",
rejected: "#ff8a7a",
};
export function MissionProposalDrawer({
missionId,
missionStatus,
onClose,
onChanged,
}: {
missionId: string;
/** Approval only applies to a draft; the server returns 400 otherwise. */
missionStatus: string;
onClose: () => void;
onChanged?: () => void;
}) {
const [tab, setTab] = useState<ProposalTab>("plan");
const [plans, setPlans] = useState<MissionPlanProposal[]>([]);
const [rosters, setRosters] = useState<MissionTeamProposal[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState("");
const isDraft = missionStatus === "draft";
const load = useCallback(async () => {
setLoading(true);
try {
const [p, r] = await Promise.all([
listPlanProposals(missionId),
listRosterProposals(missionId),
]);
setPlans(p);
setRosters(r);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
} finally {
setLoading(false);
}
}, [missionId]);
useEffect(() => {
void load();
}, [load]);
// `suggest` calls a model AND reads the repo tree from the forge, so it
// takes many seconds. Without a real pending state this reads as a hang.
const doSuggest = useCallback(async () => {
setBusy("suggest");
setError(null);
try {
if (tab === "plan") await suggestPlan(missionId);
else await suggestRoster(missionId);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "suggest failed");
} finally {
setBusy(null);
}
}, [tab, missionId, load]);
const doDecide = useCallback(
async (proposalId: string, status: "approved" | "rejected") => {
setBusy(proposalId);
setError(null);
try {
if (tab === "plan") {
await decidePlanProposal(missionId, proposalId, status, note || undefined);
} else {
await decideRosterProposal(missionId, proposalId, status, note || undefined);
}
setNote("");
await load();
onChanged?.();
} catch (e) {
// The server's refusal text is written for a human to read — it
// names which constraint failed and why. Replacing it with a
// generic message throws away the only useful part.
setError(e instanceof Error ? e.message : "decide failed");
} finally {
setBusy(null);
}
},
[tab, missionId, note, load, onChanged],
);
const proposals: (MissionPlanProposal | MissionTeamProposal)[] =
tab === "plan" ? plans : rosters;
return (
<div
role="dialog"
aria-modal
aria-label="Mission proposals"
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.55)",
display: "flex",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(620px, 100vw)",
height: "100vh",
background: "#141419",
borderLeft: "1px solid rgba(255,255,255,.08)",
display: "flex",
flexDirection: "column",
}}
>
<header
style={{
padding: "14px 18px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<strong style={{ color: "#e8e8ee", fontSize: 14 }}>Review proposals</strong>
<div style={{ display: "flex", gap: 6, marginLeft: 8 }}>
{(["plan", "roster"] as ProposalTab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
padding: "4px 10px",
fontSize: 12,
borderRadius: 6,
cursor: "pointer",
background: tab === t ? "rgba(255,255,255,.10)" : "transparent",
color: tab === t ? "#e8e8ee" : "#8b8b96",
border: "1px solid rgba(255,255,255,.10)",
}}
>
{t === "plan" ? "Plan" : "Roster"}
</button>
))}
</div>
<button
onClick={onClose}
aria-label="Close"
style={{
marginLeft: "auto",
background: "transparent",
border: "none",
color: "#8b8b96",
cursor: "pointer",
fontSize: 18,
}}
>
×
</button>
</header>
{!isDraft && (
<p
style={{
margin: 0,
padding: "10px 18px",
fontSize: 12,
color: "#f0c264",
background: "rgba(240,194,100,.08)",
borderBottom: "1px solid rgba(255,255,255,.06)",
}}
>
This mission is <strong>{missionStatus}</strong>. Proposals can only be
approved while it is a draft — approving now would be refused.
</p>
)}
<div style={{ flex: 1, overflowY: "auto", padding: "14px 18px" }}>
{error && (
<p
style={{
margin: "0 0 12px",
padding: "8px 10px",
borderRadius: 6,
fontSize: 12,
fontFamily: mono,
whiteSpace: "pre-wrap",
color: "#ff8a7a",
background: "rgba(255,138,122,.08)",
}}
>
{error}
</p>
)}
{loading ? (
<p style={{ color: "#8b8b96", fontSize: 13 }}>Loading…</p>
) : proposals.length === 0 ? (
<p style={{ color: "#8b8b96", fontSize: 13 }}>
No {tab} proposals yet. Ask a model for one below — it reads the
repository first, so it takes a few seconds.
</p>
) : (
proposals.map((p) => (
<article
key={p.id}
style={{
marginBottom: 14,
border: "1px solid rgba(255,255,255,.08)",
borderRadius: 8,
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
background: "rgba(255,255,255,.03)",
}}
>
<span
style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 999,
color: "#141419",
background: STATUS_COLOR[p.status] ?? "#8b8b96",
}}
>
{p.status}
</span>
<span style={{ fontSize: 11, color: "#8b8b96", fontFamily: mono }}>
{p.author_model ?? "unknown model"}
</span>
</div>
<div style={{ padding: "10px 12px" }}>
{"plan" in p ? (
<PlanBody plan={p.plan} />
) : (
<RosterBody roster={p.roster} />
)}
{p.note && (
<p style={{ marginTop: 8, fontSize: 12, color: "#8b8b96" }}>
Note: {p.note}
</p>
)}
</div>
{p.status === "proposed" && (
<div
style={{
display: "flex",
gap: 8,
padding: "10px 12px",
borderTop: "1px solid rgba(255,255,255,.06)",
}}
>
<button
disabled={busy !== null || !isDraft}
onClick={() => doDecide(p.id, "approved")}
style={btn("#7fd0a0", busy !== null || !isDraft)}
>
{busy === p.id ? "Working…" : "Approve"}
</button>
<button
disabled={busy !== null}
onClick={() => doDecide(p.id, "rejected")}
style={btn("#ff8a7a", busy !== null)}
>
Reject
</button>
</div>
)}
</article>
))
)}
</div>
<footer
style={{
padding: "12px 18px",
borderTop: "1px solid rgba(255,255,255,.06)",
display: "flex",
gap: 8,
}}
>
<input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Note for the decision (optional)"
aria-label="Decision note"
style={{
flex: 1,
padding: "7px 10px",
fontSize: 12,
borderRadius: 6,
background: "rgba(255,255,255,.04)",
border: "1px solid rgba(255,255,255,.10)",
color: "#e8e8ee",
}}
/>
<button
disabled={busy !== null}
onClick={doSuggest}
style={btn("#7cd6e0", busy !== null)}
>
{busy === "suggest" ? "Asking a model…" : `Propose ${tab}`}
</button>
</footer>
</div>
</div>
);
}
function btn(color: string, disabled: boolean): React.CSSProperties {
return {
padding: "6px 14px",
fontSize: 12,
borderRadius: 6,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
color: "#141419",
background: color,
border: "none",
};
}
function PlanBody({ plan }: { plan: { phases: PlanPhase[] } }) {
if (!plan?.phases?.length) {
return <p style={{ fontSize: 12, color: "#8b8b96" }}>No phases proposed.</p>;
}
return (
<ol style={{ margin: 0, paddingLeft: 18, display: "grid", gap: 10 }}>
{plan.phases.map((ph, i) => (
<li key={i} style={{ fontSize: 12, color: "#c9c9d4" }}>
<strong style={{ color: "#e8e8ee", fontFamily: mono }}>{ph.kind}</strong>
<p style={{ margin: "4px 0", whiteSpace: "pre-wrap" }}>{ph.task}</p>
{/* done_when is what makes a phase judgeable at all — a phase
without one reports completed whatever it did, so its absence
is worth showing rather than hiding. */}
{ph.done_when ? (
<p style={{ margin: 0, color: "#7fd0a0" }}>done when: {ph.done_when}</p>
) : (
<p style={{ margin: 0, color: "#f0c264" }}>
no completion condition — this phase cannot fail
</p>
)}
</li>
))}
</ol>
);
}
function RosterBody({ roster }: { roster: { topology_kind: string; members: RosterM[] } }) {
if (!roster?.members?.length) {
return <p style={{ fontSize: 12, color: "#8b8b96" }}>No members proposed.</p>;
}
return (
<div>
<p style={{ margin: "0 0 8px", fontSize: 12, color: "#8b8b96" }}>
topology: <span style={{ fontFamily: mono, color: "#e8e8ee" }}>{roster.topology_kind}</span>
</p>
<ul style={{ margin: 0, paddingLeft: 18, display: "grid", gap: 8 }}>
{roster.members.map((m, i) => (
<li key={i} style={{ fontSize: 12, color: "#c9c9d4" }}>
<strong style={{ color: "#e8e8ee", fontFamily: mono }}>{m.role}</strong>
{m.backend && (
<span style={{ color: "#8b8b96" }}> · {m.backend}</span>
)}
{m.rationale && <p style={{ margin: "3px 0 0" }}>{m.rationale}</p>}
</li>
))}
</ul>
</div>
);
}
interface PlanPhase {
kind: string;
task: string;
done_when?: string | null;
}
interface RosterM {
role: string;
backend?: string | null;
rationale?: string | null;
}
@@ -44,7 +44,9 @@ export function TeamRunsModal({ teamId, teamName, onClose }: { teamId: string; t
{ v: "research_web_readonly", label: "research_web_readonly — + web fetch" }, { v: "research_web_readonly", label: "research_web_readonly — + web fetch" },
{ v: "coding_readwrite", label: "coding_readwrite — writes to /workspace/repo" }, { v: "coding_readwrite", label: "coding_readwrite — writes to /workspace/repo" },
]; ];
const BUNDLE_OPTIONS = ["clawmates_door", "gitea_forge"]; // Only bundles the runtime actually defines. `gitea_forge` was selectable
// here and defined nowhere, so picking it granted nothing.
const BUNDLE_OPTIONS = ["clawmates_door", "clawmates_skills"];
useEffect(() => { useEffect(() => {
(async () => { (async () => {
+99
View File
@@ -569,3 +569,102 @@ export const recipeToPreset = (r: WorkflowRecipe): TemplatePreset => ({
.map((p) => ({ kind: p.kind, order_idx: p.order_idx })), .map((p) => ({ kind: p.kind, order_idx: p.order_idx })),
defaultTeamTemplate: r.default_team_template ?? null, defaultTeamTemplate: r.default_team_template ?? null,
}); });
// ── Plan + roster proposals ─────────────────────────────────────────
//
// A model proposes; a human decides. The decide step IS the safety
// mechanism for both flows — a plan approval replaces the mission's phases
// and a roster approval flips it to the composed engine — and until now it
// had no user interface at all, on a fully working backend.
//
// Both only apply while the mission is a DRAFT. Approving a running mission
// returns 400, and the server's refusal text is written for a human, so it
// is surfaced verbatim rather than replaced with "something went wrong".
export type ProposalDecision = "approved" | "rejected";
export type MissionProposalStatus = "proposed" | ProposalDecision;
export interface PlannedPhase {
kind: string;
task: string;
done_when?: string | null;
done_when_check?: string | null;
allow_empty?: boolean | null;
}
export interface MissionPlan {
phases: PlannedPhase[];
}
export interface RosterMember {
role: string;
/** Which per-CLI rootfs this member boots. Validated against the fleet. */
backend?: string | null;
rationale?: string | null;
}
export interface MissionRoster {
topology_kind: string;
members: RosterMember[];
}
export interface MissionPlanProposal {
id: string;
mission_id: string;
plan: MissionPlan;
author_model: string | null;
status: MissionProposalStatus;
note?: string | null;
created_at?: string;
decided_at?: string | null;
}
export interface MissionTeamProposal {
id: string;
mission_id: string;
roster: MissionRoster;
author_model: string | null;
status: MissionProposalStatus;
note?: string | null;
created_at?: string;
decided_at?: string | null;
}
/** Ask a model for a plan. Slow: reads the repo tree from the forge. */
export const suggestPlan = (missionId: string) =>
api<MissionPlanProposal>(`/api/missions/${missionId}/plan-proposals`, {
method: "POST",
});
export const listPlanProposals = (missionId: string) =>
api<MissionPlanProposal[]>(`/api/missions/${missionId}/plan-proposals`);
export const decidePlanProposal = (
missionId: string,
proposalId: string,
status: ProposalDecision,
note?: string,
) =>
api<{ status: string; phases?: { kind: string; order_idx: number }[] }>(
`/api/missions/${missionId}/plan-proposals/${proposalId}/decide`,
{ method: "POST", body: JSON.stringify({ status, note }) },
);
export const suggestRoster = (missionId: string) =>
api<MissionTeamProposal>(`/api/missions/${missionId}/team-proposals`, {
method: "POST",
});
export const listRosterProposals = (missionId: string) =>
api<MissionTeamProposal[]>(`/api/missions/${missionId}/team-proposals`);
export const decideRosterProposal = (
missionId: string,
proposalId: string,
status: ProposalDecision,
note?: string,
) =>
api<{ status: string; team_engine?: string; nodes?: number }>(
`/api/missions/${missionId}/team-proposals/${proposalId}/decide`,
{ method: "POST", body: JSON.stringify({ status, note }) },
);
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Rust backend teams — Postgres, DuckDB, graph databases, AP
stack = ["rust", "postgres", "duckdb", "graph", "api", "middleware"] stack = ["rust", "postgres", "duckdb", "graph", "api", "middleware"]
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "coding_readwrite" risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge"] mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+1 -1
View File
@@ -5,7 +5,7 @@ stack = ["research", "code-forensics", "obsidian", "documentation"]
category = "research" category = "research"
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "research_readonly" risk_profile = "research_readonly"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge"] mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Latest React + TailwindCSS + ShadCN — component authoring,
stack = ["typescript", "react", "tailwindcss", "shadcn", "next"] stack = ["typescript", "react", "tailwindcss", "shadcn", "next"]
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "coding_readwrite" risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge"] mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+1 -1
View File
@@ -4,7 +4,7 @@ description = "CUDA, Metal, ROCm from Rust — low-level GPU application de
stack = ["rust", "cuda", "metal", "rocm", "gpu"] stack = ["rust", "cuda", "metal", "rocm", "gpu"]
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "coding_readwrite" risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge"] mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+3 -1
View File
@@ -5,7 +5,9 @@ stack = ["research", "novelty", "publication", "obsidian", "citation-
category = "research" category = "research"
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "research_readonly" risk_profile = "research_readonly"
mcp_bundles = ["clawmates_door", "clawmates_skills", "web_fetch"] # `web_fetch` removed: no such bundle is defined, and provision_claw now
# honours this list — agents reach a page with `curl` through Bash.
mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Expo + React Native for iOS/Android — camera, comms, netwo
stack = ["typescript", "react-native", "expo", "ios", "android"] stack = ["typescript", "react-native", "expo", "ios", "android"]
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "coding_readwrite" risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge"] mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+3 -1
View File
@@ -5,7 +5,9 @@ stack = ["research", "papers", "arxiv", "obsidian", "library"]
category = "research" category = "research"
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "research_web_readonly" risk_profile = "research_web_readonly"
mcp_bundles = ["clawmates_door", "clawmates_skills", "web_fetch"] # `web_fetch` removed: no such bundle is defined, and provision_claw now
# honours this list — agents reach a page with `curl` through Bash.
mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Full software lifecycle for Rust projects — planning, impl
stack = ["rust", "systems", "distributed", "backend"] stack = ["rust", "systems", "distributed", "backend"]
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "coding_readwrite" risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge"] mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Immersive graphics + game dev in the browser — 3D, isometr
stack = ["typescript", "threejs", "webgl", "webgpu", "gsap"] stack = ["typescript", "threejs", "webgl", "webgpu", "gsap"]
default_topology = "pipeline" default_topology = "pipeline"
risk_profile = "coding_readwrite" risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge"] mcp_bundles = ["clawmates_door", "clawmates_skills"]
version = 1 version = 1
[[roles]] [[roles]]
+5 -5
View File
@@ -101,11 +101,11 @@ max_iterations = 3
commit_policy = "on_reviewer_approval" commit_policy = "on_reviewer_approval"
# NOTE: `mcp_bundles` is INERT AT PHASE LEVEL — bundles come from the TEAM # NOTE: `mcp_bundles` is INERT AT PHASE LEVEL — bundles come from the TEAM
# template (`mission_orchestrator` binds `template.mcp_bundles`). This phase # template (`mission_orchestrator` binds `template.mcp_bundles`). This phase
# gets neither `gitea_forge` nor `security_scan` from this line, and there is no # gets nothing from this line. `gitea_forge` and `security_scan` were removed
# `security_scan` bundle to get. Left visible rather than deleted because the # from it entirely: neither is defined anywhere, and now that provision_claw
# gap between what a recipe asks for and what a phase receives is the thing # HONOURS the team template's bundle list, naming a bundle that does not exist
# worth being able to see. # stopped being harmlessly inert.
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge", "security_scan"] mcp_bundles = ["clawmates_door", "clawmates_skills"]
task = """ task = """
Apply the patch strategy and prove it worked. Apply the patch strategy and prove it worked.