Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions crates/coven-cli/src/tui/cast/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ use crate::store;
/// `shell::write_cast_summary_event` for the producer side.
pub(crate) const CAST_SUMMARY_KIND: &str = "cast.summary";

/// Event kind Cast writes on the anchor session when a quest begins. See
/// `shell::dispatch_cast_quest` for the producer side.
pub(crate) const CAST_QUEST_STARTED_KIND: &str = "cast.quest.started";

/// Event kind Cast writes on the anchor session when a quest's last phase
/// has finished.
pub(crate) const CAST_QUEST_COMPLETED_KIND: &str = "cast.quest.completed";

/// Event kind Cast writes on the anchor session right after each phase
/// finishes. Used by the re-attach detector to compute the phase index.
pub(crate) const CAST_QUEST_PHASE_COMPLETED_KIND: &str = "cast.quest.phase_completed";

/// Decoded `cast.summary` event. All fields are optional because Cast may
/// have written a partial payload (e.g. an older Cast that didn't record
/// `headline`), and the renderer should degrade gracefully.
Expand All @@ -36,6 +48,88 @@ pub(crate) fn find_cast_summary(events: &[store::EventRecord]) -> Option<CastAtt
.map(decode_summary)
}

/// Decoded `cast.quest.started` event plus the count of
/// `cast.quest.phase_completed` events seen on the same session. Both come
/// from the *anchor* session of a quest (per the Phase 7 first-session
/// anchor decision).
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(crate) struct CastQuestAttachInfo {
pub(crate) title: Option<String>,
pub(crate) goal: Option<String>,
pub(crate) harness: Option<String>,
pub(crate) total_phases: Option<usize>,
pub(crate) completed_phases: usize,
pub(crate) is_complete: bool,
}

/// Detect that `events` belong to a quest's anchor session. Returns
/// `None` when no `cast.quest.started` event is present (i.e., the session
/// is a plain Cast launch, not a quest anchor). Currently used as a
/// minimal re-attach aid: callers print a note pointing the user at the
/// quest title and progress so they can re-run `/quest <goal>` if they
/// want to continue. Full state rebuild (replay handoffs, render the next
/// card) is deferred to a later phase.
pub(crate) fn find_cast_quest_info(events: &[store::EventRecord]) -> Option<CastQuestAttachInfo> {
let started = events
.iter()
.rev()
.find(|event| event.kind == CAST_QUEST_STARTED_KIND)?;
let payload = serde_json::from_str::<Value>(&started.payload_json).unwrap_or(Value::Null);
let title = payload
.get("title")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let goal = payload
.get("goal")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let harness = payload
.get("harness")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let total_phases = payload
.get("phases")
.and_then(Value::as_array)
.map(|arr| arr.len());
let completed_phases = events
.iter()
.filter(|event| event.kind == CAST_QUEST_PHASE_COMPLETED_KIND)
.count();
let is_complete = events
.iter()
.any(|event| event.kind == CAST_QUEST_COMPLETED_KIND);
Some(CastQuestAttachInfo {
title,
goal,
harness,
total_phases,
completed_phases,
is_complete,
})
}

/// One-line note for the attach outcome card describing the quest this
/// session anchors. Returns `None` when the info carries no usable text
/// (defensive — `find_cast_quest_info` already short-circuits on the
/// happy path).
pub(crate) fn format_quest_attach_note(info: &CastQuestAttachInfo) -> Option<String> {
let title = info.title.as_deref().unwrap_or("(untitled quest)");
let progress = match info.total_phases {
Some(total) if total > 0 => format!("phase {}/{total}", info.completed_phases.min(total)),
_ => format!("{} phases run", info.completed_phases),
};
let state = if info.is_complete {
"complete"
} else if info.completed_phases == 0 {
"starting"
} else {
"in progress"
};
Some(format!(
"Quest anchor — `{title}` ({progress}, {state}). Re-run `/quest <goal>` to continue."
))
}

/// One-line outcome-card note describing what Cast saw on the prior run.
/// Returns `None` when none of the fields are populated, so the caller can
/// skip the note entirely instead of printing an empty bullet.
Expand Down Expand Up @@ -254,6 +348,112 @@ mod tests {
);
}

fn quest_started_event(seq: i64, payload: serde_json::Value) -> store::EventRecord {
store::EventRecord {
seq,
id: format!("event-{seq}"),
session_id: "session-1".to_string(),
kind: CAST_QUEST_STARTED_KIND.to_string(),
payload_json: payload.to_string(),
created_at: "2026-05-20T00:00:00Z".to_string(),
}
}

fn quest_kind_event(seq: i64, kind: &str) -> store::EventRecord {
store::EventRecord {
seq,
id: format!("event-{seq}"),
session_id: "session-1".to_string(),
kind: kind.to_string(),
payload_json: "{}".to_string(),
created_at: "2026-05-20T00:00:00Z".to_string(),
}
}

#[test]
fn find_cast_quest_info_returns_none_when_no_quest_event_exists() {
let events = vec![output_event(1, "hello\n")];
assert!(find_cast_quest_info(&events).is_none());
}

#[test]
fn find_cast_quest_info_decodes_title_goal_harness_and_phase_count() {
let events = vec![quest_started_event(
1,
serde_json::json!({
"title": "Ship phase 7",
"goal": "ship phase 7",
"harness": "codex",
"phases": ["design", "implement", "verify"],
}),
)];
let info = find_cast_quest_info(&events).expect("quest info should be present");
assert_eq!(info.title.as_deref(), Some("Ship phase 7"));
assert_eq!(info.goal.as_deref(), Some("ship phase 7"));
assert_eq!(info.harness.as_deref(), Some("codex"));
assert_eq!(info.total_phases, Some(3));
assert_eq!(info.completed_phases, 0);
assert!(!info.is_complete);
}

#[test]
fn find_cast_quest_info_counts_phase_completed_events() {
let events = vec![
quest_started_event(
1,
serde_json::json!({
"title": "Ship phase 7",
"phases": ["design", "implement", "verify"],
}),
),
quest_kind_event(2, CAST_QUEST_PHASE_COMPLETED_KIND),
quest_kind_event(3, CAST_QUEST_PHASE_COMPLETED_KIND),
];
let info = find_cast_quest_info(&events).expect("quest info should be present");
assert_eq!(info.completed_phases, 2);
assert!(!info.is_complete);
}

#[test]
fn find_cast_quest_info_marks_complete_when_completed_event_is_present() {
let events = vec![
quest_started_event(1, serde_json::json!({ "title": "X", "phases": ["a"] })),
quest_kind_event(2, CAST_QUEST_PHASE_COMPLETED_KIND),
quest_kind_event(3, CAST_QUEST_COMPLETED_KIND),
];
let info = find_cast_quest_info(&events).expect("quest info should be present");
assert!(info.is_complete);
}

#[test]
fn format_quest_attach_note_describes_in_progress_quest() {
let info = CastQuestAttachInfo {
title: Some("Ship phase 7".to_string()),
total_phases: Some(3),
completed_phases: 1,
..Default::default()
};
let note = format_quest_attach_note(&info).expect("note should be produced");
assert!(note.contains("Quest anchor"));
assert!(note.contains("Ship phase 7"));
assert!(note.contains("phase 1/3"));
assert!(note.contains("in progress"));
}

#[test]
fn format_quest_attach_note_describes_complete_quest() {
let info = CastQuestAttachInfo {
title: Some("Ship phase 7".to_string()),
total_phases: Some(3),
completed_phases: 3,
is_complete: true,
..Default::default()
};
let note = format_quest_attach_note(&info).expect("note should be produced");
assert!(note.contains("phase 3/3"));
assert!(note.contains("complete"));
}

#[test]
fn format_summary_note_handles_status_without_exit_code() {
let summary = CastAttachSummary {
Expand Down
106 changes: 106 additions & 0 deletions crates/coven-cli/src/tui/cast/intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ pub(crate) enum CastIntent {
StartHere,
OpenTui,
PatchOpenClaw,
/// Multi-phase sequential goal. Cast turns the goal into a `Quest`
/// (design → implement → verify by default) and dispatches each phase
/// in order. See `cast::quest` and `docs/design/cast-quest-flow.md`.
Quest {
goal: String,
},
Quit,
}

Expand All @@ -92,6 +98,10 @@ pub(crate) fn parse_spell(raw: &str) -> Result<CastIntent> {
return Ok(plain_intent);
}

if let Some(quest_intent) = parse_natural_quest_trigger(input) {
return Ok(quest_intent);
}

if let Some(harness_spell) = parse_natural_harness_prefix(input) {
return Ok(harness_spell);
}
Expand Down Expand Up @@ -131,6 +141,7 @@ fn parse_slash_command(input: &str) -> Result<Option<CastIntent>> {
"/sacrifice" => session_id_intent(rest, "/sacrifice", |session_id| {
CastIntent::SacrificeSession { session_id }
})?,
"/quest" => parse_quest_slash(rest)?,
"/quit" | "/exit" => CastIntent::Quit,
unknown => {
return Err(anyhow!(
Expand All @@ -156,6 +167,39 @@ fn parse_plain_command(input: &str) -> Option<CastIntent> {
}
}

/// Recognise plain-language quest triggers. Returns the *original-case*
/// goal text so the rest of the pipeline can render the user's words back
/// at them. Matches `start a quest to …`, `begin a quest to …`, and the
/// shorter `quest <goal>` (must have at least one whitespace separator so
/// a bare `quest` keyword is unambiguous — currently unclaimed).
fn parse_natural_quest_trigger(input: &str) -> Option<CastIntent> {
let lower = input.to_ascii_lowercase();
let triggers = [
"start a quest to ",
"start a quest for ",
"begin a quest to ",
"begin a quest for ",
"quest to ",
"quest for ",
"quest: ",
];
for trigger in triggers {
if let Some(rest) = lower.strip_prefix(trigger) {
let goal = input[trigger.len()..].trim();
// Defensive: lower-only strip can desync from original casing
// when whitespace differs. `rest` is just the length anchor.
let _ = rest;
if goal.is_empty() {
return None;
}
return Some(CastIntent::Quest {
goal: goal.to_string(),
});
}
}
None
}

/// Translate plain-language "run claude X" / "use codex X" / "ask codex X"
/// into an explicit `HarnessSpell`. The verb itself is dropped from the
/// prompt so the harness only sees the actual task.
Expand Down Expand Up @@ -214,6 +258,18 @@ fn parse_run_slash(rest: &str) -> Result<CastIntent> {
})
}

fn parse_quest_slash(rest: &str) -> Result<CastIntent> {
let goal = rest.trim();
if goal.is_empty() {
return Err(anyhow!(
"`/quest` needs a goal. Example: `/quest fix the failing tests`."
));
}
Ok(CastIntent::Quest {
goal: goal.to_string(),
})
}

fn parse_harness_slash(harness: CastHarness, rest: &str) -> Result<CastIntent> {
let prompt = rest.trim();
if prompt.is_empty() {
Expand Down Expand Up @@ -439,6 +495,56 @@ mod tests {
assert!(error.to_string().contains("unknown Cast slash command"));
}

#[test]
fn slash_quest_requires_a_goal() {
let error = parse_spell("/quest").unwrap_err();
assert!(error.to_string().contains("`/quest` needs a goal"));
let error = parse_spell("/quest ").unwrap_err();
assert!(error.to_string().contains("`/quest` needs a goal"));
}

#[test]
fn slash_quest_with_goal_produces_quest_intent() {
assert_eq!(
intent("/quest fix the failing tests"),
CastIntent::Quest {
goal: "fix the failing tests".to_string(),
}
);
}

#[test]
fn natural_language_quest_triggers_produce_quest_intent() {
let cases = [
"start a quest to ship the redesign",
"Begin a quest to ship the redesign",
"quest to ship the redesign",
"quest: ship the redesign",
];
for raw in cases {
assert_eq!(
intent(raw),
CastIntent::Quest {
goal: "ship the redesign".to_string(),
},
"raw input `{raw}` should parse as a quest",
);
}
}

#[test]
fn bare_quest_keyword_without_goal_falls_through_to_natural_spell() {
// "quest" alone is too ambiguous to launch — leave it as a natural
// spell so the user sees what Cast would route a non-keyword
// through. `/quest` (slash form) still errors clearly above.
assert_eq!(
intent("quest"),
CastIntent::NaturalSpell {
prompt: "quest".to_string(),
}
);
}

#[test]
fn cast_harness_token_accepts_common_aliases() {
assert_eq!(CastHarness::from_token("codex"), Some(CastHarness::Codex));
Expand Down
Loading