mission canvas: add Refine button + markdown-rendered description

Adds a Refine button to the left of Refresh + Launch on the mission
detail toolbar (draft-only). Clicking it POSTs to a new endpoint that
calls Gemini 2.5 Flash to rewrite the user's freeform description into
a coherent, sectioned Markdown brief (Objective / Context / Scope /
Constraints / Acceptance Criteria / Open Questions) ready for the
research + coding agents to ingest cleanly.

Backend:
  - crates/cm-api/src/mission_refiner.rs — Gemini call with a
    system prompt that preserves user-provided facts, avoids
    invention, and emits raw markdown (not JSON).
  - POST /api/missions/{id}/refine — draft-only, 400 on empty
    description or non-draft state.
  - cm-db::repo::missions::set_description helper.

Frontend:
  - MarkdownBlock — tiny zero-dep renderer for h1/h2/h3, bullet +
    numbered lists, **bold**, `code`, paragraphs. Deliberately
    small; the refiner emits a bounded subset.
  - MissionCanvas — Refine button (Sparkles icon, secondary style)
    to the left of Refresh; description now renders through
    MarkdownBlock instead of a single <p>. Disabled while
    description is empty or a refine is in flight.
  - lib/api/missions — refineMission client.
This commit is contained in:
Omar Sobh
2026-07-19 17:20:34 -07:00
parent 4663348a0e
commit 56201a6985
8 changed files with 497 additions and 4 deletions
Generated
+21
View File
@@ -966,12 +966,14 @@ dependencies = [
"rsa", "rsa",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"sha2", "sha2",
"sqlx", "sqlx",
"thiserror 2.0.18", "thiserror 2.0.18",
"time", "time",
"tokio", "tokio",
"tokio-tungstenite 0.26.2", "tokio-tungstenite 0.26.2",
"toml",
"tower-http", "tower-http",
"urlencoding", "urlencoding",
"uuid", "uuid",
@@ -4466,6 +4468,19 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "serial" name = "serial"
version = "0.4.0" version = "0.4.0"
@@ -5625,6 +5640,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+5
View File
@@ -11,6 +11,7 @@ pub mod level_up;
mod mcp_door; mod mcp_door;
mod mcp_skills; mod mcp_skills;
pub mod mission_orchestrator; pub mod mission_orchestrator;
pub mod mission_refiner;
pub mod node_rules; pub mod node_rules;
pub mod pdf_renderer; pub mod pdf_renderer;
pub mod quota; pub mod quota;
@@ -439,6 +440,10 @@ pub fn router(state: AppState) -> Router {
"/api/missions/{id}/status", "/api/missions/{id}/status",
axum::routing::patch(routes::missions::set_status), axum::routing::patch(routes::missions::set_status),
) )
.route(
"/api/missions/{id}/refine",
post(routes::missions::refine),
)
.route( .route(
"/api/missions/{id}/benchmark", "/api/missions/{id}/benchmark",
post(routes::missions::trigger_benchmark), post(routes::missions::trigger_benchmark),
+156
View File
@@ -0,0 +1,156 @@
//! Mission refiner — take the user's freeform description on a draft
//! mission and rewrite it into a coherent, sectioned Markdown brief
//! that downstream research + coding agents can ingest cleanly.
//!
//! Uses Gemini 2.5 Flash (same call shape as level_up.rs) but with a
//! text-mode response — we want Markdown out, not JSON.
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
const DEFAULT_MODEL: &str = "gemini-2.5-flash";
fn model_name() -> String {
std::env::var("CLAWMATES_REFINER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
}
pub async fn refine(
pool: &PgPool,
workspace_id: cm_domain::WorkspaceId,
mission_id: Uuid,
) -> Result<String, String> {
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load mission: {e}"))?
.ok_or_else(|| "mission not found".to_string())?;
if mission.status != "draft" {
return Err(format!("mission is {}, refine only allowed on draft", mission.status));
}
let raw = mission.description.unwrap_or_default();
if raw.trim().is_empty() {
return Err("description is empty — nothing to refine".into());
}
let phase_kinds: Vec<String> = cm_db::repo::missions::phases_for(pool, mission_id)
.await
.map_err(|e| format!("load phases: {e}"))?
.into_iter()
.map(|p| p.kind)
.collect();
let refined = call_gemini(&mission.title, &mission.template_kind, &phase_kinds, &raw).await?;
cm_db::repo::missions::set_description(pool, mission_id, workspace_id.as_uuid(), &refined)
.await
.map_err(|e| format!("save description: {e}"))?;
Ok(refined)
}
async fn call_gemini(
title: &str,
template_kind: &str,
phase_kinds: &[String],
raw: &str,
) -> Result<String, String> {
let api_key =
std::env::var("GEMINI_API_KEY").map_err(|_| "GEMINI_API_KEY unset".to_string())?;
let model = model_name();
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
);
let system = "You are a technical brief editor for an autonomous software \
engineering platform. Rewrite the user's raw mission description into a \
clean, sectioned Markdown brief that research + coding agents can ingest \
directly. Preserve every concrete fact, requirement, constraint, and \
acceptance criterion the user provided — do not invent new scope. \
Structure the output with these sections when the source material \
supports them (omit sections with nothing to say):\n\
\n\
# <One-line restated goal>\n\
\n\
## Objective\n\
A 1–3 sentence framing of what success looks like.\n\
\n\
## Context & Background\n\
Any relevant prior art, files, systems, or motivation the user gave.\n\
\n\
## Scope\n\
Bullet list of concrete deliverables (in scope). If the user \
called out non-goals, add an `### Out of scope` subsection.\n\
\n\
## Constraints\n\
Technical, stylistic, or process constraints (languages, versions, \
style guides, migration paths, existing conventions to respect).\n\
\n\
## Acceptance Criteria\n\
Numbered list of concrete, verifiable pass/fail conditions the \
coding agents should treat as done-definitions.\n\
\n\
## Open Questions\n\
Only include if the source material has genuine ambiguity worth \
flagging to the research phase before coding starts.\n\
\n\
Rules:\n\
- Output raw Markdown only — no code fence around the whole doc, \
no preamble like \"Here is the refined brief\".\n\
- Never make up file paths, APIs, repo names, or version numbers.\n\
- If the user's text is very short, produce a short brief — do not \
pad with generic filler.\n\
- Use `**bold**` sparingly for load-bearing terms; do not bold entire \
sentences.\n\
- Prefer bullet lists over paragraphs for scope, constraints, and criteria.";
let user = format!(
"Mission title: {title}\n\
Template kind: {template_kind}\n\
Planned phases: {phases}\n\
\n\
Raw description:\n\
---\n\
{raw}\n\
---",
phases = if phase_kinds.is_empty() {
"(none configured yet)".to_string()
} else {
phase_kinds.join(", ")
}
);
let body = json!({
"system_instruction": { "parts": [{ "text": system }] },
"contents": [{ "role": "user", "parts": [{ "text": user }] }],
"generationConfig": {
"temperature": 0.3,
"maxOutputTokens": 4096,
}
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("http client: {e}"))?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("gemini call: {e}"))?;
if !resp.status().is_success() {
let code = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("gemini {code}: {}", &body[..body.len().min(500)]));
}
let json: serde_json::Value = resp.json().await.map_err(|e| format!("gemini json: {e}"))?;
let text = json
.pointer("/candidates/0/content/parts/0/text")
.and_then(|v| v.as_str())
.ok_or_else(|| "gemini response missing text".to_string())?
.trim()
.to_string();
if text.is_empty() {
return Err("gemini returned empty text".into());
}
Ok(text)
}
+26
View File
@@ -221,6 +221,32 @@ pub async fn trigger_security_scan(
Ok(Json(SecurityScanResponse { findings, tasks })) Ok(Json(SecurityScanResponse { findings, tasks }))
} }
/// POST /api/missions/{id}/refine — rewrite the description into a
/// coherent, sectioned Markdown brief ready for downstream agent
/// ingestion. Draft-only.
pub async fn refine(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Mission>, ApiError> {
crate::mission_refiner::refine(&state.pool, user.workspace_id, id)
.await
.map_err(|e| {
eprintln!("mission {id}: refine failed: {e}");
if e.contains("not found") {
ApiError::NotFound
} else if e.contains("empty") || e.contains("only allowed on draft") {
ApiError::BadRequest
} else {
ApiError::Internal
}
})?;
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(mission))
}
pub async fn set_status( pub async fn set_status(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
+19
View File
@@ -233,6 +233,25 @@ pub async fn list_by_workspace(
.collect()) .collect())
} }
pub async fn set_description(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
description: &str,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE missions
SET description = $3, updated_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(description)
.execute(pool)
.await?;
Ok(())
}
pub async fn set_status( pub async fn set_status(
pool: &PgPool, pool: &PgPool,
id: Uuid, id: Uuid,
@@ -0,0 +1,225 @@
"use client";
// MarkdownBlock — tiny zero-dep Markdown renderer. Handles the subset
// the refiner emits: h1/h2/h3 headings, - / * bullets, 1. numbered
// lists, `**bold**`, `` `code` ``, blank-line-separated paragraphs.
// Not a general-purpose renderer — deliberately small to avoid a
// react-markdown dep for one canvas surface.
import React from "react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
type Block =
| { kind: "h1" | "h2" | "h3"; text: string }
| { kind: "p"; text: string }
| { kind: "ul"; items: string[] }
| { kind: "ol"; items: string[] };
function parse(md: string): Block[] {
const lines = md.replace(/\r\n/g, "\n").split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i++;
continue;
}
// Headings
const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
if (h) {
const level = h[1].length as 1 | 2 | 3;
blocks.push({
kind: (`h${level}` as "h1" | "h2" | "h3"),
text: h[2],
});
i++;
continue;
}
// Bullet list
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
i++;
}
blocks.push({ kind: "ul", items });
continue;
}
// Numbered list
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
i++;
}
blocks.push({ kind: "ol", items });
continue;
}
// Paragraph — greedily accumulate until blank line or block boundary
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() &&
!/^(#{1,3})\s+/.test(lines[i].trim()) &&
!/^[-*]\s+/.test(lines[i].trim()) &&
!/^\d+\.\s+/.test(lines[i].trim())
) {
paraLines.push(lines[i].trim());
i++;
}
if (paraLines.length) blocks.push({ kind: "p", text: paraLines.join(" ") });
}
return blocks;
}
// Inline: **bold**, `code`. Simple sequential scan.
function renderInline(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
const tok = m[0];
if (tok.startsWith("**")) {
out.push(
<strong key={key++} style={{ color: "#f3f3f5" }}>
{tok.slice(2, -2)}
</strong>,
);
} else {
out.push(
<code
key={key++}
style={{
fontFamily: mono,
fontSize: 11.5,
padding: "1px 5px",
borderRadius: 4,
background: "rgba(255,255,255,.06)",
color: "#ffb44a",
}}
>
{tok.slice(1, -1)}
</code>,
);
}
last = m.index + tok.length;
}
if (last < text.length) out.push(text.slice(last));
return out;
}
export function MarkdownBlock({ source }: { source: string }) {
const blocks = React.useMemo(() => parse(source), [source]);
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 10,
color: "#cfcfd5",
fontSize: 13,
lineHeight: 1.55,
}}
>
{blocks.map((b, idx) => {
if (b.kind === "h1")
return (
<h1
key={idx}
style={{
margin: "8px 0 2px",
fontSize: 17,
color: "#f3f3f5",
fontWeight: 600,
letterSpacing: ".01em",
}}
>
{renderInline(b.text)}
</h1>
);
if (b.kind === "h2")
return (
<h2
key={idx}
style={{
margin: "10px 0 -2px",
fontSize: 12,
color: "#7cd6e0",
fontFamily: mono,
letterSpacing: ".14em",
textTransform: "uppercase",
fontWeight: 600,
}}
>
{renderInline(b.text)}
</h2>
);
if (b.kind === "h3")
return (
<h3
key={idx}
style={{
margin: "6px 0 -4px",
fontSize: 11.5,
color: "#a0a0a8",
fontFamily: mono,
letterSpacing: ".10em",
textTransform: "uppercase",
fontWeight: 500,
}}
>
{renderInline(b.text)}
</h3>
);
if (b.kind === "p")
return (
<p key={idx} style={{ margin: 0 }}>
{renderInline(b.text)}
</p>
);
if (b.kind === "ul")
return (
<ul
key={idx}
style={{
margin: 0,
paddingLeft: 18,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{b.items.map((it, i) => (
<li key={i}>{renderInline(it)}</li>
))}
</ul>
);
if (b.kind === "ol")
return (
<ol
key={idx}
style={{
margin: 0,
paddingLeft: 20,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{b.items.map((it, i) => (
<li key={i}>{renderInline(it)}</li>
))}
</ol>
);
return null;
})}
</div>
);
}
@@ -9,10 +9,11 @@
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover. // This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FileText, Play, RefreshCw } from "lucide-react"; import { FileText, Play, RefreshCw, Sparkles } from "lucide-react";
import { import {
getMission, getMission,
refineMission,
setMissionStatus, setMissionStatus,
type MissionDetail, type MissionDetail,
type MissionStatus, type MissionStatus,
@@ -21,6 +22,7 @@ import {
type TaskStatus, type TaskStatus,
type TemplateKind, type TemplateKind,
} from "@/lib/api/missions"; } from "@/lib/api/missions";
import { MarkdownBlock } from "./MarkdownBlock";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -77,6 +79,7 @@ export function MissionCanvas({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview"); const [tab, setTab] = useState<Tab>("overview");
const [launching, setLaunching] = useState(false); const [launching, setLaunching] = useState(false);
const [refining, setRefining] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!selectedId) { if (!selectedId) {
@@ -100,6 +103,21 @@ export function MissionCanvas({
void load(); void load();
}, [load, refreshKey]); }, [load, refreshKey]);
const refine = useCallback(async () => {
if (!mission) return;
setRefining(true);
setError(null);
try {
await refineMission(mission.id);
onChanged();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "refine failed");
} finally {
setRefining(false);
}
}, [mission, onChanged, load]);
const launch = useCallback(async () => { const launch = useCallback(async () => {
if (!mission) return; if (!mission) return;
setLaunching(true); setLaunching(true);
@@ -198,6 +216,26 @@ export function MissionCanvas({
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind} {TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
</span> </span>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}> <div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
{mission.status === "draft" && (
<button
type="button"
onClick={refine}
disabled={refining || !mission.description?.trim()}
title={
mission.description?.trim()
? "Refine the description into a sectioned brief"
: "Add a description first"
}
aria-label="Refine"
style={{
...secondaryBtn,
opacity: refining || !mission.description?.trim() ? 0.5 : 1,
}}
>
<Sparkles size={13} style={{ marginRight: 4 }} />
{refining ? "Refining…" : "Refine"}
</button>
)}
<button <button
type="button" type="button"
onClick={load} onClick={load}
@@ -225,9 +263,9 @@ export function MissionCanvas({
</div> </div>
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1> <h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
{mission.description && ( {mission.description && (
<p style={{ margin: 0, fontSize: 13, color: "#cfcfd5", lineHeight: 1.5 }}> <div style={{ marginTop: 4 }}>
{mission.description} <MarkdownBlock source={mission.description} />
</p> </div>
)} )}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}> <div style={{ display: "flex", gap: 4, marginTop: 4 }}>
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => { {(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => {
+3
View File
@@ -195,6 +195,9 @@ export const createMission = (body: CreateMissionRequest) =>
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
export const refineMission = (id: string) =>
api<Mission>(`/api/missions/${id}/refine`, { method: "POST" });
export const setMissionStatus = (id: string, status: MissionStatus) => export const setMissionStatus = (id: string, status: MissionStatus) =>
api<Mission>(`/api/missions/${id}/status`, { api<Mission>(`/api/missions/${id}/status`, {
method: "PATCH", method: "PATCH",