slice 2: MissionWizard + MissionCanvas + MissionsList frontend
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m10s
ci / e2e (push) Skipped
ci / publish (push) Successful in 4m10s

Adds the unified missions tier to the dashboard. Runs in parallel
with Research + Loops tabs until Slice 9's big-bang cutover.

New files:
  - lib/api/missions.ts       — typed API client + 5 template presets
                                (research_only, research_and_code,
                                security_hardening, refactor, benchmark)
  - dashboard/MissionsList.tsx — sidebar list with status pills +
                                template-kind badges + new-mission CTA
  - dashboard/MissionWizard.tsx — 5-step adaptive wizard:
      1) template picker (5 cards)
      2) title/description + repo (when required)
      3) team (placeholder — Slice 3 wires templates + auto-provision)
      4) schedule (one-shot or cron)
      5) review + launch
  - dashboard/MissionCanvas.tsx — 4-tab detail view (overview/phases/
                                tasks/artifacts), draft→running launch

Dashboard.tsx gets a new "missions" tier + crumb + rail icon (flag).

Slice 2 ships a minimal implementation that creates missions with the
template's canned phase composition. Slice 4 replaces the preset table
with real TOML-recipe dispatch on the server; the client's fallback
presets keep offline preview working.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 12:19:19 -07:00
co-authored by Claude Opus 4.7
parent a72da9dff0
commit fc67936e33
5 changed files with 1587 additions and 2 deletions
@@ -27,6 +27,8 @@ import { ResearchList } from "./ResearchList";
import { ResearchCanvas } from "./ResearchCanvas"; import { ResearchCanvas } from "./ResearchCanvas";
import { LoopsList } from "./LoopsList"; import { LoopsList } from "./LoopsList";
import { LoopsCanvas } from "./LoopsCanvas"; import { LoopsCanvas } from "./LoopsCanvas";
import { MissionsList } from "./MissionsList";
import { MissionCanvas } from "./MissionCanvas";
import { RepoList } from "./RepoList"; import { RepoList } from "./RepoList";
import { RepoCanvas } from "./RepoCanvas"; import { RepoCanvas } from "./RepoCanvas";
import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub"; import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub";
@@ -67,7 +69,7 @@ function readSidebarCollapse(): boolean {
} }
} }
type Tier = "world" | "research" | "loops" | "claw" | "repos" | "infra"; type Tier = "world" | "research" | "loops" | "missions" | "claw" | "repos" | "infra";
const mono = "'JetBrains Mono', ui-monospace, monospace"; const mono = "'JetBrains Mono', ui-monospace, monospace";
// dashboard-data.ts fabricates a few org/company/team nodes so an empty // dashboard-data.ts fabricates a few org/company/team nodes so an empty
@@ -117,6 +119,8 @@ const railIcon: Record<Tier, React.ReactNode> = {
research: (<svg width="20" height="20" viewBox="0 0 24 24"><rect x="4" y="3.5" width="12" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><path d="M4 15.5 H16" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="17.5" cy="17.5" r="3.2" stroke="currentColor" strokeWidth="1.6" fill="none" /><path d="M19.9 19.9 L22 22" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>), research: (<svg width="20" height="20" viewBox="0 0 24 24"><rect x="4" y="3.5" width="12" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><path d="M4 15.5 H16" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="17.5" cy="17.5" r="3.2" stroke="currentColor" strokeWidth="1.6" fill="none" /><path d="M19.9 19.9 L22 22" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>),
// Circular arrow ⟳ with an inner dot — loops. // Circular arrow ⟳ with an inner dot — loops.
loops: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 12 A8 8 0 1 1 12 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /><path d="M13 3 L20 4 L18.5 10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinejoin="round" strokeLinecap="round" /><circle cx="12" cy="12" r="1.8" fill="currentColor" /></svg>), loops: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 12 A8 8 0 1 1 12 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /><path d="M13 3 L20 4 L18.5 10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinejoin="round" strokeLinecap="round" /><circle cx="12" cy="12" r="1.8" fill="currentColor" /></svg>),
// Flag on a pole — missions.
missions: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M6 3 V21" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /><path d="M6 4 L18 4 L15 8 L18 12 L6 12 Z" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinejoin="round" /></svg>),
claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>), claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>),
// Branching lines forking off a trunk — repositories. // Branching lines forking off a trunk — repositories.
repos: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="5" cy="5" r="1.6" fill="currentColor" /><circle cx="5" cy="15" r="1.6" fill="currentColor" /><circle cx="15" cy="10" r="1.6" fill="currentColor" /><path d="M5 6.6 V13.4 M5 10 C5 10 8 10 15 10" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>), repos: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="5" cy="5" r="1.6" fill="currentColor" /><circle cx="5" cy="15" r="1.6" fill="currentColor" /><circle cx="15" cy="10" r="1.6" fill="currentColor" /><path d="M5 6.6 V13.4 M5 10 C5 10 8 10 15 10" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>),
@@ -538,9 +542,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
const isWorld = tier === "world", const isWorld = tier === "world",
isResearch = tier === "research", isResearch = tier === "research",
isLoops = tier === "loops", isLoops = tier === "loops",
isMissions = tier === "missions",
isClaw = tier === "claw", isClaw = tier === "claw",
isRepos = tier === "repos", isRepos = tier === "repos",
isInfra = tier === "infra"; isInfra = tier === "infra";
const [missionsSel, setMissionsSel] = useState<string | null>(null);
const [missionsRefresh, setMissionsRefresh] = useState(0);
const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents))); const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents)));
// World: the full expandable org→company→team→agent forest. Agents page: a flat list. // World: the full expandable org→company→team→agent forest. Agents page: a flat list.
@@ -634,6 +641,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
<span style={crumbStyle(isResearch)} onClick={() => setTier("research")}>Research</span> <span style={crumbStyle(isResearch)} onClick={() => setTier("research")}>Research</span>
<span style={{ color: "#3a3a40" }}>/</span> <span style={{ color: "#3a3a40" }}>/</span>
<span style={crumbStyle(isLoops)} onClick={() => setTier("loops")}>Loops</span> <span style={crumbStyle(isLoops)} onClick={() => setTier("loops")}>Loops</span>
<span style={crumbStyle(isMissions)} onClick={() => setTier("missions")}>Missions</span>
<span style={{ color: "#3a3a40" }}>/</span> <span style={{ color: "#3a3a40" }}>/</span>
<span style={crumbStyle(isClaw)} onClick={() => setTier("claw")}>Agents</span> <span style={crumbStyle(isClaw)} onClick={() => setTier("claw")}>Agents</span>
<span style={{ color: "#3a3a40" }}>/</span> <span style={{ color: "#3a3a40" }}>/</span>
@@ -706,7 +714,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button> <button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
</div> </div>
) : isInfra || isResearch || isLoops || isRepos ? null : ( ) : isInfra || isResearch || isLoops || isMissions || isRepos ? null : (
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}> <div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}>
{/* Title on the left; toolbar (wrench / history / brain) sits {/* Title on the left; toolbar (wrench / history / brain) sits
flush right at the same baseline. The prior "N orgs · N co flush right at the same baseline. The prior "N orgs · N co
@@ -746,6 +754,16 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
setLoopsRefresh((n) => n + 1); setLoopsRefresh((n) => n + 1);
}} }}
/> />
) : isMissions ? (
<MissionsList
selectedId={missionsSel}
onSelect={setMissionsSel}
refreshKey={missionsRefresh}
onCreated={(id) => {
setMissionsSel(id);
setMissionsRefresh((n) => n + 1);
}}
/>
) : isRepos ? ( ) : isRepos ? (
<RepoList <RepoList
selectedId={repoSel} selectedId={repoSel}
@@ -842,6 +860,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
setLoopsRefresh((n) => n + 1); setLoopsRefresh((n) => n + 1);
}} }}
/> />
) : isMissions ? (
<MissionCanvas
selectedId={missionsSel}
refreshKey={missionsRefresh}
onChanged={() => setMissionsRefresh((n) => n + 1)}
/>
) : isRepos ? ( ) : isRepos ? (
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} /> <RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
) : isWorld ? ( ) : isWorld ? (
@@ -0,0 +1,581 @@
"use client";
// MissionCanvas — unified detail view for the missions tier. Tabs:
// Overview · title, description, template kind, team, repo, schedule
// Phases · ordered timeline of phase kinds + statuses
// Tasks · INT-XX cards (Slice 5 fills these; Slice 2 renders shell)
// Artifacts · MD/PDF/benchmark/security files with PDF-render status
//
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
import { useCallback, useEffect, useMemo, useState } from "react";
import { FileText, Play, RefreshCw } from "lucide-react";
import {
getMission,
setMissionStatus,
type MissionDetail,
type MissionStatus,
type PhaseKind,
type PhaseStatus,
type TaskStatus,
type TemplateKind,
} from "@/lib/api/missions";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const STATUS_COLOR: Record<MissionStatus, string> = {
draft: "#8a8a92",
running: "#5ec8d8",
completed: "#5fd08a",
failed: "#ff8a7a",
cancelled: "#6a6a72",
};
const PHASE_STATUS_COLOR: Record<PhaseStatus, string> = {
pending: "#6a6a72",
running: "#5ec8d8",
completed: "#5fd08a",
failed: "#ff8a7a",
skipped: "#8a8a92",
};
const TASK_STATUS_COLOR: Record<TaskStatus, string> = {
created: "#8a8a92",
working: "#5ec8d8",
validating: "#ffb44a",
complete: "#5fd08a",
failed: "#ff8a7a",
};
const PHASE_LABEL: Record<PhaseKind, string> = {
research: "Research",
coding: "Coding",
benchmark: "Benchmark",
security_scan: "Security scan",
};
const TEMPLATE_LABEL: Record<TemplateKind, string> = {
research_only: "Research only",
research_and_code: "Research + Coding Loop",
security_hardening: "Security Hardening",
refactor: "Refactor",
benchmark: "Benchmark",
custom: "Custom",
};
type Tab = "overview" | "phases" | "tasks" | "artifacts";
export function MissionCanvas({
selectedId,
refreshKey,
onChanged,
}: {
selectedId: string | null;
refreshKey: number;
onChanged: () => void;
}) {
const [mission, setMission] = useState<MissionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
const [launching, setLaunching] = useState(false);
const load = useCallback(async () => {
if (!selectedId) {
setMission(null);
return;
}
setLoading(true);
setError(null);
try {
const m = await getMission(selectedId);
setMission(m);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
} finally {
setLoading(false);
}
}, [selectedId]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load, refreshKey]);
const launch = useCallback(async () => {
if (!mission) return;
setLaunching(true);
try {
await setMissionStatus(mission.id, "running");
onChanged();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "launch failed");
} finally {
setLaunching(false);
}
}, [mission, onChanged, load]);
const orderedPhases = useMemo(
() =>
mission
? [...mission.phases].sort((a, b) => a.order_idx - b.order_idx)
: [],
[mission],
);
if (!selectedId) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#6a6a72",
fontSize: 13,
padding: 24,
textAlign: "center",
}}
>
Pick a mission from the left, or hit + to create one.
</div>
);
}
if (loading && !mission) {
return (
<div style={{ padding: 20, fontFamily: mono, color: "#5ec8d8" }}>
Loading…
</div>
);
}
if (error) {
return (
<div style={{ padding: 20, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
);
}
if (!mission) return null;
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
<div
style={{
flex: "none",
padding: "16px 22px 12px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
style={{
width: 9,
height: 9,
borderRadius: "50%",
background: STATUS_COLOR[mission.status],
}}
/>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".14em",
color: STATUS_COLOR[mission.status],
textTransform: "uppercase",
}}
>
{mission.status}
</span>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".12em",
color: "#7cd6e0",
marginLeft: 8,
}}
>
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
</span>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
<button
type="button"
onClick={load}
title="Refresh"
aria-label="Refresh"
style={iconBtn}
>
<RefreshCw size={13} />
</button>
{mission.status === "draft" && (
<button
type="button"
onClick={launch}
disabled={launching}
style={{
...primaryBtn,
opacity: launching ? 0.5 : 1,
}}
>
<Play size={13} style={{ marginRight: 4 }} />
{launching ? "Launching…" : "Launch"}
</button>
)}
</div>
</div>
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
{mission.description && (
<p style={{ margin: 0, fontSize: 13, color: "#cfcfd5", lineHeight: 1.5 }}>
{mission.description}
</p>
)}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
{(["overview", "phases", "tasks", "artifacts"] as Tab[]).map((t) => {
const active = tab === t;
const badge =
t === "tasks"
? mission.tasks.length
: t === "artifacts"
? mission.artifacts.length
: t === "phases"
? mission.phases.length
: null;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
style={{
padding: "5px 12px",
borderRadius: 8,
border: `1px solid ${active ? "rgba(255,138,122,.5)" : "rgba(255,255,255,.08)"}`,
background: active ? "rgba(255,138,122,.08)" : "transparent",
color: active ? "#ff8a7a" : "#a0a0a8",
fontFamily: mono,
fontSize: 11,
letterSpacing: ".08em",
textTransform: "uppercase",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{t}
{badge !== null && (
<span
style={{
fontSize: 10,
color: active ? "#ff8a7a" : "#8a8a92",
}}
>
{badge}
</span>
)}
</button>
);
})}
</div>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
{tab === "overview" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<FieldRow k="Template" v={TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind} />
<FieldRow k="Status" v={mission.status} />
<FieldRow k="Team" v={mission.team_id ?? "(auto-provision on launch)"} />
<FieldRow k="Repo" v={mission.repo_id ?? "—"} />
<FieldRow
k="Schedule"
v={
mission.schedule.kind === "cron"
? `cron: ${mission.schedule.cron ?? "?"}`
: mission.schedule.kind === "on_event"
? `on: ${mission.schedule.event ?? "?"}`
: "one-shot"
}
/>
<FieldRow k="Created" v={new Date(mission.created_at).toLocaleString()} />
{mission.completed_at && (
<FieldRow k="Completed" v={new Date(mission.completed_at).toLocaleString()} />
)}
</div>
)}
{tab === "phases" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{orderedPhases.length === 0 ? (
<Empty label="no phases" />
) : (
orderedPhases.map((p) => (
<div
key={p.id}
style={{
padding: 12,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: PHASE_STATUS_COLOR[p.status],
}}
/>
<span
style={{
fontFamily: mono,
fontSize: 9,
color: PHASE_STATUS_COLOR[p.status],
textTransform: "uppercase",
}}
>
{p.status}
</span>
<span
style={{
fontSize: 13.5,
fontWeight: 700,
color: "#f3f3f5",
marginLeft: 8,
}}
>
#{p.order_idx + 1} · {PHASE_LABEL[p.kind] ?? p.kind}
</span>
</div>
{(p.started_at || p.completed_at) && (
<span style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{p.started_at ? `started ${new Date(p.started_at).toLocaleTimeString()}` : "—"}
{p.completed_at
? ` · finished ${new Date(p.completed_at).toLocaleTimeString()}`
: ""}
</span>
)}
</div>
))
)}
</div>
)}
{tab === "tasks" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.tasks.length === 0 ? (
<Empty label="no tasks yet — task-card parser lands in Slice 5" />
) : (
mission.tasks.map((t) => (
<div
key={t.id}
style={{
padding: 11,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: TASK_STATUS_COLOR[t.status],
}}
/>
<span
style={{
fontFamily: mono,
fontSize: 9,
color: TASK_STATUS_COLOR[t.status],
textTransform: "uppercase",
}}
>
{t.status}
</span>
{t.external_id && (
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: "#7cd6e0",
}}
>
{t.external_id}
</span>
)}
<span
style={{
fontSize: 13,
color: "#f3f3f5",
marginLeft: 4,
}}
>
{t.title}
</span>
</div>
{t.artifact_paths.length > 0 && (
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
{t.artifact_paths.join(" · ")}
</div>
)}
</div>
))
)}
</div>
)}
{tab === "artifacts" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.artifacts.length === 0 ? (
<Empty label="no artifacts yet — phases produce them as they run" />
) : (
mission.artifacts.map((a) => (
<div
key={a.id}
style={{
padding: 11,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<FileText size={16} style={{ color: "#7cd6e0", flex: "none" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
{a.title ?? a.path.split("/").pop() ?? a.path}
</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
{a.kind} · {a.path}
</div>
</div>
{a.rendered_pdf_path ? (
<a
href={a.rendered_pdf_path}
target="_blank"
rel="noreferrer"
style={{
...secondaryBtn,
padding: "5px 10px",
fontSize: 11,
textDecoration: "none",
}}
>
Open PDF
</a>
) : a.render_pdf_status !== "skip" ? (
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color:
a.render_pdf_status === "failed"
? "#ff8a7a"
: "#8a8a92",
}}
>
pdf: {a.render_pdf_status}
</span>
) : null}
</div>
))
)}
</div>
)}
</div>
</div>
);
}
function FieldRow({ k, v }: { k: string; v: string }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "140px 1fr",
gap: 12,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.06)",
background: "#101014",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".1em",
color: "#8a8a92",
textTransform: "uppercase",
}}
>
{k}
</span>
<span style={{ fontSize: 12.5, color: "#eaeaee", wordBreak: "break-word" }}>
{v}
</span>
</div>
);
}
function Empty({ label }: { label: string }) {
return (
<div
style={{
padding: 24,
textAlign: "center",
color: "#6a6a72",
fontSize: 12.5,
fontFamily: mono,
}}
>
{label}
</div>
);
}
const iconBtn: React.CSSProperties = {
width: 30,
height: 30,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
};
const primaryBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "6px 12px",
borderRadius: 8,
border: 0,
background: "#ff8a7a",
color: "#1a0d0b",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
padding: "6px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#d7d7db",
fontSize: 12,
cursor: "pointer",
};
@@ -0,0 +1,470 @@
"use client";
// MissionWizard — unified 5-step wizard replacing ResearchWizard +
// LoopsWizard.
// 1. Template — pick one of the 5 workflow templates (or custom)
// 2. Target — repo picker (when template requires) + subject/prompt
// 3. Team — bind existing team (later slices: team template + auto-provision)
// 4. Schedule — one-shot / cron / on-event
// 5. Review — plan preview + launch
//
// Slice 2 ships a minimal implementation that creates a mission with
// 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 { X } from "lucide-react";
import {
createMission,
presetForKind,
TEMPLATE_PRESETS,
type Schedule,
type TemplateKind,
type TemplatePreset,
} from "@/lib/api/missions";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
type Step = 1 | 2 | 3 | 4 | 5;
export function MissionWizard({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (id: string) => void;
}) {
const [step, setStep] = useState<Step>(1);
const [templateKind, setTemplateKind] = useState<TemplateKind>("research_only");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [teamId, setTeamId] = useState<string>("");
const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot");
const [cron, setCron] = useState("0 */6 * * *");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const preset: TemplatePreset = useMemo(
() => presetForKind(templateKind) ?? TEMPLATE_PRESETS[0],
[templateKind],
);
const canNext =
(step === 1 && !!templateKind) ||
(step === 2 &&
title.trim().length > 0 &&
(!preset.requiresRepo || repo !== null)) ||
step === 3 ||
step === 4;
async function submit() {
setError(null);
setSubmitting(true);
try {
const schedule: Schedule =
scheduleKind === "cron" ? { kind: "cron", cron } : { kind: "one_shot" };
const created = await createMission({
title: title.trim(),
template_kind: templateKind,
repo_id: repo?.repo_id,
team_id: teamId || undefined,
schedule,
description: description.trim() || undefined,
phases: preset.phases,
});
onCreated(created.id);
} catch (e) {
setError(e instanceof Error ? e.message : "create failed");
} finally {
setSubmitting(false);
}
}
return (
<div
onClick={onClose}
role="presentation"
style={{
position: "fixed",
inset: 0,
zIndex: 110,
background: "rgba(0,0,0,.62)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
animation: "cm-fade .18s ease",
}}
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="New mission"
style={{
width: "100%",
maxWidth: 720,
maxHeight: "88vh",
display: "flex",
flexDirection: "column",
borderRadius: 16,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
boxShadow: "0 30px 90px rgba(0,0,0,.6)",
overflow: "hidden",
animation: "scale-in .18s ease",
}}
>
<div
style={{
flex: "none",
display: "flex",
alignItems: "center",
gap: 11,
padding: "16px 20px",
borderBottom: "1px solid rgba(255,255,255,.07)",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color: "#5ec8d8",
}}
>
STEP {step} / 5
</span>
<span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5" }}>
New mission
</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
marginLeft: "auto",
width: 28,
height: 28,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
}}
>
<X aria-hidden size={14} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Template</span>
<p style={hintStyle}>
Pick the workflow shape. This decides which phases run and what
the coding/research agents are asked to do.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr",
gap: 8,
marginTop: 4,
}}
>
{TEMPLATE_PRESETS.map((p) => {
const active = p.kind === templateKind;
return (
<button
key={p.kind}
type="button"
onClick={() => setTemplateKind(p.kind)}
style={{
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: 4,
}}
>
<span
style={{
fontWeight: 700,
fontSize: 13.5,
color: "#f3f3f5",
}}
>
{p.title}
</span>
<span style={{ fontSize: 12, color: "#a0a0a8", lineHeight: 1.5 }}>
{p.blurb}
</span>
<span
style={{
fontFamily: mono,
fontSize: 10,
color: "#7cd6e0",
marginTop: 4,
}}
>
{p.phases.map((ph) => ph.kind).join(" → ")}
{p.requiresRepo ? " · needs repo" : ""}
</span>
</button>
);
})}
</div>
</div>
)}
{step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label style={labelStyle} htmlFor="mission-title">
Title
</label>
<input
id="mission-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. ClawHDF5 spec — memory engine v3"
style={fieldStyle}
/>
<label style={labelStyle} htmlFor="mission-desc">
Description / prompt
</label>
<textarea
id="mission-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
placeholder="What should the mission accomplish? The template's agents will use this as their driving prompt."
style={{ ...fieldStyle, resize: "vertical", fontFamily: "inherit" }}
/>
{preset.requiresRepo && (
<>
<span style={labelStyle}>Repository</span>
<p style={hintStyle}>
{preset.title} needs a repo bound so agents can read + write
/workspace/repo.
</p>
<RepoPicker value={repo} onChange={setRepo} />
</>
)}
</div>
)}
{step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Team</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.
</p>
<input
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
placeholder="optional team UUID"
style={fieldStyle}
/>
</div>
)}
{step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Schedule</span>
<label style={radioRowStyle(scheduleKind === "one_shot")}>
<input
type="radio"
checked={scheduleKind === "one_shot"}
onChange={() => setScheduleKind("one_shot")}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>One-shot</div>
<div style={hintStyle}>Runs once on launch. Simplest.</div>
</div>
</label>
<label style={radioRowStyle(scheduleKind === "cron")}>
<input
type="radio"
checked={scheduleKind === "cron"}
onChange={() => setScheduleKind("cron")}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>Cron</div>
<div style={hintStyle}>
Fires on schedule. Coding phases re-check the repo and pick
up where they left off.
</div>
{scheduleKind === "cron" && (
<input
type="text"
value={cron}
onChange={(e) => setCron(e.target.value)}
placeholder="0 */6 * * *"
style={{ ...fieldStyle, marginTop: 6 }}
/>
)}
</div>
</label>
</div>
)}
{step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Review</span>
<ReviewRow k="Template" v={preset.title} />
<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="Schedule"
v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"}
/>
<ReviewRow k="Phases" v={preset.phases.map((p) => p.kind).join(" → ")} />
{error && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>
)}
</div>
)}
</div>
<div
style={{
padding: 14,
borderTop: "1px solid rgba(255,255,255,.06)",
display: "flex",
gap: 8,
justifyContent: "space-between",
}}
>
<button
type="button"
onClick={() => setStep((s) => Math.max(1, s - 1) as Step)}
disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
>
Back
</button>
{step < 5 ? (
<button
type="button"
onClick={() => setStep((s) => Math.min(5, s + 1) as Step)}
disabled={!canNext}
style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }}
>
Next
</button>
) : (
<button
type="button"
onClick={submit}
disabled={submitting}
style={{ ...primaryBtn, opacity: submitting ? 0.5 : 1 }}
>
{submitting ? "Creating…" : "Launch mission"}
</button>
)}
</div>
</div>
</div>
);
}
function ReviewRow({ k, v }: { k: string; v: string }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "120px 1fr",
gap: 12,
padding: "8px 10px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.06)",
background: "#101014",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
textTransform: "uppercase",
letterSpacing: ".08em",
}}
>
{k}
</span>
<span style={{ fontSize: 12.5, color: "#eaeaee", wordBreak: "break-word" }}>
{v}
</span>
</div>
);
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color: "#8a8a92",
textTransform: "uppercase",
};
const hintStyle: React.CSSProperties = {
fontSize: 12,
color: "#a0a0a8",
lineHeight: 1.55,
};
const fieldStyle: React.CSSProperties = {
width: "100%",
boxSizing: "border-box",
padding: "8px 10px",
borderRadius: 9,
border: "1px solid rgba(255,255,255,.12)",
background: "#141417",
color: "#eaeaee",
fontSize: 13,
};
const primaryBtn: React.CSSProperties = {
padding: "8px 14px",
borderRadius: 9,
border: 0,
background: "#ff8a7a",
color: "#1a0d0b",
fontSize: 12.5,
fontWeight: 700,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
padding: "8px 14px",
borderRadius: 9,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#d7d7db",
fontSize: 12.5,
cursor: "pointer",
};
const radioRowStyle = (active: boolean): React.CSSProperties => ({
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${active ? "rgba(94,200,216,.4)" : "rgba(255,255,255,.08)"}`,
background: active ? "rgba(94,200,216,.05)" : "#101014",
cursor: "pointer",
});
@@ -0,0 +1,274 @@
"use client";
// Sidebar for the Missions tier — the unified list that replaces
// ResearchList + LoopsList after Slice 9's cutover. During Slice 2 it
// runs in parallel with the old two lists so we can iterate on the
// UX without breaking existing workflows.
import { useCallback, useEffect, useState } from "react";
import { Plus, RotateCw } from "lucide-react";
import {
listMissions,
type Mission,
type MissionStatus,
type TemplateKind,
} from "@/lib/api/missions";
import { MissionWizard } from "./MissionWizard";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const STATUS_COLOR: Record<MissionStatus, string> = {
draft: "#8a8a92",
running: "#5ec8d8",
completed: "#5fd08a",
failed: "#ff8a7a",
cancelled: "#6a6a72",
};
const TEMPLATE_BADGE: Record<TemplateKind, { label: string; color: string }> = {
research_only: { label: "RESEARCH", color: "#7cd6e0" },
research_and_code: { label: "R+CODE", color: "#c98af0" },
security_hardening: { label: "SECURITY", color: "#ff8a7a" },
refactor: { label: "REFACTOR", color: "#ffb44a" },
benchmark: { label: "BENCHMARK", color: "#83e6a5" },
custom: { label: "CUSTOM", color: "#8a8a92" },
};
export function MissionsList({
selectedId,
onSelect,
refreshKey,
onCreated,
}: {
selectedId: string | null;
onSelect: (id: string) => void;
refreshKey: number;
onCreated: (id: string) => void;
}) {
const [missions, setMissions] = useState<Mission[]>([]);
const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setError(null);
try {
const rows = await listMissions(100);
setMissions(rows);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let alive = true;
(async () => {
if (alive) await load();
})();
return () => {
alive = false;
};
}, [load, refreshKey]);
return (
<>
<div
style={{
flex: "none",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 14px 8px",
gap: 8,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".16em",
color: "#8a8a92",
}}
>
MISSIONS · {missions.length}
</span>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={load}
title="Refresh"
aria-label="Refresh"
style={iconBtn}
>
<RotateCw size={13} />
</button>
<button
type="button"
onClick={() => setWizardOpen(true)}
title="New mission"
aria-label="New mission"
style={{
...iconBtn,
color: "#ff8a7a",
borderColor: "rgba(255,138,122,.5)",
}}
>
<Plus size={13} />
</button>
</div>
</div>
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
padding: "4px 10px 12px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{loading ? (
<div style={{ fontFamily: mono, fontSize: 11, color: "#5ec8d8", padding: 8 }}>
Loading…
</div>
) : error ? (
<div style={{ fontSize: 12, color: "#ff8a7a", padding: 8 }}>{error}</div>
) : missions.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 10,
padding: 12,
alignItems: "flex-start",
}}
>
<span style={{ fontSize: 12.5, color: "#cfcfd5", lineHeight: 1.5 }}>
No missions yet. Pick a workflow template to get started.
</span>
<button
type="button"
onClick={() => setWizardOpen(true)}
style={ctaBtn}
>
<Plus size={13} /> New mission
</button>
</div>
) : (
missions.map((m) => {
const active = selectedId === m.id;
const badge = TEMPLATE_BADGE[m.template_kind] ?? TEMPLATE_BADGE.custom;
return (
<button
key={m.id}
type="button"
onClick={() => onSelect(m.id)}
style={{
width: "100%",
textAlign: "left",
display: "flex",
flexDirection: "column",
gap: 4,
padding: "10px 11px",
borderRadius: 9,
border: `1px solid ${active ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.07)"}`,
background: active ? "rgba(94,200,216,.08)" : "#101013",
cursor: "pointer",
color: "#eaeaee",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: STATUS_COLOR[m.status],
flex: "none",
}}
/>
<span
style={{
fontFamily: mono,
fontSize: 9,
color: STATUS_COLOR[m.status],
textTransform: "uppercase",
}}
>
{m.status}
</span>
<span
style={{
marginLeft: "auto",
fontFamily: mono,
fontSize: 9,
color: badge.color,
}}
>
{badge.label}
</span>
</div>
<div
style={{
fontSize: 13,
color: "#f3f3f5",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
}}
>
{m.title}
</div>
</button>
);
})
)}
</div>
{wizardOpen && (
<MissionWizard
onClose={() => setWizardOpen(false)}
onCreated={(id) => {
setWizardOpen(false);
onCreated(id);
void load();
}}
/>
)}
</>
);
}
const iconBtn: React.CSSProperties = {
width: 26,
height: 26,
borderRadius: 7,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
};
const ctaBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
borderRadius: 9,
border: 0,
background: "#ff8a7a",
color: "#1a0d0b",
fontSize: 12.5,
fontWeight: 700,
cursor: "pointer",
};
+236
View File
@@ -0,0 +1,236 @@
// Missions API client — the unified workflow surface.
// Slice 2: create / list / get / status. Later slices layer
// template-driven behavior + task/artifact/benchmark rendering.
export type TemplateKind =
| "research_only"
| "research_and_code"
| "security_hardening"
| "refactor"
| "benchmark"
| "custom";
export type MissionStatus =
| "draft"
| "running"
| "completed"
| "failed"
| "cancelled";
export type PhaseKind = "research" | "coding" | "benchmark" | "security_scan";
export type PhaseStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "skipped";
export type TaskStatus =
| "created"
| "working"
| "validating"
| "complete"
| "failed";
export type ArtifactKind =
| "md"
| "pdf"
| "benchmark_result"
| "security_report"
| "code_diff"
| "index";
export type ScheduleKind = "one_shot" | "cron" | "on_event";
export interface Schedule {
kind: ScheduleKind;
cron?: string;
event?: string;
}
export interface Mission {
id: string;
workspace_id: string;
title: string;
template_kind: TemplateKind;
team_id: string | null;
team_template_id: string | null;
repo_id: string | null;
schedule: Schedule;
status: MissionStatus;
description: string | null;
config: Record<string, unknown>;
created_at: string;
updated_at: string;
completed_at: string | null;
}
export interface MissionPhase {
id: string;
mission_id: string;
kind: PhaseKind;
order_idx: number;
status: PhaseStatus;
config: Record<string, unknown>;
started_at: string | null;
completed_at: string | null;
}
export interface MissionTask {
id: string;
mission_id: string;
phase_id: string;
external_id: string | null;
title: string;
assigned_agent_id: string | null;
status: TaskStatus;
run_id: string | null;
artifact_paths: string[];
created_at: string;
updated_at: string;
completed_at: string | null;
}
export interface MissionArtifact {
id: string;
mission_id: string;
phase_id: string | null;
path: string;
kind: ArtifactKind;
mime: string | null;
generated_by_run: string | null;
rendered_pdf_path: string | null;
render_pdf_status: "skip" | "pending" | "rendering" | "done" | "failed";
render_pdf_error: string | null;
title: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface MissionDetail extends Mission {
phases: MissionPhase[];
tasks: MissionTask[];
artifacts: MissionArtifact[];
}
export interface PhaseSpec {
kind: PhaseKind;
order_idx: number;
config?: Record<string, unknown>;
}
export interface CreateMissionRequest {
title: string;
template_kind: TemplateKind;
team_id?: string;
team_template_id?: string;
repo_id?: string;
schedule?: Schedule;
description?: string;
config?: Record<string, unknown>;
phases?: PhaseSpec[];
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`${init?.method ?? "GET"} ${path} → ${r.status} ${text}`);
}
if (r.status === 204) return undefined as T;
return (await r.json()) as T;
}
export const listMissions = (limit = 50) =>
api<Mission[]>(`/api/missions?limit=${limit}`);
export const getMission = (id: string) =>
api<MissionDetail>(`/api/missions/${id}`);
export const createMission = (body: CreateMissionRequest) =>
api<Mission>("/api/missions", {
method: "POST",
body: JSON.stringify(body),
});
export const setMissionStatus = (id: string, status: MissionStatus) =>
api<Mission>(`/api/missions/${id}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
// ── Template presets ─────────────────────────────────────────────
//
// Slice 2 ships hardcoded phase compositions per template_kind so
// the wizard can create sensible missions before Slice 4's TOML
// recipe engine lands. When Slice 4 arrives, the server derives
// phases from the recipe and this preset table becomes a fallback
// for offline / preview rendering.
export interface TemplatePreset {
kind: TemplateKind;
title: string;
blurb: string;
requiresRepo: boolean;
phases: PhaseSpec[];
}
export const TEMPLATE_PRESETS: TemplatePreset[] = [
{
kind: "research_only",
title: "Research only",
blurb:
"Produce a styled MD + PDF artifact in the workspace. One-shot or scheduled.",
requiresRepo: false,
phases: [{ kind: "research", order_idx: 0 }],
},
{
kind: "research_and_code",
title: "Research + Coding Loop",
blurb:
"Research a topic against a repo, then loop the coding team through the produced INT-XX items until done.",
requiresRepo: true,
phases: [
{ kind: "research", order_idx: 0 },
{ kind: "coding", order_idx: 1 },
],
},
{
kind: "security_hardening",
title: "Security Hardening",
blurb:
"Scan the repo for vulnerabilities, research patches, then apply + verify.",
requiresRepo: true,
phases: [
{ kind: "security_scan", order_idx: 0 },
{ kind: "research", order_idx: 1 },
{ kind: "coding", order_idx: 2 },
],
},
{
kind: "refactor",
title: "Refactor",
blurb:
"Audit dependencies + versions, propose API/SDK adaptations, apply the changes.",
requiresRepo: true,
phases: [{ kind: "coding", order_idx: 0 }],
},
{
kind: "benchmark",
title: "Benchmark",
blurb:
"Author + baseline benchmarks so subsequent refactors can be measured before/after.",
requiresRepo: true,
phases: [{ kind: "benchmark", order_idx: 0 }],
},
];
export const presetForKind = (k: TemplateKind): TemplatePreset | undefined =>
TEMPLATE_PRESETS.find((p) => p.kind === k);