missions: phase-completion summary card (Claude Opus 4.8 synthesized)
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 9s
ci / frontend (push) Successful in 26s
ci / e2e (push) Skipped
ci / publish (push) Skipped

New phase_summarizer background worker fires on any mission_phase
transition to a terminal state (completed/failed). Aggregates every
topology_runs.checkpoint.outputs[] + mission_tasks + mission_artifacts
bound to that phase and asks Claude Opus 4.8 to produce a structured
JSON card:

  { narrative, metrics, sources, tooling, next_actions }

Rendered inline on the mission page under each completed phase via
new PhaseSummaryCard component. Metrics grid is kind-specific:
research surfaces insights/sources/int_cards/artifacts, coding
surfaces cards_picked_up/commits/tests/issues, benchmark surfaces
regressions/improvements, security surfaces findings-by-severity.

New table: mission_phase_summaries (migration 0060), unique per
phase_id — regenerates on retry.
New endpoint: GET /api/missions/{id}/phases/{phase_id}/summary.

Model overridable via CLAWMATES_SUMMARIZER_MODEL. Reuses the
ANTHROPIC_API_KEY prod already carries for mission_refiner.
This commit is contained in:
Omar Sobh
2026-07-23 16:54:38 -07:00
parent 1be3430bf2
commit 5c63ef0ed3
8 changed files with 1022 additions and 0 deletions
@@ -0,0 +1,314 @@
"use client";
// Post-phase completion card. Fetches the LLM-synthesized summary
// (`GET /api/missions/{id}/phases/{phase_id}/summary`) for any phase
// in a terminal state and renders: narrative, kind-specific metrics
// grid, sources, tooling recommendations, artifacts, and next actions.
//
// 404 while `phase_summarizer` hasn't run yet — shows a "waiting"
// pill in that case instead of an error.
import { useEffect, useState } from "react";
import { getPhaseSummary, type PhaseSummary } from "@/lib/api/missions";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function PhaseSummaryCard({
missionId,
phaseId,
}: {
missionId: string;
phaseId: string;
}) {
const [summary, setSummary] = useState<PhaseSummary | null>(null);
const [waiting, setWaiting] = useState(true);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
let stop = false;
const load = async () => {
try {
const s = await getPhaseSummary(missionId, phaseId);
if (!alive) return;
setSummary(s);
setWaiting(false);
setErr(null);
stop = true;
} catch (e) {
const msg = String(e);
if (msg.includes("404")) {
if (!alive) return;
setWaiting(true);
} else {
if (!alive) return;
setErr(msg);
setWaiting(false);
stop = true;
}
}
};
void load();
// Poll for up to ~10 min while waiting for the summarizer.
const t = setInterval(() => {
if (stop) return;
void load();
}, 15_000);
return () => {
alive = false;
clearInterval(t);
};
}, [missionId, phaseId]);
if (waiting && !summary) {
return (
<div
style={{
marginTop: 8,
padding: 8,
borderRadius: 6,
border: "1px dashed rgba(255,255,255,.10)",
background: "rgba(255,255,255,.02)",
color: "#8a8a92",
fontSize: 11,
fontFamily: mono,
}}
>
Summarizing this phase (Claude Opus 4.8 fires on the next 30s tick after
the phase reaches a terminal state)
</div>
);
}
if (err) {
return (
<div
style={{
marginTop: 8,
padding: 8,
borderRadius: 6,
background: "rgba(255,138,122,.06)",
border: "1px solid rgba(255,138,122,.35)",
color: "#ff8a7a",
fontSize: 11,
fontFamily: mono,
}}
>
summary failed: {err}
</div>
);
}
if (!summary) return null;
if (summary.error) {
return (
<div
style={{
marginTop: 8,
padding: 8,
borderRadius: 6,
background: "rgba(255,138,122,.06)",
border: "1px solid rgba(255,138,122,.35)",
fontSize: 11,
fontFamily: mono,
color: "#e0d0cf",
}}
>
<div style={{ color: "#ff8a7a", marginBottom: 4 }}>
summary generation failed
</div>
{summary.error}
</div>
);
}
const metrics = Object.entries(summary.metrics ?? {});
return (
<div
style={{
marginTop: 8,
padding: 10,
borderRadius: 6,
background: "linear-gradient(180deg, rgba(94,200,216,.05), rgba(94,200,216,.02))",
border: "1px solid rgba(94,200,216,.20)",
fontFamily: mono,
color: "#cfcfd5",
fontSize: 11,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
<span
style={{
fontSize: 9,
letterSpacing: ".1em",
textTransform: "uppercase",
color: "#5ec8d8",
}}
>
phase summary · {summary.kind}
</span>
<span style={{ fontSize: 9, color: "#6a6a72" }}>
{summary.model} · {new Date(summary.generated_at).toLocaleTimeString()}
</span>
</div>
<div style={{ whiteSpace: "pre-wrap", color: "#e0e0e5", lineHeight: 1.4 }}>
{summary.narrative}
</div>
{metrics.length > 0 && (
<MetricsGrid entries={metrics} />
)}
{summary.sources && summary.sources.length > 0 && (
<Section
label="sources consulted"
items={summary.sources.map((s) => ({
head: s.title ?? s.url ?? s.path ?? "source",
body: [s.note, s.url ?? s.path].filter(Boolean).join(" · "),
href: s.url,
}))}
accent="#c9a0ff"
/>
)}
{summary.tooling && summary.tooling.length > 0 && (
<Section
label="tooling recommendations"
items={summary.tooling.map((t) => ({
head: `${t.kind ? `[${t.kind}] ` : ""}${t.title ?? "recommendation"}`,
body: t.why ?? t.note ?? "",
}))}
accent="#5fd08a"
/>
)}
{summary.artifacts && summary.artifacts.length > 0 && (
<Section
label="artifacts saved"
items={summary.artifacts.map((a) => ({
head: a.title ?? a.path,
body: `[${a.kind}] ${a.path}`,
}))}
accent="#f0c060"
/>
)}
{summary.next_actions && summary.next_actions.length > 0 && (
<Section
label="next actions"
items={summary.next_actions.map((n) => ({
head: n.title ?? "action",
body: n.note ?? "",
}))}
accent="#5ec8d8"
/>
)}
</div>
);
}
function MetricsGrid({ entries }: { entries: Array<[string, unknown]> }) {
const flat: Array<[string, string]> = [];
for (const [k, v] of entries) {
if (v && typeof v === "object" && !Array.isArray(v)) {
for (const [sk, sv] of Object.entries(v as Record<string, unknown>)) {
flat.push([`${k}.${sk}`, formatVal(sv)]);
}
} else {
flat.push([k, formatVal(v)]);
}
}
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 6,
}}
>
{flat.map(([k, v]) => (
<div
key={k}
style={{
padding: "5px 8px",
borderRadius: 4,
background: "rgba(0,0,0,.25)",
border: "1px solid rgba(255,255,255,.06)",
}}
>
<div style={{ color: "#6a6a72", fontSize: 9, textTransform: "uppercase" }}>
{k.replace(/_/g, " ")}
</div>
<div style={{ color: "#e0e0e5", fontSize: 13 }}>{v}</div>
</div>
))}
</div>
);
}
function formatVal(v: unknown): string {
if (v == null) return "—";
if (typeof v === "number") return v.toLocaleString();
return String(v);
}
function Section({
label,
items,
accent,
}: {
label: string;
items: Array<{ head: string; body: string; href?: string }>;
accent: string;
}) {
return (
<div>
<div
style={{
color: accent,
fontSize: 9,
letterSpacing: ".08em",
textTransform: "uppercase",
marginBottom: 4,
}}
>
{label}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{items.map((it, i) => (
<div
key={i}
style={{
padding: "4px 6px",
borderRadius: 3,
background: "rgba(255,255,255,.02)",
}}
>
<div style={{ color: "#e0e0e5" }}>
{it.href ? (
<a
href={it.href}
target="_blank"
rel="noreferrer"
style={{ color: accent, textDecoration: "underline" }}
>
{it.head}
</a>
) : (
it.head
)}
</div>
{it.body && (
<div style={{ color: "#8a8a92", fontSize: 10, marginTop: 1 }}>
{it.body}
</div>
)}
</div>
))}
</div>
</div>
);
}