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
+10
View File
@@ -271,6 +271,16 @@ async fn run() -> Result<(), String> {
runtime.clone(), runtime.clone(),
std::time::Duration::from_secs(3), std::time::Duration::from_secs(3),
); );
// Load builtin team templates from disk into team_templates +
// template_roles. Idempotent per boot; edits to templates/teams/*.toml
// go live on the next deploy.
{
let pool = pool.clone();
tokio::spawn(async move {
let n = cm_api::team_template_loader::load_builtins(&pool).await;
eprintln!("team_template_loader: loaded {n} builtin team template(s)");
});
}
// Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert // Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert
// until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist. // until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist.
cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10)); cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10));
+1
View File
@@ -8,6 +8,7 @@ publish.workspace = true
[dependencies] [dependencies]
getrandom = "0.2" getrandom = "0.2"
toml = "0.8"
hex = "0.4" hex = "0.4"
hmac = "0.12" hmac = "0.12"
sha2 = "0.10" sha2 = "0.10"
+9
View File
@@ -13,6 +13,7 @@ pub mod research_container;
mod routes; mod routes;
mod runtime_provision; mod runtime_provision;
pub mod swarm; pub mod swarm;
pub mod team_template_loader;
pub mod tool_versions; pub mod tool_versions;
mod topology_exec; mod topology_exec;
pub mod topology_worker; pub mod topology_worker;
@@ -401,6 +402,14 @@ pub fn router(state: AppState) -> Router {
axum::routing::patch(routes::orgs::rename_org), axum::routing::patch(routes::orgs::rename_org),
) )
.route("/api/orgs/{id}/run", post(routes::orgs::run_org)) .route("/api/orgs/{id}/run", post(routes::orgs::run_org))
// Team templates — Slice 3 read-only surface. Server upserts
// builtins from TOML on boot; workspace-authored templates
// land here in a later slice.
.route("/api/team-templates", get(routes::team_templates::list))
.route(
"/api/team-templates/{id}",
get(routes::team_templates::get),
)
// Missions — Slice 1 skeleton. Runs in parallel with the // Missions — Slice 1 skeleton. Runs in parallel with the
// legacy research/loops routes until Slice 9's cutover. // legacy research/loops routes until Slice 9's cutover.
.route( .route(
+1
View File
@@ -14,6 +14,7 @@ pub mod health;
pub mod identity; pub mod identity;
pub mod loops; pub mod loops;
pub mod missions; pub mod missions;
pub mod team_templates;
pub mod nodes; pub mod nodes;
pub mod oauth; pub mod oauth;
pub mod orgs; pub mod orgs;
@@ -0,0 +1,33 @@
//! `/api/team-templates/*` — expose builtin + workspace team templates
//! to the wizard's team-picker step.
use axum::{
Json,
extract::{Path, State},
};
use cm_db::repo::team_templates::{TeamTemplate, TeamTemplateDetail};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
pub async fn list(
State(state): State<AppState>,
Authed(_user): Authed,
) -> Result<Json<Vec<TeamTemplate>>, ApiError> {
// Builtins are cross-workspace; workspace-authored templates are
// filtered by the repo layer (this route only exposes builtins for
// now — Slice 3 doesn't ship a workspace template editor yet).
let rows = cm_db::repo::team_templates::list_all(&state.pool).await?;
Ok(Json(rows))
}
pub async fn get(
State(state): State<AppState>,
Authed(_user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<TeamTemplateDetail>, ApiError> {
let d = cm_db::repo::team_templates::get(&state.pool, id)
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(d))
}
+156
View File
@@ -0,0 +1,156 @@
//! Load builtin team template TOML recipes from disk into
//! `team_templates` + `template_roles` at server boot.
//!
//! Templates ship under `templates/teams/*.toml` (repo-root path,
//! bundled into the server container image). The loader is idempotent:
//! re-upserts every boot so template edits go live on the next
//! deploy without needing a manual migration.
use serde::Deserialize;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use std::path::PathBuf;
use cm_db::repo::team_templates::{
upsert_builtin, UpsertBuiltin, UpsertBuiltinRole,
};
/// Deterministic id per builtin template key. Rolling to sha256 of a
/// stable namespace + the key gives us a v4-shaped id that never
/// changes across boots (the uuid crate's v5 feature isn't enabled in
/// the workspace and we didn't want to bump it just for this).
fn builtin_id(key: &str) -> uuid::Uuid {
let mut h = Sha256::new();
h.update(b"clawmates.builtin.team_template\x00");
h.update(key.as_bytes());
let digest = h.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
// Force the v4 layout so the id passes any downstream v4-shaped
// checks (version nibble = 4, variant bits = 10xx).
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
uuid::Uuid::from_bytes(bytes)
}
#[derive(Debug, Deserialize)]
struct TemplateFile {
key: String,
name: String,
#[serde(default)]
stack: Vec<String>,
default_topology: String,
risk_profile: String,
#[serde(default)]
mcp_bundles: Vec<String>,
#[serde(default = "default_version")]
version: i32,
#[serde(default)]
description: Option<String>,
#[serde(default)]
config: serde_json::Value,
#[serde(default)]
roles: Vec<TemplateRoleFile>,
}
fn default_version() -> i32 {
1
}
#[derive(Debug, Deserialize)]
struct TemplateRoleFile {
slot: String,
order_idx: i32,
system_prompt: String,
#[serde(default)]
skills: Vec<String>,
#[serde(default)]
brain_seed: Option<String>,
}
fn templates_dir() -> PathBuf {
if let Ok(d) = std::env::var("CLAWMATES_TEAM_TEMPLATES_DIR") {
return PathBuf::from(d);
}
// Container default — Dockerfile copies templates/ to /etc/clawmates/templates.
let container = PathBuf::from("/etc/clawmates/templates/teams");
if container.exists() {
return container;
}
// Dev fallback — repo-relative.
PathBuf::from("templates/teams")
}
/// Read every `*.toml` under the templates dir and upsert. Skips files
/// that fail to parse but logs the reason so a broken template can't
/// block server boot entirely.
pub async fn load_builtins(pool: &PgPool) -> usize {
let dir = templates_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(e) => {
eprintln!(
"team_template_loader: templates dir {} not readable: {e} — skipping builtin seed",
dir.display()
);
return 0;
}
};
let mut loaded = 0usize;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("toml") {
continue;
}
match load_one(pool, &path).await {
Ok(key) => {
loaded += 1;
eprintln!("team_template_loader: upserted builtin {key}");
}
Err(e) => {
eprintln!(
"team_template_loader: failed to load {}: {e}",
path.display()
);
}
}
}
loaded
}
async fn load_one(pool: &PgPool, path: &std::path::Path) -> Result<String, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("read {}: {e}", path.display()))?;
let file: TemplateFile = toml::from_str(&text)
.map_err(|e| format!("parse {}: {e}", path.display()))?;
let key = file.key.clone();
let roles: Vec<UpsertBuiltinRole<'_>> = file
.roles
.iter()
.map(|r| UpsertBuiltinRole {
slot: &r.slot,
order_idx: r.order_idx,
system_prompt: &r.system_prompt,
skills: r.skills.clone(),
brain_seed: r.brain_seed.as_deref(),
})
.collect();
let builtin = UpsertBuiltin {
id: builtin_id(&file.key),
key: &file.key,
name: &file.name,
stack: file.stack.clone(),
default_topology: &file.default_topology,
risk_profile: &file.risk_profile,
mcp_bundles: file.mcp_bundles.clone(),
version: file.version,
description: file.description.as_deref(),
config: file.config.clone(),
roles,
};
upsert_builtin(pool, builtin)
.await
.map_err(|e| format!("upsert {key}: {e}"))?;
Ok(key)
}
+1
View File
@@ -30,6 +30,7 @@ pub mod sessions;
pub mod skills; pub mod skills;
pub mod steps; pub mod steps;
pub mod structure_reify; pub mod structure_reify;
pub mod team_templates;
pub mod teams; pub mod teams;
pub mod terminal_tabs; pub mod terminal_tabs;
pub mod threads; 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"),
}
}
@@ -12,7 +12,7 @@
// the template's canned phase composition; Slice 4 threads the TOML // the template's canned phase composition; Slice 4 threads the TOML
// recipe engine through here for real template dispatch. // recipe engine through here for real template dispatch.
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { import {
@@ -23,6 +23,10 @@ import {
type TemplateKind, type TemplateKind,
type TemplatePreset, type TemplatePreset,
} from "@/lib/api/missions"; } from "@/lib/api/missions";
import {
listTeamTemplates,
type TeamTemplate,
} from "@/lib/api/team-templates";
import { RepoPicker, type PickedRepo } from "./RepoPicker"; import { RepoPicker, type PickedRepo } from "./RepoPicker";
const mono = const mono =
@@ -42,7 +46,17 @@ export function MissionWizard({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null); const [repo, setRepo] = useState<PickedRepo | null>(null);
const [teamId, setTeamId] = useState<string>(""); const [teamTemplateId, setTeamTemplateId] = useState<string>("");
const [teamTemplates, setTeamTemplates] = useState<TeamTemplate[]>([]);
useEffect(() => {
(async () => {
try {
setTeamTemplates(await listTeamTemplates());
} catch {
// Non-fatal: user can still create a mission without a team template.
}
})();
}, []);
const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot"); const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot");
const [cron, setCron] = useState("0 */6 * * *"); const [cron, setCron] = useState("0 */6 * * *");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -71,7 +85,7 @@ export function MissionWizard({
title: title.trim(), title: title.trim(),
template_kind: templateKind, template_kind: templateKind,
repo_id: repo?.repo_id, repo_id: repo?.repo_id,
team_id: teamId || undefined, team_template_id: teamTemplateId || undefined,
schedule, schedule,
description: description.trim() || undefined, description: description.trim() || undefined,
phases: preset.phases, phases: preset.phases,
@@ -268,18 +282,91 @@ export function MissionWizard({
{step === 3 && ( {step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Team</span> <span style={labelStyle}>Team template</span>
<p style={hintStyle}> <p style={hintStyle}>
Slice 3 ships team templates + auto-provision. For now, paste an Pick a canonical roster. On mission launch the team is
existing team id or leave blank to let phase execution materialized from the template — roles, prompts, MCP bundles,
auto-provision one from the description. and (later) skills + brain seeds. Leave unset to let the
mission auto-provision an LLM-derived team from the prompt.
</p> </p>
<input <div style={{ display: "grid", gap: 8 }}>
value={teamId} <button
onChange={(e) => setTeamId(e.target.value)} type="button"
placeholder="optional team UUID" onClick={() => setTeamTemplateId("")}
style={fieldStyle} style={{
/> ...templateCardStyle(teamTemplateId === ""),
}}
>
<span style={{ fontWeight: 700, color: "#f3f3f5" }}>
LLM auto-provision
</span>
<span style={{ fontSize: 12, color: "#a0a0a8" }}>
Let Claude Sonnet 5 derive 3–5 roles from the description.
Best for one-off or exploratory missions.
</span>
</button>
{teamTemplates.map((t) => {
const active = teamTemplateId === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTeamTemplateId(t.id)}
style={templateCardStyle(active)}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 700, color: "#f3f3f5", fontSize: 13.5 }}>
{t.name}
</span>
{t.source === "builtin" && (
<span
style={{
fontFamily: mono,
fontSize: 9.5,
color: "#7cd6e0",
letterSpacing: ".1em",
}}
>
BUILTIN
</span>
)}
<span
style={{
marginLeft: "auto",
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
}}
>
{t.default_topology} · {t.risk_profile}
</span>
</div>
{t.description && (
<span style={{ fontSize: 12, color: "#a0a0a8", lineHeight: 1.5 }}>
{t.description}
</span>
)}
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{t.stack.map((s) => (
<span
key={s}
style={{
fontFamily: mono,
fontSize: 10,
padding: "2px 7px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,.1)",
color: "#cfcfd5",
}}
>
{s}
</span>
))}
</div>
</button>
);
})}
</div>
</div> </div>
)} )}
@@ -330,7 +417,15 @@ export function MissionWizard({
<ReviewRow k="Title" v={title} /> <ReviewRow k="Title" v={title} />
{description && <ReviewRow k="Description" v={description} />} {description && <ReviewRow k="Description" v={description} />}
{repo && <ReviewRow k="Repo" v={`${repo.owner}/${repo.name}`} />} {repo && <ReviewRow k="Repo" v={`${repo.owner}/${repo.name}`} />}
{teamId && <ReviewRow k="Team" v={teamId} />} <ReviewRow
k="Team"
v={
teamTemplateId
? (teamTemplates.find((t) => t.id === teamTemplateId)?.name ??
teamTemplateId)
: "auto-provision from prompt"
}
/>
<ReviewRow <ReviewRow
k="Schedule" k="Schedule"
v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"} v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"}
@@ -458,6 +553,18 @@ const secondaryBtn: React.CSSProperties = {
fontSize: 12.5, fontSize: 12.5,
cursor: "pointer", cursor: "pointer",
}; };
const templateCardStyle = (active: boolean): React.CSSProperties => ({
textAlign: "left",
padding: 12,
borderRadius: 10,
border: `1px solid ${active ? "rgba(255,138,122,.6)" : "rgba(255,255,255,.1)"}`,
background: active ? "rgba(255,138,122,.08)" : "#101014",
cursor: "pointer",
color: "#eaeaee",
display: "flex",
flexDirection: "column",
gap: 6,
});
const radioRowStyle = (active: boolean): React.CSSProperties => ({ const radioRowStyle = (active: boolean): React.CSSProperties => ({
display: "flex", display: "flex",
alignItems: "flex-start", alignItems: "flex-start",
+47
View File
@@ -0,0 +1,47 @@
// Team templates API client — builtins loaded from disk at server
// boot land here. Slice 3 exposes list + get; workspace-authored
// templates come later.
export interface TeamTemplate {
id: string;
key: string;
name: string;
stack: string[];
default_topology: string;
risk_profile: string;
mcp_bundles: string[];
version: number;
description: string | null;
config: Record<string, unknown>;
source: "builtin" | "user";
workspace_id: string | null;
created_at: string;
updated_at: string;
}
export interface TemplateRole {
template_id: string;
slot: string;
order_idx: number;
system_prompt: string;
skills: string[];
brain_seed: string | null;
}
export interface TeamTemplateDetail extends TeamTemplate {
roles: TemplateRole[];
}
async function api<T>(path: string): Promise<T> {
const r = await fetch(path);
if (!r.ok) {
throw new Error(`GET ${path} → ${r.status}`);
}
return (await r.json()) as T;
}
export const listTeamTemplates = () =>
api<TeamTemplate[]>("/api/team-templates");
export const getTeamTemplate = (id: string) =>
api<TeamTemplateDetail>(`/api/team-templates/${id}`);
+2
View File
@@ -44,5 +44,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd -u 65532 -M -s /usr/sbin/nologin nonroot && useradd -u 65532 -M -s /usr/sbin/nologin nonroot
COPY --from=builder /clawmates-server /usr/local/bin/clawmates-server COPY --from=builder /clawmates-server /usr/local/bin/clawmates-server
# Builtin templates (team + workflow). Loader upserts them on boot.
COPY templates /etc/clawmates/templates
USER 65532 USER 65532
ENTRYPOINT ["/usr/local/bin/clawmates-server"] ENTRYPOINT ["/usr/local/bin/clawmates-server"]
+74
View File
@@ -0,0 +1,74 @@
-- Slice 3 — team templates.
--
-- Team templates are the canonical rosters + tool bundles used to
-- materialize concrete `teams` rows. They ship as TOML recipes on
-- disk (see templates/teams/*.toml) and are upserted into these
-- tables at server boot.
--
-- Later slices layer more on:
-- - Slice 3.5a: `skills` + `template_role_skills` (m2m join)
-- - Slice 3.5d: `agent_template_link` for level-up lineage
--
-- Discriminators kept TEXT so new templates ship as PRs without
-- schema migrations.
CREATE TABLE team_templates (
-- Deterministic id per template kind so seeded rows are stable
-- across boots. We use uuid5 of a namespace + the template `key`
-- computed in the loader; recording the key here lets us look
-- them up by name from the API.
id UUID PRIMARY KEY,
-- Slug used in URLs + TOML filenames (e.g. "rust_sdlc").
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
stack TEXT[] NOT NULL DEFAULT '{}',
default_topology TEXT NOT NULL,
risk_profile TEXT NOT NULL,
mcp_bundles TEXT[] NOT NULL DEFAULT '{}',
version INT NOT NULL DEFAULT 1,
description TEXT,
-- Free-form for template-specific knobs (default cargo features,
-- lint policy, etc.). Merged into missions.config when a mission
-- is materialized from this template.
config JSONB NOT NULL DEFAULT '{}'::jsonb,
-- 'builtin' rows are re-upserted from disk at every boot. Rows
-- with source='user' are workspace-authored and never overwritten.
source TEXT NOT NULL DEFAULT 'builtin',
workspace_id UUID, -- NULL for builtins; set for user rows
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX team_templates_workspace_idx
ON team_templates (workspace_id) WHERE workspace_id IS NOT NULL;
CREATE INDEX team_templates_source_idx
ON team_templates (source);
CREATE TABLE template_roles (
template_id UUID NOT NULL REFERENCES team_templates(id) ON DELETE CASCADE,
-- role slot within the template (e.g. "planner", "coder"). Unique
-- per template — a template can't have two "coder" slots.
slot TEXT NOT NULL,
order_idx INT NOT NULL,
-- The system prompt for this role. Loaded from disk for builtins;
-- editable for user templates.
system_prompt TEXT NOT NULL,
-- Skills attached by default when the template mints an agent for
-- this slot. Stored as a name array; Slice 3.5a introduces the
-- typed join table + real skills catalog.
skills TEXT[] NOT NULL DEFAULT '{}',
-- Brain seed content ingested into a fresh agent's .brain on
-- creation (Slice 3.5d actually wires the ingestion; this column
-- stores the seed markdown from the template).
brain_seed TEXT,
PRIMARY KEY (template_id, slot)
);
CREATE INDEX template_roles_order_idx
ON template_roles (template_id, order_idx);
-- Track which template a team was minted from, so level-up (Slice 8.5)
-- can diff learned knowledge back into template PR proposals.
ALTER TABLE teams
ADD COLUMN template_id UUID REFERENCES team_templates(id) ON DELETE SET NULL,
ADD COLUMN template_version INT;
CREATE INDEX teams_template_idx
ON teams (template_id, template_version) WHERE template_id IS NOT NULL;
+89
View File
@@ -0,0 +1,89 @@
key = "backend"
name = "Backend"
description = "Rust backend teams — Postgres, DuckDB, graph databases, APIs, wire protocols, middleware."
stack = ["rust", "postgres", "duckdb", "graph", "api", "middleware"]
default_topology = "pipeline"
risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "gitea_forge"]
version = 1
[[roles]]
slot = "api_designer"
order_idx = 0
skills = ["decompose_int_items", "openapi_schema", "small_focused_commits"]
system_prompt = """
You are the API DESIGNER of a Backend team.
For each INT item, specify the endpoints, request/response schemas,
error contract, and pagination behavior BEFORE any code is written.
Prefer additive changes; call out breaking ones explicitly.
"""
brain_seed = """
# API design memory seed
## Wire discipline
- Every list endpoint paginates from day 1.
- Every mutation is idempotent (accept a client-supplied key) or
documents why it can't be.
- Errors use RFC 7807 problem details.
"""
[[roles]]
slot = "db_engineer"
order_idx = 1
skills = ["postgres_migrations", "index_selection", "explain_analyze", "write_rust", "workspace_repo_edit"]
system_prompt = """
You are the DB ENGINEER of a Backend team.
Own migrations, indexes, transaction boundaries, and query plans.
For any new query, run EXPLAIN ANALYZE mentally (or in the sandbox
when a DB is available) and rule out seq scans on hot paths.
"""
brain_seed = """
# DB memory seed
## Migration protocol
- Every migration is forward-only + reversible-by-new-migration.
- Never DROP without a two-release deprecation window.
- CREATE INDEX CONCURRENTLY on tables > 1M rows.
## Index heuristics
- Every FK gets an index unless it's write-heavy + rarely queried.
- Partial indexes for high-cardinality boolean filters.
"""
[[roles]]
slot = "coder"
order_idx = 2
skills = ["write_rust", "cargo_build", "cargo_test", "workspace_repo_edit", "git_commit_protocol"]
system_prompt = """
You are the CODER of a Backend team.
Implement per the API DESIGNER + DB ENGINEER handoffs. Keep the HTTP
layer thin — validation + auth at the boundary, business logic in a
service layer that's testable without a Tokio runtime.
"""
brain_seed = ""
[[roles]]
slot = "tester"
order_idx = 3
skills = ["cargo_test", "integration_tests_pg", "coverage_report"]
system_prompt = """
You are the TESTER of a Backend team.
Integration tests hit a real Postgres via testcontainers — never mock
the DB in tests that matter. Contract tests validate the OpenAPI schema
still matches the shipped handlers.
"""
brain_seed = ""
[[roles]]
slot = "committer"
order_idx = 4
skills = ["git_commit_protocol", "small_focused_commits"]
system_prompt = """
You are the COMMITTER. Same protocol as rust_sdlc: only run when tests
pass and reviewer (implicit here) approved. Emit COMPLETED: INT-<NN>.
"""
brain_seed = ""
+73
View File
@@ -0,0 +1,73 @@
key = "frontend"
name = "Frontend"
description = "Latest React + TailwindCSS + ShadCN — component authoring, accessibility, responsive design."
stack = ["typescript", "react", "tailwindcss", "shadcn", "next"]
default_topology = "pipeline"
risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "gitea_forge"]
version = 1
[[roles]]
slot = "designer"
order_idx = 0
skills = ["decompose_int_items", "design_system_check", "a11y_checklist"]
system_prompt = """
You are the DESIGNER of a Frontend team.
For each INT item: sketch the component's states, define props, list
edge cases (empty / loading / error / RTL / dark mode), and pick the
ShadCN primitives to compose. Hand off to the coder with the exact
Tailwind class names and any a11y notes.
"""
brain_seed = """
# Frontend design seed
## Every component gets four states
- empty (no data yet)
- loading (skeleton, never a spinner alone)
- error (with recovery affordance)
- ready
## Never author raw HTML for a primitive ShadCN ships.
"""
[[roles]]
slot = "coder"
order_idx = 1
skills = ["write_typescript_react", "tailwind_idioms", "workspace_repo_edit", "git_commit_protocol"]
system_prompt = """
You are the CODER of a Frontend team.
Working directory /workspace/repo. Implement per the DESIGNER's spec.
Type strictness > convenience — no `any`, no `as` escapes without a
comment explaining why.
"""
brain_seed = """
# React/TS memory seed
- Server components by default; 'use client' only where interactivity
or hooks demand it.
- Tailwind class order: layout → box → typography → color → state.
- No inline styles on components that render more than once.
"""
[[roles]]
slot = "tester"
order_idx = 2
skills = ["playwright_e2e", "vitest_unit", "a11y_axe"]
system_prompt = """
You are the TESTER of a Frontend team.
Unit tests with Vitest for pure components. Playwright for flows.
Axe scan every page under test — a11y regressions fail the build.
"""
brain_seed = ""
[[roles]]
slot = "committer"
order_idx = 3
skills = ["git_commit_protocol", "small_focused_commits"]
system_prompt = """
You are the COMMITTER. Only run on green tests + a11y pass. Emit
COMPLETED: INT-<NN>.
"""
brain_seed = ""
+85
View File
@@ -0,0 +1,85 @@
key = "gpu"
name = "GPU Programming"
description = "CUDA, Metal, ROCm from Rust — low-level GPU application development, kernel authoring, memory hierarchy tuning."
stack = ["rust", "cuda", "metal", "rocm", "gpu"]
default_topology = "pipeline"
risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "gitea_forge"]
version = 1
[[roles]]
slot = "arch_analyst"
order_idx = 0
skills = ["decompose_int_items", "gpu_arch_reference", "roofline_model"]
system_prompt = """
You are the ARCHITECTURE ANALYST of a GPU team.
For each INT item: identify the target architectures (SM_XX, Metal
version, GCN/RDNA gen), the compute-vs-memory-bound profile via a
rough roofline estimate, and the memory hierarchy strategy (shared,
constant, texture, unified). Hand off with target occupancy + tile
shape recommendations.
"""
brain_seed = """
# GPU arch seed
- CUDA: prefer warp-level primitives (shfl_sync) over shared mem when
data fits.
- Metal: threadgroup memory is 32KB on Apple7+; plan tiles around it.
- ROCm: LDS is 64KB; wavefront is 64 threads (vs CUDA's 32).
- Always check bandwidth-bound vs compute-bound BEFORE optimizing.
"""
[[roles]]
slot = "kernel_author"
order_idx = 1
skills = ["write_cuda", "write_metal", "write_rocm", "write_rust_ffi", "workspace_repo_edit", "git_commit_protocol"]
system_prompt = """
You are the KERNEL AUTHOR of a GPU team.
Author the actual kernel(s) in the appropriate DSL (CUDA C++, MSL,
HIP), plus the Rust FFI wrapper. Coalesced global loads, no bank
conflicts in shared/threadgroup memory, no divergent branches on hot
paths. Prove each of those in a comment.
"""
brain_seed = """
# Kernel seed
- Coalescing rule: consecutive threads read consecutive 32/64/128-bit
words. Violating it = 10× slowdown.
- Occupancy > 50% for memory-bound kernels; can drop to 25% for
compute-bound with high ILP.
"""
[[roles]]
slot = "bench_engineer"
order_idx = 2
skills = ["nsight_profile", "metal_frame_capture", "rocprof", "criterion_bench"]
system_prompt = """
You are the BENCH ENGINEER of a GPU team.
Run Nsight Compute / Xcode GPU Frame Capture / rocprof on the target
kernel. Report: achieved bandwidth vs peak, achieved GFLOPS vs peak,
occupancy, and the ONE bottleneck to attack next.
"""
brain_seed = ""
[[roles]]
slot = "coder"
order_idx = 3
skills = ["write_rust", "cargo_build", "cargo_test", "workspace_repo_edit"]
system_prompt = """
You are the RUST-SIDE CODER. Integrate the kernel + FFI into the Rust
library, add safe wrappers, and expose ergonomic APIs. Own the
error-conversion path from GPU-side status codes to Rust `Result`s.
"""
brain_seed = ""
[[roles]]
slot = "committer"
order_idx = 4
skills = ["git_commit_protocol", "small_focused_commits"]
system_prompt = """
You are the COMMITTER. Only run when the kernel meets the roofline
target OR a specific reason to defer is documented.
Emit COMPLETED: INT-<NN>.
"""
brain_seed = ""
+67
View File
@@ -0,0 +1,67 @@
key = "mobile"
name = "Mobile"
description = "Expo + React Native for iOS/Android — camera, comms, networking, native modules."
stack = ["typescript", "react-native", "expo", "ios", "android"]
default_topology = "pipeline"
risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "gitea_forge"]
version = 1
[[roles]]
slot = "designer"
order_idx = 0
skills = ["decompose_int_items", "ios_hig_check", "material_you_check"]
system_prompt = """
You are the DESIGNER of a Mobile team.
For each INT item: define the screen's state model, navigation
integration, platform-specific behavior (safe-area on iOS, back-button
on Android), and any native-module permissions required.
"""
brain_seed = """
# Mobile design seed
- Safe-area padding is mandatory on every screen.
- Deep links go through the app's central router — never hand-crafted.
- Every permission prompt has a pre-prompt explaining WHY.
"""
[[roles]]
slot = "coder"
order_idx = 1
skills = ["write_typescript_react_native", "expo_managed_workflow", "workspace_repo_edit", "git_commit_protocol"]
system_prompt = """
You are the CODER of a Mobile team.
Working directory /workspace/repo. Prefer Expo's managed workflow;
justify any drop to bare workflow. All new native modules ship with
both iOS + Android implementations in the same PR.
"""
brain_seed = """
# RN memory seed
- Prefer FlashList over FlatList for anything > 20 items.
- Reanimated for anything animating > 3× per frame.
- No inline require() — dynamic imports break Metro's tree-shake.
"""
[[roles]]
slot = "tester"
order_idx = 2
skills = ["detox_e2e", "jest_unit", "ios_simulator_check", "android_emulator_check"]
system_prompt = """
You are the TESTER of a Mobile team.
Detox e2e on both platforms. Snapshot tests for critical layouts.
Run on both iPhone 15 Pro simulator + Pixel 8 emulator before signing
off.
"""
brain_seed = ""
[[roles]]
slot = "committer"
order_idx = 3
skills = ["git_commit_protocol", "small_focused_commits"]
system_prompt = """
You are the COMMITTER. Only run on green tests both platforms.
Emit COMPLETED: INT-<NN>.
"""
brain_seed = ""
+132
View File
@@ -0,0 +1,132 @@
key = "rust_sdlc"
name = "Rust SDLC"
description = "Full software lifecycle for Rust projects — planning, implementation, testing, review, commit. Deep systems + distributed + enterprise backend expertise."
stack = ["rust", "systems", "distributed", "backend"]
default_topology = "pipeline"
risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "gitea_forge"]
version = 1
[[roles]]
slot = "planner"
order_idx = 0
skills = ["read_roadmap", "decompose_int_items", "estimate_effort", "small_focused_commits"]
system_prompt = """
You are the PLANNER of a Rust SDLC team.
Read the mission's roadmap or research artifact. Decompose the next
unconsumed unit of work into concrete INT-XX items with clear acceptance
criteria. Estimate effort. Hand off to the coder with:
- a specific INT id and title
- the files most likely to change
- explicit test coverage requirements (≥90%)
- any invariants that must not break
Emit `TASK: INT-<NN> — <title>` on its own line when you begin a new item,
and `PLAN_COMPLETE: INT-<NN>` when the plan is fully specified.
"""
brain_seed = """
# Planner memory seed — Rust SDLC
## Decomposition heuristics
- Every INT item should be small enough that one coder can finish in
under 2 hours of focused work.
- If an INT touches more than 5 files, split it.
- Prefer refactor-then-feature over feature-with-refactor.
## Acceptance criteria checklist
- Behavior described in observable terms (input → output)
- Coverage bar named (usually ≥90% for changed lines)
- Backwards-compat expectations stated
"""
[[roles]]
slot = "coder"
order_idx = 1
skills = ["write_rust", "cargo_build", "cargo_test", "workspace_repo_edit", "small_focused_commits", "git_commit_protocol"]
system_prompt = """
You are the CODER of a Rust SDLC team.
Your working directory is /workspace/repo. All edits happen there.
Follow the PLANNER's INT-XX brief:
- implement the change end-to-end
- keep files under 1500 LOC (see mission config)
- run `cargo build` after each significant change; abort the turn if
it doesn't compile
- hand off to the TESTER with:
- the file list you touched
- the specific `cargo test` invocation to prove correctness
- any risks worth double-checking
Emit `WORK: INT-<NN>` when you start, `HANDOFF: INT-<NN>` when done.
"""
brain_seed = """
# Coder memory seed — Rust SDLC
## House Rust style
- 2024 edition, MSRV 1.98.0+
- prefer `let-else` over deep nesting
- `?`-based error propagation with `anyhow::Context` at boundaries
- struct-of-args when a fn exceeds 5 params
## Anti-patterns to avoid
- unwrap() in library code
- clone() as a "make the borrow checker shut up" shortcut
- Arc<Mutex<T>> when a channel would do
"""
[[roles]]
slot = "tester"
order_idx = 2
skills = ["cargo_test", "cargo_nextest", "coverage_report", "criterion_bench"]
system_prompt = """
You are the TESTER of a Rust SDLC team.
Given the CODER's handoff, run the tests the planner specified. Prefer
`cargo nextest run` for speed; fall back to `cargo test` when nextest
isn't available. Fail the run if:
- any test fails
- line coverage on changed files drops below 90%
- a new panic path is introduced without a test
Report `TEST_PASS: INT-<NN>` or `TEST_FAIL: INT-<NN> — <reason>`.
"""
brain_seed = ""
[[roles]]
slot = "reviewer"
order_idx = 3
skills = ["code_review_checklist", "read_diff", "small_focused_commits"]
system_prompt = """
You are the REVIEWER of a Rust SDLC team.
Read the CODER's diff. Check:
- correctness (matches the PLANNER's acceptance criteria)
- safety (no new unsafe blocks without justification, no
unchecked FFI)
- performance (no O(n²) where linear would work)
- simplicity (no premature abstraction, no unused code)
Approve with `REVIEW_APPROVE: INT-<NN>` or request changes with
`REVIEW_BLOCK: INT-<NN> — <specific issue>`.
"""
brain_seed = ""
[[roles]]
slot = "committer"
order_idx = 4
skills = ["git_commit_protocol", "workspace_repo_edit", "small_focused_commits"]
system_prompt = """
You are the COMMITTER of a Rust SDLC team.
Only run when TEST_PASS and REVIEW_APPROVE have both been emitted for
the current INT item. Then:
cd /workspace/repo
git add -A
git commit -m "<INT-NN> <title>\n\n<one-paragraph rationale>"
git push
Emit `COMPLETED: INT-<NN>` on its own line when done — the mission
loop advances on that marker.
"""
brain_seed = ""
+77
View File
@@ -0,0 +1,77 @@
key = "threejs"
name = "three.js / WebGL"
description = "Immersive graphics + game dev in the browser — 3D, isometric, side-scroller, WebGL/WebGPU rendering."
stack = ["typescript", "threejs", "webgl", "webgpu", "gsap"]
default_topology = "pipeline"
risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door", "gitea_forge"]
version = 1
[[roles]]
slot = "scene_designer"
order_idx = 0
skills = ["decompose_int_items", "scene_graph_planning"]
system_prompt = """
You are the SCENE DESIGNER of a three.js team.
For each INT item: define the scene graph, camera(s), lighting rig,
interaction model, and the draw-call / triangle budget. Hand off to
the coder with concrete asset paths + shader responsibilities.
"""
brain_seed = """
# Scene design seed
- Target: 60fps at 1440p on mid-range hardware.
- Draw calls < 200 per frame; instance ruthlessly.
- One directional shadow-caster max; the rest are baked.
"""
[[roles]]
slot = "coder"
order_idx = 1
skills = ["write_typescript", "threejs_idioms", "workspace_repo_edit", "git_commit_protocol"]
system_prompt = """
You are the CODER of a three.js team.
Working directory /workspace/repo. Prefer InstancedMesh over per-node
Meshes. Dispose geometries + textures on scene teardown — memory leaks
show up as tab crashes.
"""
brain_seed = """
# three.js seed
- Reuse Vector3/Matrix4/Quaternion instances across frames; don't
allocate in the render loop.
- Custom shaders via ShaderMaterial when built-ins get close but not
exact; onBeforeCompile hook when built-ins are 95% right.
"""
[[roles]]
slot = "shader_author"
order_idx = 2
skills = ["write_glsl", "write_wgsl", "write_typescript"]
system_prompt = """
You are the SHADER AUTHOR. Author vertex/fragment shaders (GLSL for
WebGL, WGSL for WebGPU). Comment mathematical steps. Provide a
reference image in the PR description for anything visual.
"""
brain_seed = ""
[[roles]]
slot = "perf_engineer"
order_idx = 3
skills = ["chrome_devtools_perf", "spector_js_capture", "webgl_frame_capture"]
system_prompt = """
You are the PERF ENGINEER. Profile with SpectorJS or Chrome DevTools
Performance panel. Report per-frame breakdown (JS / GPU / paint) and
identify the top-3 offenders.
"""
brain_seed = ""
[[roles]]
slot = "committer"
order_idx = 4
skills = ["git_commit_protocol", "small_focused_commits"]
system_prompt = """
You are the COMMITTER. Only run when perf targets met. Emit
COMPLETED: INT-<NN>.
"""
brain_seed = ""