slice 3.5a: skills catalog — the "how to think" layer
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 38s
ci / rust (push) Failing after 1m28s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Introduce the skills catalog: the second half of the two-layer agent
model (skills teach agents HOW to think about a problem; MCP servers
give them the ABILITY to act). Delivery via MCP resources lands in
Slice 3.5b; this slice ships the data model + API surface.

Migration 0049 extends the legacy `skills` table (from 0001_init.sql,
originally a workspace catalog of markdown snippets) with the richer
typing we need — name, when_to_use, tags, source_kind, current_version
— rather than duplicating tables. Also adds:
  - skill_versions       (version history for level-up promotions +
                          rollback; back-pointer via promoted_from
                          JSONB records agent_id / research artifact /
                          brain memory that produced it)
  - template_role_skills (m2m binding skills to team-template roles
                          with pin_in_context + order_idx)
  - agent_skills_ext     (per-agent overlay: include=true adds a skill
                          to the bundle; include=false prunes a
                          template default for this specific agent)

Rust surface:
  - cm_db::repo::skills_catalog with typed Skill/SkillVersion/
    AgentSkillBinding structs + upsert_builtin (idempotent — bumps
    version + appends to skill_versions ONLY when body changes) +
    list_visible/get/get_by_name reads + template + agent binding
    helpers + effective_for_agent (merges template defaults with
    agent overrides, applies exclude precedence, batch-fetches skill
    bodies)
  - cm_api::routes::skills_catalog with:
      GET /api/skills             — list visible
      GET /api/skills/{id}        — detail
      GET /api/claws/{id}/skills  — effective binding (accepts
        template_id + role_slot as query args to merge in template
        defaults)

Follow-ups:
  - Slice 3.5b: clawmates_skills MCP server exposes catalog as MCP
    resources, honoring pin_in_context for auto-injection
  - Slice 3.5c: seed ~40-60 builtin skills across the 6 stacks

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 13:00:56 -07:00
co-authored by Claude Opus 4.7
parent 3f77370a6e
commit 1153d72d00
6 changed files with 577 additions and 0 deletions
+1
View File
@@ -28,6 +28,7 @@ pub mod run_events;
pub mod runs;
pub mod sessions;
pub mod skills;
pub mod skills_catalog;
pub mod steps;
pub mod structure_reify;
pub mod team_templates;
+411
View File
@@ -0,0 +1,411 @@
//! Skills catalog — the second half of the two-layer agent model
//! (skills = how to think, MCP = how to act). Slice 3.5a of the
//! missions consolidation.
//!
//! Note: this module intentionally sits alongside the existing
//! `crate::repo::skills` module (which is unrelated — that older
//! module deals with a different concept). The two coexist until
//! we get around to renaming the legacy one.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::PgPool;
use sqlx::Row;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
// ── Types ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
pub id: Uuid,
pub name: String,
pub description: String,
pub when_to_use: Option<String>,
pub tags: Vec<String>,
pub source_kind: String,
pub workspace_id: Option<Uuid>,
pub current_version: i32,
pub body: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillVersion {
pub skill_id: Uuid,
pub version: i32,
pub body: String,
pub description: String,
pub when_to_use: Option<String>,
pub promoted_from: Option<Value>,
pub author_id: Option<Uuid>,
pub approved_by: Option<Uuid>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
/// Effective skill binding for a specific agent — the merged view of
/// (template role skills) + (agent overrides). Slice 3.5b's MCP
/// server reads this to figure out what to expose.
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillBinding {
pub skill: Skill,
pub pin_in_context: bool,
/// Where the binding came from — helpful for the level-up UI.
/// "template" | "agent_override" | "agent_only"
pub source: String,
}
// ── Upsert builtins ──────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct UpsertBuiltinSkill<'a> {
/// Caller-provided deterministic id (sha256-derived in the loader).
pub id: Uuid,
pub name: &'a str,
pub description: &'a str,
pub when_to_use: Option<&'a str>,
pub tags: Vec<String>,
pub body: &'a str,
}
/// Idempotent upsert for builtin skills. Bumps `current_version` +
/// appends to `skill_versions` only when `body` actually changes.
/// Never touches non-builtin rows.
pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltinSkill<'_>) -> Result<Uuid, DbError> {
let mut tx = pool.begin().await?;
// Look up the current stored body (if any) to decide whether the
// upsert should bump the version. Builtins have workspace_id NULL.
let existing: Option<(i32, String)> = sqlx::query(
"SELECT current_version, body
FROM skills WHERE id = $1 AND source_kind = 'builtin'",
)
.bind(b.id)
.fetch_optional(&mut *tx)
.await?
.map(|r| {
(
r.get::<i32, _>("current_version"),
r.get::<String, _>("body"),
)
});
let (next_version, bump) = match &existing {
Some((v, prev)) if prev == b.body => (*v, false),
Some((v, _)) => (v + 1, true),
None => (1, true),
};
// Legacy `skills` table (0001_init.sql) still requires `title` +
// `author` — populate from `name` + a system marker until Slice 9
// drops the legacy columns.
sqlx::query(
"INSERT INTO skills
(id, name, title, author, description, when_to_use, tags,
source_kind, workspace_id, current_version, body)
VALUES ($1,$2,$2,'system',$3,$4,$5,'builtin',NULL,$6,$7)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
title = EXCLUDED.title,
description = EXCLUDED.description,
when_to_use = EXCLUDED.when_to_use,
tags = EXCLUDED.tags,
current_version = EXCLUDED.current_version,
body = EXCLUDED.body,
updated_at = now()",
)
.bind(b.id)
.bind(b.name)
.bind(b.description)
.bind(b.when_to_use)
.bind(&b.tags)
.bind(next_version)
.bind(b.body)
.execute(&mut *tx)
.await?;
if bump {
sqlx::query(
"INSERT INTO skill_versions
(skill_id, version, body_md, description, when_to_use)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT DO NOTHING",
)
.bind(b.id)
.bind(next_version)
.bind(b.body)
.bind(b.description)
.bind(b.when_to_use)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(b.id)
}
// ── Reads ────────────────────────────────────────────────────────
/// All builtin + workspace-scoped skills the caller can see.
pub async fn list_visible(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<Skill>, DbError> {
let rows = sqlx::query(
"SELECT id, name, description, when_to_use, tags, source_kind,
workspace_id, current_version, body, created_at, updated_at
FROM skills
WHERE workspace_id IS NULL OR workspace_id = $1
ORDER BY workspace_id NULLS FIRST, name ASC",
)
.bind(workspace_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_skill).collect())
}
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<Skill>, DbError> {
let row = sqlx::query(
"SELECT id, name, description, when_to_use, tags, source_kind,
workspace_id, current_version, body, created_at, updated_at
FROM skills WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_skill))
}
pub async fn get_by_name(
pool: &PgPool,
workspace_id: Option<Uuid>,
name: &str,
) -> Result<Option<Skill>, DbError> {
// COALESCE-based unique lookup mirroring the partial UNIQUE index
// in the migration.
let sentinel: Uuid = "00000000-0000-0000-0000-000000000000".parse().unwrap();
let row = sqlx::query(
"SELECT id, name, description, when_to_use, tags, source_kind,
workspace_id, current_version, body, created_at, updated_at
FROM skills
WHERE COALESCE(workspace_id, $1::uuid) = COALESCE($2::uuid, $1::uuid)
AND name = $3",
)
.bind(sentinel)
.bind(workspace_id)
.bind(name)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_skill))
}
// ── Template-role bindings ───────────────────────────────────────
#[derive(Debug, Clone)]
pub struct AttachRoleSkill<'a> {
pub template_id: Uuid,
pub slot: &'a str,
pub skill_id: Uuid,
pub pin_in_context: bool,
pub order_idx: i32,
}
pub async fn attach_role_skill(pool: &PgPool, a: AttachRoleSkill<'_>) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO template_role_skills
(template_id, slot, skill_id, pin_in_context, order_idx)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (template_id, slot, skill_id) DO UPDATE SET
pin_in_context = EXCLUDED.pin_in_context,
order_idx = EXCLUDED.order_idx",
)
.bind(a.template_id)
.bind(a.slot)
.bind(a.skill_id)
.bind(a.pin_in_context)
.bind(a.order_idx)
.execute(pool)
.await?;
Ok(())
}
/// Wipe all skill bindings for a template's roles. Used by the
/// template loader before it re-attaches the current-truth set from
/// TOML, so a role that no longer references a skill loses that
/// binding without an explicit detach call.
pub async fn clear_template_role_skills(pool: &PgPool, template_id: Uuid) -> Result<(), DbError> {
sqlx::query("DELETE FROM template_role_skills WHERE template_id = $1")
.bind(template_id)
.execute(pool)
.await?;
Ok(())
}
// ── Agent-level overlay ──────────────────────────────────────────
pub async fn set_agent_skill(
pool: &PgPool,
agent_id: Uuid,
skill_id: Uuid,
included: bool,
pin_in_context: bool,
added_by: Option<Uuid>,
added_reason: Option<&str>,
) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO agent_skills_ext
(agent_id, skill_id, included, pin_in_context, added_by, added_reason)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (agent_id, skill_id) DO UPDATE SET
included = EXCLUDED.included,
pin_in_context = EXCLUDED.pin_in_context,
added_by = COALESCE(EXCLUDED.added_by, agent_skills_ext.added_by),
added_reason = COALESCE(EXCLUDED.added_reason, agent_skills_ext.added_reason)",
)
.bind(agent_id)
.bind(skill_id)
.bind(included)
.bind(pin_in_context)
.bind(added_by)
.bind(added_reason)
.execute(pool)
.await?;
Ok(())
}
/// Effective binding for the specified agent — merges template
/// defaults with per-agent overrides. Excluded skills (agent_skills_ext.included=false)
/// are filtered out here so callers see one flat truth list.
pub async fn effective_for_agent(
pool: &PgPool,
agent_id: Uuid,
template_id: Option<Uuid>,
role_slot: Option<&str>,
) -> Result<Vec<AgentSkillBinding>, DbError> {
// Two queries + client-side merge — the join is straightforward
// but the "template default overridden by agent" precedence is
// cleaner in Rust than in SQL. In-workspace call so N stays small
// (dozens, not thousands).
let mut template_bindings: Vec<(Uuid, bool, i32)> = Vec::new();
if let (Some(tid), Some(slot)) = (template_id, role_slot) {
let rows = sqlx::query(
"SELECT skill_id, pin_in_context, order_idx
FROM template_role_skills
WHERE template_id = $1 AND slot = $2
ORDER BY order_idx ASC",
)
.bind(tid)
.bind(slot)
.fetch_all(pool)
.await?;
template_bindings = rows
.into_iter()
.map(|r| {
(
r.get::<Uuid, _>("skill_id"),
r.get::<bool, _>("pin_in_context"),
r.get::<i32, _>("order_idx"),
)
})
.collect();
}
let overrides: Vec<(Uuid, bool, bool)> = sqlx::query(
"SELECT skill_id, included, pin_in_context
FROM agent_skills_ext WHERE agent_id = $1",
)
.bind(agent_id)
.fetch_all(pool)
.await?
.into_iter()
.map(|r| {
(
r.get::<Uuid, _>("skill_id"),
r.get::<bool, _>("included"),
r.get::<bool, _>("pin_in_context"),
)
})
.collect();
use std::collections::HashMap;
let override_map: HashMap<Uuid, (bool, bool)> = overrides
.iter()
.map(|(id, inc, pin)| (*id, (*inc, *pin)))
.collect();
// Build the final id set + per-id (pin, source) tuple.
let mut ordered: Vec<(Uuid, bool, &'static str)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for (id, tpl_pin, _order) in &template_bindings {
let (included, pin) = match override_map.get(id) {
Some((false, _)) => continue, // agent explicitly excluded
Some((true, agent_pin)) => (true, *agent_pin || *tpl_pin),
None => (true, *tpl_pin),
};
if included && seen.insert(*id) {
ordered.push((*id, pin, "template"));
}
}
for (id, included, agent_pin) in &overrides {
if !included || seen.contains(id) {
continue;
}
if seen.insert(*id) {
ordered.push((*id, *agent_pin, "agent_only"));
}
}
if ordered.is_empty() {
return Ok(Vec::new());
}
// Batch-fetch the skill bodies in one query.
let ids: Vec<Uuid> = ordered.iter().map(|(id, _, _)| *id).collect();
let skill_rows = sqlx::query(
"SELECT id, name, description, when_to_use, tags, source_kind,
workspace_id, current_version, body, created_at, updated_at
FROM skills WHERE id = ANY($1)",
)
.bind(&ids)
.fetch_all(pool)
.await?;
let skill_map: HashMap<Uuid, Skill> = skill_rows
.into_iter()
.map(|r| {
let s = row_to_skill(r);
(s.id, s)
})
.collect();
Ok(ordered
.into_iter()
.filter_map(|(id, pin, source)| {
skill_map.get(&id).cloned().map(|skill| AgentSkillBinding {
skill,
pin_in_context: pin,
source: source.to_string(),
})
})
.collect())
}
// ── Helpers ──────────────────────────────────────────────────────
fn row_to_skill(r: sqlx::postgres::PgRow) -> Skill {
Skill {
id: r.get("id"),
name: r.get("name"),
description: r.get("description"),
when_to_use: r.get("when_to_use"),
tags: r.get("tags"),
source_kind: r.get("source_kind"),
workspace_id: r.get("workspace_id"),
current_version: r.get("current_version"),
body: r.get("body"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
}
}