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
+88 -1
View File
@@ -42,6 +42,13 @@ use tokio_tungstenite::tungstenite::Message;
const TURN_TIMEOUT: Duration = Duration::from_secs(3600);
/// Drives ZeroClaw role-agents (in one container) to execute topology turns.
/// Cap on the pinned-skill text injected into one mission turn.
///
/// Skill bodies average ~3.5 KB and pinning is `idx < 2 || foundation`, so a
/// role lands near 7-10 KB. The cap exists for the role that grows a long
/// foundation set, and it is stated in the prompt when it fires.
const MAX_PINNED_SKILL_BYTES: usize = 24_000;
pub struct ZeroClawDriveExecutor {
/// Gateway base URL, e.g. `http://127.0.0.1:42617`.
gateway_url: String,
@@ -276,6 +283,65 @@ impl ZeroClawDriveExecutor {
Ok(token)
}
/// The pinned skills for the claw behind `alias`, rendered for the prompt.
///
/// Missions had NO path to a skill. The catalogue's only delivery channel
/// is the `clawmates_skills` MCP server, and a mission agent cannot reach
/// it for three independent reasons: `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 at all. Two doc comments in `cm-runtime` describe the mission path
/// as already having this contract. It never did — so every skill authored
/// for a mission role was unreachable prose, and no measurement of whether
/// skills fire could have returned anything but zero.
///
/// Bodies, not an index. The chat path lists names and lets the claw call
/// `skills.read`; there is no such tool here, so an index would advertise a
/// capability that does not exist — the exact failure this whole change is
/// about. Pinned only (`pin_in_context`), because everything else would go
/// in unbounded and unread.
pub async fn pinned_skills_text(&self, alias: &str) -> Option<String> {
let tap = self.tap.as_ref()?;
let agent_id = crate::runtime_provision::claw_from_alias(alias)?;
let link = cm_db::repo::agent_template_link::get(&tap.pool, agent_id)
.await
.ok()
.flatten();
let (tpl_id, slot) = link
.as_ref()
.map(|l| (Some(l.template_id), Some(l.role_slot.as_str())))
.unwrap_or((None, None));
let bindings =
cm_db::repo::skills_catalog::effective_for_agent(&tap.pool, agent_id, tpl_id, slot)
.await
.ok()?;
let mut out = String::new();
let mut n = 0usize;
for b in bindings.iter().filter(|b| b.pin_in_context) {
// Bounded, and truncation is STATED. A silently clipped procedure
// is worse than an absent one: the agent follows the half it can
// see and reports success against a rule it never read.
if out.len() + b.skill.body.len() > MAX_PINNED_SKILL_BYTES {
out.push_str(&format!(
"\n[skill \"{}\" omitted — the pinned set exceeded {} bytes]\n",
b.skill.name, MAX_PINNED_SKILL_BYTES
));
continue;
}
out.push_str("\n## ");
out.push_str(&b.skill.name);
out.push('\n');
out.push_str(&b.skill.body);
out.push('\n');
n += 1;
}
if n == 0 {
return None;
}
Some(out)
}
/// Mirror of `ProviderExecutor`'s prompt, flattened to one `content` string
/// (the gateway `message` envelope carries a single content field).
fn build_prompt(req: &TurnRequest) -> String {
@@ -674,11 +740,32 @@ impl TurnExecutor for ZeroClawDriveExecutor {
);
fallback
});
let prompt = Self::build_prompt(&req);
let prompt = compose_turn_prompt(
&Self::build_prompt(&req),
self.pinned_skills_text(&alias).await.as_deref(),
);
self.drive(&alias, &prompt).await
}
}
/// The base turn prompt with the agent's pinned skills appended, if it has any.
///
/// Split out from `run_turn` so the wiring is testable: `pinned_skills_text`
/// working and `run_turn` actually calling it are different claims, and the
/// second is the one that was false for every skill in the catalogue.
pub fn compose_turn_prompt(base: &str, skills: Option<&str>) -> String {
let Some(skills) = skills.map(str::trim).filter(|s| !s.is_empty()) else {
// No heading when there is nothing under it. An empty "Your skills"
// section tells the model it has skills and then shows it none, which
// is worse than silence.
return base.to_string();
};
format!(
"{base}\n\n# Your skills\n\nThese are procedures you are expected to follow for \
this kind of work. Where one applies to what you are about to do, follow it.\n\n{skills}"
)
}
/// Parse `role=alias,role=alias` into a map (blank/malformed entries skipped).
fn parse_agent_map(s: &str) -> HashMap<String, String> {
s.split(',')