slice 3: 6 team templates seeded from TOML recipes
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 11s
ci / frontend (push) Successful in 28s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Team templates are the canonical rosters + tool bundles that mint
concrete teams for a mission. Every builtin ships as a TOML recipe
under templates/teams/*.toml, loaded into the DB at server boot.

Migration 0048 adds:
  - team_templates    (id, key, name, stack, default_topology,
                       risk_profile, mcp_bundles, version, source,
                       workspace_id)
  - template_roles    (m2m: template_id + slot; system_prompt,
                       skills[], brain_seed)
  - teams gets template_id + template_version for level-up lineage

Ships 6 builtins:
  - rust_sdlc  — planner/coder/tester/reviewer/committer for Rust
  - backend    — api_designer/db_engineer/coder/tester/committer
                 (Postgres, DuckDB, graph DBs, wire protocols)
  - frontend   — designer/coder/tester/committer (React + Tailwind + ShadCN)
  - mobile     — designer/coder/tester/committer (Expo, RN, iOS, Android)
  - gpu        — arch_analyst/kernel_author/bench_engineer/coder/committer
                 (CUDA, Metal, ROCm from Rust)
  - threejs    — scene_designer/coder/shader_author/perf_engineer/
                 committer (three.js, WebGL, WebGPU)

Each role has a versioned system_prompt + skill list + brain_seed
markdown. Skills column is a name array today; Slice 3.5a promotes it
to a typed m2m join with the real skills catalog.

Server boot:
  - team_template_loader::load_builtins reads TOML from
    /etc/clawmates/templates/teams (container) or templates/teams (dev),
    upserts idempotently. Deterministic uuid per template key (sha256
    of a fixed namespace + key) so ids are stable across boots.
  - Dockerfile copies templates/ to /etc/clawmates/templates.

Read API:
  - GET /api/team-templates       — list all
  - GET /api/team-templates/{id}  — detail with roles

Wizard:
  - Step 3 rewired from a raw team_id text field to a template picker
    with "LLM auto-provision" as the default option + one card per
    builtin, showing stack, topology, risk profile, and description.
  - Mission create now passes team_template_id (not team_id) so phase
    execution knows which template to mint from.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 12:41:15 -07:00
co-authored by Claude Opus 4.7
parent fc67936e33
commit 9ba5c06a1a
18 changed files with 1202 additions and 14 deletions
+1
View File
@@ -30,6 +30,7 @@ pub mod sessions;
pub mod skills;
pub mod steps;
pub mod structure_reify;
pub mod team_templates;
pub mod teams;
pub mod terminal_tabs;
pub mod threads;
+224
View File
@@ -0,0 +1,224 @@
//! Team templates — canonical rosters + tool bundles that materialize
//! concrete `teams` rows. Slice 3 of the missions consolidation.
//!
//! Rows with `source = 'builtin'` are re-upserted from disk (TOML
//! recipes under `templates/teams/`) at server boot. `source = 'user'`
//! rows are workspace-authored and never overwritten by the loader.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamTemplate {
pub id: Uuid,
pub key: String,
pub name: String,
pub stack: Vec<String>,
pub default_topology: String,
pub risk_profile: String,
pub mcp_bundles: Vec<String>,
pub version: i32,
pub description: Option<String>,
pub config: Value,
pub source: String,
pub workspace_id: Option<Uuid>,
#[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 TemplateRole {
pub template_id: Uuid,
pub slot: String,
pub order_idx: i32,
pub system_prompt: String,
pub skills: Vec<String>,
pub brain_seed: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TeamTemplateDetail {
#[serde(flatten)]
pub template: TeamTemplate,
pub roles: Vec<TemplateRole>,
}
#[derive(Debug, Clone)]
pub struct UpsertBuiltinRole<'a> {
pub slot: &'a str,
pub order_idx: i32,
pub system_prompt: &'a str,
pub skills: Vec<String>,
pub brain_seed: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct UpsertBuiltin<'a> {
/// Caller-provided deterministic id (typically a hash of `key`
/// computed in cm-api's loader — cm-db doesn't need the hashing
/// dep just for this one thing).
pub id: Uuid,
pub key: &'a str,
pub name: &'a str,
pub stack: Vec<String>,
pub default_topology: &'a str,
pub risk_profile: &'a str,
pub mcp_bundles: Vec<String>,
pub version: i32,
pub description: Option<&'a str>,
pub config: Value,
pub roles: Vec<UpsertBuiltinRole<'a>>,
}
/// Upsert a builtin template + its roles in one txn. Idempotent.
pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid, DbError> {
let id = b.id;
let mut tx = pool.begin().await?;
sqlx::query(
"INSERT INTO team_templates
(id, key, name, stack, default_topology, risk_profile,
mcp_bundles, version, description, config, source, workspace_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'builtin',NULL)
ON CONFLICT (id) DO UPDATE SET
key = EXCLUDED.key,
name = EXCLUDED.name,
stack = EXCLUDED.stack,
default_topology = EXCLUDED.default_topology,
risk_profile = EXCLUDED.risk_profile,
mcp_bundles = EXCLUDED.mcp_bundles,
version = EXCLUDED.version,
description = EXCLUDED.description,
config = EXCLUDED.config,
updated_at = now()",
)
.bind(id)
.bind(b.key)
.bind(b.name)
.bind(&b.stack)
.bind(b.default_topology)
.bind(b.risk_profile)
.bind(&b.mcp_bundles)
.bind(b.version)
.bind(b.description)
.bind(&b.config)
.execute(&mut *tx)
.await?;
// Replace-in-place role set. Roles that get removed from the TOML
// disappear from the DB; keeps the on-disk source authoritative.
sqlx::query("DELETE FROM template_roles WHERE template_id = $1")
.bind(id)
.execute(&mut *tx)
.await?;
for r in &b.roles {
sqlx::query(
"INSERT INTO template_roles
(template_id, slot, order_idx, system_prompt, skills, brain_seed)
VALUES ($1,$2,$3,$4,$5,$6)",
)
.bind(id)
.bind(r.slot)
.bind(r.order_idx)
.bind(r.system_prompt)
.bind(&r.skills)
.bind(r.brain_seed)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(id)
}
pub async fn list_all(pool: &PgPool) -> Result<Vec<TeamTemplate>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, key, name, stack, default_topology, risk_profile,
mcp_bundles, version, description, config, source,
workspace_id, created_at, updated_at
FROM team_templates
WHERE source = 'builtin' OR workspace_id IS NOT NULL
ORDER BY source DESC, name ASC",
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_template).collect())
}
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TeamTemplateDetail>, DbError> {
use sqlx::Row;
let Some(t) = sqlx::query(
"SELECT id, key, name, stack, default_topology, risk_profile,
mcp_bundles, version, description, config, source,
workspace_id, created_at, updated_at
FROM team_templates WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?
.map(row_to_template) else {
return Ok(None);
};
let role_rows = sqlx::query(
"SELECT template_id, slot, order_idx, system_prompt, skills, brain_seed
FROM template_roles WHERE template_id = $1
ORDER BY order_idx ASC",
)
.bind(id)
.fetch_all(pool)
.await?;
let roles = role_rows
.into_iter()
.map(|r| TemplateRole {
template_id: r.get("template_id"),
slot: r.get("slot"),
order_idx: r.get("order_idx"),
system_prompt: r.get("system_prompt"),
skills: r.get("skills"),
brain_seed: r.get("brain_seed"),
})
.collect();
Ok(Some(TeamTemplateDetail { template: t, roles }))
}
pub async fn get_by_key(pool: &PgPool, key: &str) -> Result<Option<TeamTemplate>, DbError> {
use sqlx::Row;
let row = sqlx::query(
"SELECT id, key, name, stack, default_topology, risk_profile,
mcp_bundles, version, description, config, source,
workspace_id, created_at, updated_at
FROM team_templates WHERE key = $1",
)
.bind(key)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_template))
}
fn row_to_template(r: sqlx::postgres::PgRow) -> TeamTemplate {
use sqlx::Row;
TeamTemplate {
id: r.get("id"),
key: r.get("key"),
name: r.get("name"),
stack: r.get("stack"),
default_topology: r.get("default_topology"),
risk_profile: r.get("risk_profile"),
mcp_bundles: r.get("mcp_bundles"),
version: r.get("version"),
description: r.get("description"),
config: r.get("config"),
source: r.get("source"),
workspace_id: r.get("workspace_id"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
}
}