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:
co-authored by
Claude Opus 5
parent
91a6b4e304
commit
5c2c63f8e8
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.")]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 }) },
|
||||||
|
);
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user