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
@@ -12,7 +12,7 @@
// the template's canned phase composition; Slice 4 threads the TOML
// 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 {
@@ -23,6 +23,10 @@ import {
type TemplateKind,
type TemplatePreset,
} from "@/lib/api/missions";
import {
listTeamTemplates,
type TeamTemplate,
} from "@/lib/api/team-templates";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
const mono =
@@ -42,7 +46,17 @@ export function MissionWizard({
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
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 [cron, setCron] = useState("0 */6 * * *");
const [submitting, setSubmitting] = useState(false);
@@ -71,7 +85,7 @@ export function MissionWizard({
title: title.trim(),
template_kind: templateKind,
repo_id: repo?.repo_id,
team_id: teamId || undefined,
team_template_id: teamTemplateId || undefined,
schedule,
description: description.trim() || undefined,
phases: preset.phases,
@@ -268,18 +282,91 @@ export function MissionWizard({
{step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Team</span>
<span style={labelStyle}>Team template</span>
<p style={hintStyle}>
Slice 3 ships team templates + auto-provision. For now, paste an
existing team id or leave blank to let phase execution
auto-provision one from the description.
Pick a canonical roster. On mission launch the team is
materialized from the template roles, prompts, MCP bundles,
and (later) skills + brain seeds. Leave unset to let the
mission auto-provision an LLM-derived team from the prompt.
</p>
<input
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
placeholder="optional team UUID"
style={fieldStyle}
/>
<div style={{ display: "grid", gap: 8 }}>
<button
type="button"
onClick={() => setTeamTemplateId("")}
style={{
...templateCardStyle(teamTemplateId === ""),
}}
>
<span style={{ fontWeight: 700, color: "#f3f3f5" }}>
LLM auto-provision
</span>
<span style={{ fontSize: 12, color: "#a0a0a8" }}>
Let Claude Sonnet 5 derive 35 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>
)}
@@ -330,7 +417,15 @@ export function MissionWizard({
<ReviewRow k="Title" v={title} />
{description && <ReviewRow k="Description" v={description} />}
{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
k="Schedule"
v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"}
@@ -458,6 +553,18 @@ const secondaryBtn: React.CSSProperties = {
fontSize: 12.5,
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 => ({
display: "flex",
alignItems: "flex-start",