diff --git a/crates/coven-cli/src/tui/cast/attach.rs b/crates/coven-cli/src/tui/cast/attach.rs index f00ba47..cd6e63f 100644 --- a/crates/coven-cli/src/tui/cast/attach.rs +++ b/crates/coven-cli/src/tui/cast/attach.rs @@ -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. @@ -36,6 +48,88 @@ pub(crate) fn find_cast_summary(events: &[store::EventRecord]) -> Option, + pub(crate) goal: Option, + pub(crate) harness: Option, + pub(crate) total_phases: Option, + 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 ` 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 { + let started = events + .iter() + .rev() + .find(|event| event.kind == CAST_QUEST_STARTED_KIND)?; + let payload = serde_json::from_str::(&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 { + 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 ` 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. @@ -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 { diff --git a/crates/coven-cli/src/tui/cast/intent.rs b/crates/coven-cli/src/tui/cast/intent.rs index 4da6edb..a8b5b6d 100644 --- a/crates/coven-cli/src/tui/cast/intent.rs +++ b/crates/coven-cli/src/tui/cast/intent.rs @@ -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, } @@ -92,6 +98,10 @@ pub(crate) fn parse_spell(raw: &str) -> Result { 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); } @@ -131,6 +141,7 @@ fn parse_slash_command(input: &str) -> Result> { "/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!( @@ -156,6 +167,39 @@ fn parse_plain_command(input: &str) -> Option { } } +/// 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 ` (must have at least one whitespace separator so +/// a bare `quest` keyword is unambiguous — currently unclaimed). +fn parse_natural_quest_trigger(input: &str) -> Option { + 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. @@ -214,6 +258,18 @@ fn parse_run_slash(rest: &str) -> Result { }) } +fn parse_quest_slash(rest: &str) -> Result { + 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 { let prompt = rest.trim(); if prompt.is_empty() { @@ -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)); diff --git a/crates/coven-cli/src/tui/cast/mod.rs b/crates/coven-cli/src/tui/cast/mod.rs index c5e8a0b..8b02a71 100644 --- a/crates/coven-cli/src/tui/cast/mod.rs +++ b/crates/coven-cli/src/tui/cast/mod.rs @@ -24,20 +24,29 @@ use anyhow::Result; use crate::harness; -pub(crate) use attach::{find_cast_summary, format_summary_note}; +pub(crate) use attach::{ + find_cast_quest_info, find_cast_summary, format_quest_attach_note, format_summary_note, +}; pub(crate) use follow::{follow_until_exit, CastSessionExit, FollowerObserver, FollowerPacer}; pub(crate) use gate::{evaluate_gate, GateOutcome}; pub(crate) use intent::{parse_spell, CastHarness, CastIntent}; pub(crate) use outcome::CastOutcome; pub(crate) use plan::{build_plan, CastPlan}; -#[allow(unused_imports)] pub(crate) use quest::{ - advance as advance_quest, compose_sub_prompt, quest_from_goal, set_phase_sub_prompt, - skip_phase, Quest, QuestHandoff, QuestPhase, QuestPhaseStatus, QuestPhaseSummary, + advance as advance_quest, quest_from_goal, Quest, QuestPhase, QuestPhaseSummary, }; +// `compose_sub_prompt`, `set_phase_sub_prompt`, `skip_phase`, `QuestHandoff`, +// and `QuestPhaseStatus` are exercised by the in-module test suite and the +// edit / skip UX deferred per the Phase 7 scope decision; keep them in the +// public crate surface so the future UX can pick them up without further +// plumbing. #[allow(unused_imports)] -pub(crate) use render::render_quest_handoff; -pub(crate) use render::{render_cast_frame_for_terminal, render_outcome, render_plan_intro}; +pub(crate) use quest::{ + compose_sub_prompt, set_phase_sub_prompt, skip_phase, QuestHandoff, QuestPhaseStatus, +}; +pub(crate) use render::{ + render_cast_frame_for_terminal, render_outcome, render_plan_intro, render_quest_handoff, +}; pub(crate) use safety::SafetyDecision; // Re-exports used only by tests in `crate::tests` (main.rs). Bundled here diff --git a/crates/coven-cli/src/tui/cast/plan.rs b/crates/coven-cli/src/tui/cast/plan.rs index d4055e0..ca33ee6 100644 --- a/crates/coven-cli/src/tui/cast/plan.rs +++ b/crates/coven-cli/src/tui/cast/plan.rs @@ -170,6 +170,7 @@ where "Open the guided OpenClaw patch room", CastStep::new(CastStepKind::Inform, "Walk through `coven patch openclaw`"), ), + CastIntent::Quest { ref goal } => quest_plan(goal, intent.clone(), &default_harness), CastIntent::Quit => simple_plan( intent, "Close Cast without changing anything", @@ -178,6 +179,47 @@ where }) } +/// Plan card for `/quest `. The quest itself is safe to *announce* — +/// no side effects until a phase is dispatched. Each phase reruns the +/// safety gate against its own sub-prompt before the harness sees it, so +/// the quest plan card doesn't need to reclassify the goal text. +fn quest_plan( + goal: &str, + intent: CastIntent, + default_harness: &dyn Fn() -> Option, +) -> CastPlan { + let title = derive_title(goal); + let headline = format!("Begin quest: {title}"); + let harness = default_harness().map(|harness| CastPlanHarness { + harness, + source: CastHarnessSource::SafeDefault, + }); + let harness_note = match harness { + Some(plan_harness) => format!( + "Each phase delegates to {} unless you override the sub-prompt", + plan_harness.harness.label() + ), + None => "No harness ready — run `coven doctor` to install Codex or Claude Code".to_string(), + }; + let steps = vec![ + CastStep::new(CastStepKind::Inform, "design — scope the work"), + CastStep::new(CastStepKind::Inform, "implement — make the change"), + CastStep::new(CastStepKind::Inform, "verify — confirm the change"), + CastStep::new(CastStepKind::Inform, harness_note), + ]; + CastPlan { + raw_spell: String::new(), + intent, + headline, + steps, + decision: SafetyDecision::Proceed, + harness, + session_id: None, + prompt: Some(goal.to_string()), + title: Some(title), + } +} + fn natural_spell_plan( prompt: &str, intent: CastIntent, @@ -516,6 +558,53 @@ mod tests { assert!(plan.headline.contains("abcdef123456")); } + #[test] + fn quest_intent_produces_three_phase_plan_with_default_harness() { + let plan = build_plan( + CastIntent::Quest { + goal: "ship the launcher redesign".to_string(), + }, + codex, + ) + .unwrap(); + + assert!(matches!(plan.intent, CastIntent::Quest { .. })); + assert_eq!(plan.risk(), CastRisk::Safe); + assert!(plan.headline.starts_with("Begin quest:")); + assert!(plan.headline.contains("ship the launcher redesign")); + + let notes: Vec<&str> = plan.steps.iter().map(|s| s.note.as_str()).collect(); + assert!(notes.iter().any(|n| n.starts_with("design"))); + assert!(notes.iter().any(|n| n.starts_with("implement"))); + assert!(notes.iter().any(|n| n.starts_with("verify"))); + assert!( + notes.iter().any(|n| n.contains("Codex")), + "harness note should name the resolved default, got {notes:?}", + ); + + let harness = plan.harness.expect("default harness should be resolved"); + assert_eq!(harness.harness, CastHarness::Codex); + assert_eq!(harness.source, CastHarnessSource::SafeDefault); + } + + #[test] + fn quest_intent_with_no_default_harness_surfaces_doctor_hint() { + let plan = build_plan( + CastIntent::Quest { + goal: "rewrite the README".to_string(), + }, + none, + ) + .unwrap(); + + assert!(plan.harness.is_none()); + let notes: Vec<&str> = plan.steps.iter().map(|s| s.note.as_str()).collect(); + assert!( + notes.iter().any(|n| n.contains("coven doctor")), + "missing-harness note should point at `coven doctor`, got {notes:?}", + ); + } + #[test] fn natural_spell_title_truncates_long_prompts() { let prompt = "do everything imaginable that could possibly matter to this repository"; diff --git a/crates/coven-cli/src/tui/cast/quest.rs b/crates/coven-cli/src/tui/cast/quest.rs index 6618dbb..b1a242d 100644 --- a/crates/coven-cli/src/tui/cast/quest.rs +++ b/crates/coven-cli/src/tui/cast/quest.rs @@ -16,11 +16,11 @@ //! //! Integration boundary: this module is pure. The Cast shell wires the //! quest into its existing gate / follow / outcome surfaces; `quest.rs` -//! does no IO and never reaches the daemon directly. Until the shell -//! integration lands, the surface is exercised only by the in-module test -//! suite — `#![allow(dead_code)]` keeps the warning noise off the seam. - -#![allow(dead_code)] +//! does no IO and never reaches the daemon directly. Phase 7 lands the +//! shell wiring, so the module is no longer dead — but `skip_phase`, +//! `set_phase_sub_prompt`, and `compose_sub_prompt` remain reachable only +//! through future UX (edit / skip prompts deferred per the Phase 7 scope +//! decision). Their `#[allow(dead_code)]` annotations document that. use anyhow::{anyhow, Result}; @@ -56,9 +56,19 @@ pub(crate) struct QuestPhase { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum QuestPhaseStatus { Pending, - Running { session_id: String }, + /// Reserved for a future "live attach to a running quest phase" UX — + /// the shell loop in Phase 7 transitions Pending → Complete directly. + #[allow(dead_code)] + Running { + session_id: String, + }, Complete(QuestPhaseSummary), - Skipped { reason: String }, + /// Set by `skip_phase` (currently only exercised by tests; the + /// auto-advance loop never skips on its own). + #[allow(dead_code)] + Skipped { + reason: String, + }, } /// Structured outcome of a single phase. Cast feeds this into the next @@ -93,10 +103,17 @@ impl Quest { } } + /// Convenience accessor for the current phase. Exercised by the + /// in-module tests; the shell loop currently indexes `quest.phases` + /// directly so it can borrow mutably elsewhere. + #[allow(dead_code)] pub(crate) fn current(&self) -> Option<&QuestPhase> { self.current_index().map(|idx| &self.phases[idx]) } + /// Convenience predicate used by the in-module test suite to assert + /// quest exhaustion; the shell loop reads `current_index` directly. + #[allow(dead_code)] pub(crate) fn is_complete(&self) -> bool { self.cursor >= self.phases.len() } @@ -235,6 +252,10 @@ pub(crate) fn advance(quest: &mut Quest, summary: QuestPhaseSummary) -> Option Result<()> { let phase = quest .phases diff --git a/crates/coven-cli/src/tui/shell.rs b/crates/coven-cli/src/tui/shell.rs index 55a7e42..864d338 100644 --- a/crates/coven-cli/src/tui/shell.rs +++ b/crates/coven-cli/src/tui/shell.rs @@ -13,9 +13,11 @@ use crossterm::{ use uuid::Uuid; use super::cast::{ - self, evaluate_gate, follow_until_exit, format_summary_note, render_cast_frame_for_terminal, - render_outcome, render_plan_intro, CastIntent, CastOutcome, CastPlan, CastSessionExit, - FollowerObserver, FollowerPacer, GateOutcome, SafetyDecision, + self, advance_quest, build_plan, evaluate_gate, find_cast_summary, follow_until_exit, + format_summary_note, quest_from_goal, render_cast_frame_for_terminal, render_outcome, + render_plan_intro, render_quest_handoff, CastHarness, CastIntent, CastOutcome, CastPlan, + CastSessionExit, FollowerObserver, FollowerPacer, GateOutcome, Quest, QuestPhase, + QuestPhaseSummary, SafetyDecision, }; use super::chat::client::{ChatClient, ChatEventQuery, DaemonChatClient, LaunchRequest}; use super::{is_key_press, sessions}; @@ -428,6 +430,7 @@ fn dispatch_cast_plan(plan: CastPlan) -> Result<()> { run_patch_openclaw(vec![], None, None, None, false, false, true)?; CastOutcome::for_request(request_text) } + CastIntent::Quest { goal } => dispatch_cast_quest(&plan, &goal)?, CastIntent::Quit => { let primary = theme::fg(theme::PRIMARY); let reset = theme::reset(); @@ -499,6 +502,285 @@ fn dispatch_harness_spell(plan: &CastPlan, harness_id: &str, prompt: &str) -> Re ) } +/// Event kind prefix Cast writes to the anchor session's event log so a +/// future `coven attach ` can detect that the session belongs to a +/// quest. Per the Phase 7 scope decision (first-session anchor), every +/// `cast.quest.*` event lives on phase-0's session; later phases still +/// emit their own `cast.summary` events on their own sessions. +const CAST_QUEST_STARTED: &str = "cast.quest.started"; +const CAST_QUEST_PHASE_STARTED: &str = "cast.quest.phase_started"; +const CAST_QUEST_PHASE_COMPLETED: &str = "cast.quest.phase_completed"; +const CAST_QUEST_ADVANCED: &str = "cast.quest.advanced"; +const CAST_QUEST_COMPLETED: &str = "cast.quest.completed"; + +/// Loop a deterministic Cast quest through its phases. Each iteration +/// renders the handoff card, gates the phase's sub-prompt, dispatches via +/// `dispatch_cast_launch`, then advances the quest with a summary built +/// from the session's `cast.summary` event. `cast.quest.*` events are +/// written to the anchor (first-phase) session as a re-attach aid; they +/// are best-effort and skipped silently when the daemon is not running +/// (no session id means no anchor and no ledger writes). +fn dispatch_cast_quest(plan: &CastPlan, goal: &str) -> Result { + let default_harness = plan.harness.map(|h| h.harness); + let mut quest = quest_from_goal(goal, default_harness); + let mut anchor_session_id: Option = None; + let mut completed_notes: Vec = Vec::new(); + let request_text = outcome_request_text(plan); + + while let Some(idx) = quest.current_index() { + println!(); + print!("{}", render_quest_handoff(&quest, idx)); + println!(); + + let phase_harness = match resolve_phase_harness(&quest.phases[idx], default_harness) { + Some(harness) => harness, + None => { + return Ok(quest_outcome( + &request_text, + &quest, + anchor_session_id.clone(), + completed_notes, + Some( + "No harness ready. Run `coven doctor`, then retry `/quest `." + .to_string(), + ), + Some("Quest paused — no harness available for the next phase.".to_string()), + )); + } + }; + + let phase_plan = quest_phase_plan(&quest, idx, phase_harness, goal)?; + + let mut reader = stdin_line_reader; + match evaluate_gate(&phase_plan, &mut reader)? { + GateOutcome::Cancelled { reason, next_step } => { + completed_notes.push(format!( + "Phase `{}` cancelled: {reason}", + quest.phases[idx].name + )); + return Ok(quest_outcome( + &request_text, + &quest, + anchor_session_id.clone(), + completed_notes, + next_step, + Some(format!( + "Quest cancelled at phase `{}`.", + quest.phases[idx].name + )), + )); + } + GateOutcome::Proceed => {} + } + + let phase_outcome = dispatch_cast_launch( + &phase_plan, + phase_harness.id(), + &quest.phases[idx].sub_prompt, + format!( + "Quest phase `{}` ({})", + quest.phases[idx].name, + phase_harness.label() + ), + )?; + + let phase_session_id = phase_outcome.session_id.clone(); + if anchor_session_id.is_none() { + if let Some(sid) = &phase_session_id { + anchor_session_id = Some(sid.clone()); + write_quest_event( + sid, + CAST_QUEST_STARTED, + serde_json::json!({ + "title": quest.title, + "goal": quest.goal, + "harness": phase_harness.id(), + "phases": quest + .phases + .iter() + .map(|p| p.name.clone()) + .collect::>(), + }), + ); + } + } + + if let Some(anchor) = anchor_session_id.as_deref() { + write_quest_event( + anchor, + CAST_QUEST_PHASE_STARTED, + serde_json::json!({ + "phase": quest.phases[idx].name, + "index": idx, + "session_id": phase_session_id, + "harness": phase_harness.id(), + }), + ); + } + + let summary = phase_summary_from_session(phase_session_id.as_deref()); + completed_notes.push(format_phase_note(&quest.phases[idx].name, &summary)); + + if let Some(anchor) = anchor_session_id.as_deref() { + write_quest_event( + anchor, + CAST_QUEST_PHASE_COMPLETED, + serde_json::json!({ + "phase": quest.phases[idx].name, + "index": idx, + "session_id": summary.session_id, + "exit_status": summary.exit_status, + "exit_code": summary.exit_code, + }), + ); + } + + let next = advance_quest(&mut quest, summary); + if let Some(anchor) = anchor_session_id.as_deref() { + write_quest_event( + anchor, + CAST_QUEST_ADVANCED, + serde_json::json!({ + "from_index": idx, + "next_index": next, + }), + ); + } + } + + if let Some(anchor) = anchor_session_id.as_deref() { + write_quest_event( + anchor, + CAST_QUEST_COMPLETED, + serde_json::json!({ + "title": quest.title, + "phase_count": quest.phases.len(), + }), + ); + } + + Ok(quest_outcome( + &request_text, + &quest, + anchor_session_id, + completed_notes, + Some( + "Use `/sessions` to inspect each phase, or `/attach ` to read the quest event log." + .to_string(), + ), + Some(format!("Quest `{}` complete.", quest.title)), + )) +} + +/// Each phase prefers the harness recorded on the phase (so a future UX +/// could let the user route phase N to a different harness) and falls back +/// to the quest-wide default. Returns `None` only when neither is set. +fn resolve_phase_harness( + phase: &QuestPhase, + default_harness: Option, +) -> Option { + phase.harness.or(default_harness) +} + +/// Synthesize a per-phase `CastPlan` so the existing safety gate can vet +/// the resolved sub-prompt before each launch. The plan is built from a +/// `HarnessSpell` intent so the classifier reads the *sub-prompt* content, +/// not the original quest goal verbatim. +fn quest_phase_plan( + quest: &Quest, + idx: usize, + harness: CastHarness, + goal: &str, +) -> Result { + let phase = &quest.phases[idx]; + let intent = CastIntent::HarnessSpell { + harness, + prompt: phase.sub_prompt.clone(), + }; + Ok( + build_plan(intent, || Some(harness))?.with_raw_spell(format!( + "/quest {goal} (phase {phase_num}/{total}: {name})", + phase_num = idx + 1, + total = quest.phases.len(), + name = phase.name, + )), + ) +} + +/// Build a `QuestPhaseSummary` from the session's `cast.summary` event. +/// Returns a default summary (no exit info) when the session id is absent +/// (local-PTY fallback) or the store cannot be opened. +fn phase_summary_from_session(session_id: Option<&str>) -> QuestPhaseSummary { + let Some(sid) = session_id else { + return QuestPhaseSummary::default(); + }; + let Ok(store_path) = coven_store_path() else { + return QuestPhaseSummary { + session_id: Some(sid.to_string()), + ..Default::default() + }; + }; + let Ok(conn) = store::open_store(&store_path) else { + return QuestPhaseSummary { + session_id: Some(sid.to_string()), + ..Default::default() + }; + }; + let Ok(events) = store::list_events(&conn, sid) else { + return QuestPhaseSummary { + session_id: Some(sid.to_string()), + ..Default::default() + }; + }; + let summary = find_cast_summary(&events); + QuestPhaseSummary { + session_id: Some(sid.to_string()), + exit_status: summary.as_ref().and_then(|s| s.status.clone()), + exit_code: summary.as_ref().and_then(|s| s.exit_code), + carried_context: Vec::new(), + } +} + +fn format_phase_note(name: &str, summary: &QuestPhaseSummary) -> String { + match (&summary.exit_status, summary.exit_code) { + (Some(status), Some(code)) => format!("Phase `{name}`: {status} (exit {code})"), + (Some(status), None) => format!("Phase `{name}`: {status}"), + (None, Some(code)) => format!("Phase `{name}`: exit {code}"), + (None, None) => format!("Phase `{name}`: complete"), + } +} + +fn quest_outcome( + request: &str, + quest: &Quest, + anchor_session_id: Option, + notes: Vec, + next_step: Option, + launched: Option, +) -> CastOutcome { + CastOutcome { + request: request.to_string(), + launched: launched.or_else(|| Some(format!("Quest `{}`", quest.title))), + session_id: anchor_session_id, + next_step, + notes, + } +} + +/// Best-effort writer for `cast.quest.*` events. Failures are intentionally +/// swallowed: the harness output is the source of truth; these events are +/// reconstruction aids. The daemon may have a different connection open +/// simultaneously; SQLite's WAL/locking handles that. +fn write_quest_event(session_id: &str, kind: &str, payload: serde_json::Value) { + let Ok(store_path) = coven_store_path() else { + return; + }; + let Ok(conn) = store::open_store(&store_path) else { + return; + }; + let _ = store::insert_json_event(&conn, session_id, kind, &payload, ¤t_timestamp()); +} + /// Launch a project-scoped session and follow its events into the Cast TUI. /// /// Phase 2 prefers the daemon-backed path so the follower can stream events @@ -856,7 +1138,8 @@ fn attach_via_daemon( // For completed/replayed sessions, surface the original Cast summary so // the user can see what the prior run was about. Live attaches just wrote // the summary themselves, so showing it again would only echo the exit - // line we already printed. + // line we already printed. We also use the same history query to detect + // a quest anchor (Phase 7 minimal re-attach aid — detect + inform). if !is_live { let history = client.list_events(ChatEventQuery { session_id: &session.id, @@ -867,6 +1150,11 @@ fn attach_via_daemon( { notes.push(note); } + if let Some(note) = cast::find_cast_quest_info(&history) + .and_then(|info| cast::format_quest_attach_note(&info)) + { + notes.push(note); + } } let launched = if is_live { diff --git a/docs/design/cast-quest-flow.md b/docs/design/cast-quest-flow.md index b66e8e6..7328073 100644 --- a/docs/design/cast-quest-flow.md +++ b/docs/design/cast-quest-flow.md @@ -139,5 +139,14 @@ This module deliberately stops short of the shell wiring so Phase 5's contract - [x] `skip_phase` rolls the cursor past a phase the user judged unnecessary. - [x] `render_quest_handoff` shows the source phase, prior status, carried context, target harness, and the sub-prompt text the harness will see. - [x] 15 new unit tests cover the composer, advancer, edits, skip, failure framing, and the render card (incl. long sub-prompts and quest exhaustion). -- [ ] Shell wiring for `/quest ` — Phase 6. -- [ ] Quest event ledger entries (`cast.quest.*`) so re-attach can reconstruct state — Phase 6. +- [x] Shell wiring for `/quest ` — landed in Phase 7. `dispatch_cast_quest` in `tui/shell.rs` loops phases, gates each sub-prompt, dispatches via `dispatch_cast_launch`, and advances on each `cast.summary`-derived `QuestPhaseSummary`. +- [x] Quest event ledger entries (`cast.quest.*`) — landed in Phase 7. `cast.quest.{started, phase_started, phase_completed, advanced, completed}` are written to the first phase's session (the "anchor"). `cast::find_cast_quest_info` decodes them so `/attach ` surfaces a one-line quest-anchor note alongside the cast.summary. + +## 8. Deferred to a later phase + +Per the Phase 7 scope decision, four things did **not** land and are tracked for the next slice: + +- **Edit / skip UX per phase.** Phase 7 auto-advances every phase. `set_phase_sub_prompt`, `skip_phase`, `compose_sub_prompt`, `QuestHandoff`, and `QuestPhaseStatus::Skipped` are still in the crate surface but reachable only through tests; their `#[allow(dead_code)]` notes call this out. +- **Full re-attach state rebuild.** `/attach ` currently prints a one-line note ("phase N/M, in progress / complete"). Replaying `cast.quest.*` events to reconstruct a `Quest` and re-render the next handoff card is the long pole and a separate phase. +- **Local-PTY fallback ledger.** Quest event writes are best-effort and skipped silently when `dispatch_cast_launch` falls back to the synchronous local PTY path (no daemon, no session id, no anchor). Re-attach will not find a quest there. +- **`QuestPhaseStatus::Running` transition.** The shell loop transitions Pending → Complete directly (synchronous dispatch). A future async / detached-quest UX would set Running between the two.