fix(missions): skills can now reach a mission agent at all

Repairing the 55 broken skill bindings made the catalogue correct. This
makes it reachable, which it was not — for any skill, on any mission, since
the catalogue was built.

The skills had exactly ONE delivery channel: the `clawmates_skills` MCP
server. A mission claw could not reach it for three independent reasons:

  1. `provision_claw` wrote the constant `["clawmates_door"]` and ignored
     the template's mcp_bundles — which mission_orchestrator had already
     resolved and stored on the team row.
  2. The runtime config defines no `clawmates_skills` bundle. The live
     local config defines no bundles at all, not even the door.
  3. Mission claws run on `claude_cli`, which the runtime's own config
     comments document as text-only: it cannot surface a tool call, so no
     MCP server is reachable from a mission turn regardless of bundles.

And a mission turn's whole system context is two sentences synthesised from
the role slot in topology_exec::build_prompt. The template's role prose is
not used either — mission_orchestrator documents this, and it means the
role prompts describing which procedures to follow were never read.

Two doc comments in cm-runtime describe the mission path as already having
the summary-and-fetch contract. It never did. The belief was written down
twice and checked zero times, which is why nobody looked — and it is why
the Skill-Use measurement this review planned could only ever have returned
a trigger rate of zero. That would have read as a finding about the agents.

  - provision_claw takes the bundles, with clawmates_door always added: a
    template that forgets to list it must not get an ungated agent
  - all 11 templates now request clawmates_skills; web_fetch removed, since
    a list that is honoured must not name a bundle that does not exist
  - the re-provision sweep re-asserts the team's own stored bundles rather
    than a constant, which would have silently stripped a capability
    mid-mission
  - pinned skill BODIES are injected into the mission prompt, bounded and
    with truncation stated. Bodies, not an index: there is no `skills.read`
    tool on this path, so an index would advertise a capability that does
    not exist — the exact failure this whole change is about

Three tests: the body reaches the prompt, an agent with no skills adds no
heading (an empty "Your skills" section announces skills the agent does not
have), and the composition is exercised separately from the lookup, because
`pinned_skills_text` working and `run_turn` calling it are different claims
and the second is the one that was false.

Also adds the three review documents: CAPABILITY-REVIEW (inventory, what
was repaired, what is deferred and why), PROVENANCE-ASSESSMENT (assess
only, per decision — what each store answers and the two candidate paths),
and RESEARCH-SWEEP (the fortnight's papers and what we did about each,
including the ones we deliberately did nothing about).

Full workspace suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 08:24:02 -07:00
co-authored by Claude Opus 5
parent 18dc0b964b
commit e4942ce985
16 changed files with 646 additions and 21 deletions
@@ -0,0 +1,213 @@
//! Do a mission agent's pinned skills actually reach its prompt?
//!
//! Before this test the honest answer was no, for every skill and every role.
//! The catalogue's only delivery channel was the `clawmates_skills` MCP server,
//! and a mission claw could not reach it: `provision_claw` wrote a constant
//! bundle list, the runtime config defines no such bundle, and mission claws
//! run on `claude_cli`, which is text-only and cannot surface a tool call.
//!
//! So the skills were authored, bound, listed in the boot log as bound — and
//! structurally unreadable. That is why this is a test and not a comment: the
//! failure produced no error anywhere, and every layer reported success.
use cm_api::topology_exec::{MissionTap, ZeroClawDriveExecutor};
use cm_domain::{Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId};
/// `agents.managed_by` is a real FK, so the owner has to exist.
async fn seed_user(pool: &sqlx::PgPool, ws: WorkspaceId) -> UserId {
let user = User {
id: UserId::new(),
workspace_id: ws,
email: format!("owner-{}@example.com", Uuid::now_v7().simple()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(pool, &user).await.unwrap();
user.id
}
use std::collections::HashMap;
use uuid::Uuid;
/// A claw with one template-bound, pinned skill. Returns its runtime alias.
async fn seed_claw_with_pinned_skill(pool: &sqlx::PgPool, body: &str) -> (String, WorkspaceId) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Skill Delivery Test".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
let user = seed_user(pool, ws.id).await;
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "researcher".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: user,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &cm_domain::AccessPolicy::default())
.await
.unwrap();
// A template with one role, and a skill pinned to it.
let template_id = Uuid::now_v7();
sqlx::query(
"INSERT INTO team_templates (id, key, name, description, category, stack,
default_topology, risk_profile, mcp_bundles, version)
VALUES ($1, $2, 'Delivery Test', 'test', 'research', '{}',
'pipeline', 'research_readonly', '{}', 1)",
)
.bind(template_id)
.bind(format!("delivery_test_{}", template_id.simple()))
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO template_roles (template_id, slot, order_idx, system_prompt)
VALUES ($1, 'researcher', 0, 'you research')",
)
.bind(template_id)
.execute(pool)
.await
.unwrap();
let skill_id = Uuid::now_v7();
sqlx::query(
"INSERT INTO skills
(id, workspace_id, name, title, author, description, when_to_use,
tags, source_kind, current_version, body)
VALUES ($1, NULL, $2, $2, 'system',
'a procedure the agent must follow', 'always', '{}',
'builtin', 1, $3)",
)
.bind(skill_id)
.bind(format!("delivery-test-skill-{}", skill_id.simple()))
.bind(body)
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO template_role_skills (template_id, slot, skill_id, pin_in_context, order_idx)
VALUES ($1, 'researcher', $2, true, 0)",
)
.bind(template_id)
.bind(skill_id)
.execute(pool)
.await
.unwrap();
cm_db::repo::agent_template_link::upsert(
pool,
agent.id.as_uuid(),
template_id,
1,
"researcher",
)
.await
.unwrap();
(
cm_api::runtime_provision::claw_alias(agent.id.as_uuid()),
ws.id,
)
}
fn executor(pool: &sqlx::PgPool, workspace_id: WorkspaceId) -> ZeroClawDriveExecutor {
ZeroClawDriveExecutor::new(
"http://127.0.0.1:1".into(),
"unused".into(),
HashMap::new(),
"default".into(),
)
.with_tap(MissionTap {
pool: pool.clone(),
workspace_id: workspace_id.as_uuid(),
mission_id: Uuid::now_v7(),
phase_id: None,
run_id: None,
})
}
#[tokio::test]
async fn a_pinned_skill_body_reaches_the_turn_prompt() {
let pool = cm_testkit::test_pool().await;
const MARKER: &str = "Never review a paper from its title alone.";
let (alias, ws) = seed_claw_with_pinned_skill(&pool, MARKER).await;
let text = executor(&pool, ws)
.pinned_skills_text(&alias)
.await
.expect("a claw with a pinned template skill must produce skill text");
assert!(
text.contains(MARKER),
"the skill BODY must be present, not just its name — there is no \
`skills.read` tool on the mission path, so an index would name a \
procedure the agent has no way to fetch. Got:\n{text}"
);
}
#[tokio::test]
async fn an_agent_with_no_pinned_skills_adds_nothing() {
let pool = cm_testkit::test_pool().await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "No Skills".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let user = seed_user(&pool, ws.id).await;
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Bare".into(),
job_title: "researcher".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: user,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(&pool, &agent, &cm_domain::AccessPolicy::default())
.await
.unwrap();
let alias = cm_api::runtime_provision::claw_alias(agent.id.as_uuid());
assert!(
executor(&pool, ws.id)
.pinned_skills_text(&alias)
.await
.is_none(),
"an agent with no bound skills must add no section at all — an empty \
`# Your skills` heading tells the model it has skills and then shows \
it none"
);
}
#[test]
fn the_composed_prompt_carries_the_skill_and_omits_the_heading_when_empty() {
let base = "You are the \"researcher\" agent.\n\nTask: read the papers";
let with = cm_api::topology_exec::compose_turn_prompt(base, Some("## arxiv-daily\nDo not re-search."));
assert!(with.contains("Task: read the papers"), "the base turn must survive");
assert!(with.contains("Do not re-search."), "the skill body must be in the prompt");
assert!(with.contains("# Your skills"), "the section needs a heading");
for empty in [None, Some(""), Some(" \n ")] {
let without = cm_api::topology_exec::compose_turn_prompt(base, empty);
assert_eq!(
without, base,
"with no skills the prompt must be byte-identical to the base — an \
empty heading announces skills the agent does not have"
);
}
}