From 9fea7650cdbd598db1ad67f8bdd002cf56014ca1 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 08:52:15 +1100 Subject: [PATCH 01/19] feat: add lean game mode with DAG-based simulation navigation New experimental game mode focused on efficient spec refinement through simulated software play. Each output generates 2 new child nodes plus shortcut edges to existing nodes, forming a DAG. Batch pre-generation (depth 3) keeps navigation instant, and background spec updates refine the spec as the player navigates. Includes TUI panel, MCP tools, and entropy-guided interaction selection. --- crates/spec-forest-tui/src/action.rs | 18 + crates/spec-forest-tui/src/app.rs | 344 ++++++++++- crates/spec-forest-tui/src/input.rs | 45 ++ crates/spec-forest-tui/src/lean_state.rs | 68 +++ crates/spec-forest-tui/src/lib.rs | 1 + crates/spec-forest-tui/src/ui.rs | 2 + crates/spec-forest-tui/src/ui/help_popup.rs | 13 + crates/spec-forest-tui/src/ui/lean_game.rs | 359 ++++++++++++ .../src/ui/sim_channel_picker.rs | 19 +- crates/spec-forest/src/simulation.rs | 6 + .../spec-forest/src/simulation/lean_graph.rs | 422 ++++++++++++++ .../src/simulation/lean_orchestrate.rs | 544 ++++++++++++++++++ .../spec-forest/src/simulation/lean_prompt.rs | 349 +++++++++++ .../spec-forest/src/simulation/lean_types.rs | 102 ++++ crates/spec-forest/src/simulation/runner.rs | 154 ++++- crates/spec-forest/src/simulation/session.rs | 26 + crates/spec-forest/src/tool_types.rs | 26 + crates/spec-forest/src/tools.rs | 242 ++++++++ 18 files changed, 2711 insertions(+), 29 deletions(-) create mode 100644 crates/spec-forest-tui/src/lean_state.rs create mode 100644 crates/spec-forest-tui/src/ui/lean_game.rs create mode 100644 crates/spec-forest/src/simulation/lean_graph.rs create mode 100644 crates/spec-forest/src/simulation/lean_orchestrate.rs create mode 100644 crates/spec-forest/src/simulation/lean_prompt.rs create mode 100644 crates/spec-forest/src/simulation/lean_types.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 54ef21f..6d1666e 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -108,6 +108,7 @@ pub enum Action { SimChannelToggleWholeSpec, SimChannelToggleExploreCode, SimChannelToggleGameMode, + SimChannelToggleLeanMode, SimChannelConfirm, SimChannelCancel, @@ -158,6 +159,23 @@ pub enum Action { GameRejectCancel, GameToggleUpdateLog, + // Lean game mode + LeanSelectUp, + LeanSelectDown, + LeanConfirm, + LeanGoBack, + LeanEnterQuery, + LeanEnterModify, + LeanToggleUpdateLog, + LeanScrollUp, + LeanScrollDown, + LeanInputChar(char), + LeanInputBackspace, + LeanInputSubmit, + LeanInputCancel, + LeanInputNewline, + LeanEnd, + // Notification / session picker OpenSessionPicker, SessionPickerUp, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 2580f83..65318b6 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -93,7 +93,10 @@ pub struct App { pub sim_consume_whole_spec: bool, pub sim_explore_code: bool, pub sim_game_mode: bool, + pub sim_lean_mode: bool, pub sim_scenario_input: String, + // Lean game + pub lean_state: Option, // Background simulation notifications pub background_sims: Vec, pub sim_notifications: Vec, @@ -126,6 +129,7 @@ pub enum Screen { SimScenario { spec_id: String, node_id: String }, Simulation { spec_id: String, session_id: String }, ExploreDepthPicker { spec_id: String }, + LeanGame { spec_id: String, session_id: String }, } #[derive(Clone)] @@ -203,7 +207,9 @@ impl App { sim_consume_whole_spec: false, sim_explore_code: false, sim_game_mode: false, + sim_lean_mode: false, sim_scenario_input: String::new(), + lean_state: None, background_sims: Vec::new(), sim_notifications: Vec::new(), session_picker: None, @@ -352,6 +358,17 @@ impl App { self.execute_action(action).await; return; } + // Lean game screen needs modifiers for Shift+Enter + if matches!(self.screen, Screen::LeanGame { .. }) { + let in_input_mode = self + .lean_state + .as_ref() + .map(|s| s.in_input_mode()) + .unwrap_or(false); + let action = input::map_lean_game_key(key, modifiers, in_input_mode); + self.execute_action(action).await; + return; + } // Simulation screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::Simulation { .. }) { let (mode, game_mode, reject_mode, breadcrumb_focused) = self @@ -836,6 +853,15 @@ impl App { } Action::SimChannelToggleGameMode => { self.sim_game_mode = !self.sim_game_mode; + if self.sim_game_mode { + self.sim_lean_mode = false; // mutually exclusive + } + } + Action::SimChannelToggleLeanMode => { + self.sim_lean_mode = !self.sim_lean_mode; + if self.sim_lean_mode { + self.sim_game_mode = false; // mutually exclusive + } } Action::SimChannelToggleExploreCode => { if let Screen::SimChannelPicker { ref spec_id, .. } = self.screen { @@ -1383,6 +1409,155 @@ impl App { } } + // ── Lean game mode actions ──────────────────────────────── + Action::LeanSelectUp => { + if let Some(ref mut lean) = self.lean_state { + if lean.selected_interaction > 0 { + lean.selected_interaction -= 1; + } + } + } + Action::LeanSelectDown => { + if let Some(ref mut lean) = self.lean_state { + if !lean.interactions.is_empty() + && lean.selected_interaction < lean.interactions.len() - 1 + { + lean.selected_interaction += 1; + } + } + } + Action::LeanConfirm => { + if let Some(ref lean) = self.lean_state { + if !lean.interactions.is_empty() && !lean.processing { + let session_id = lean.session_id.clone(); + let edge_index = lean.selected_interaction; + let state = self.state.clone(); + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_navigate( + state, + session_id, + edge_index, + ) + .await; + }); + } + } + } + Action::LeanGoBack => { + if let Some(ref lean) = self.lean_state { + if lean.can_go_back { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_go_back( + &self.state, + &lean.session_id, + ); + } + } + } + Action::LeanEnterQuery => { + if let Some(ref mut lean) = self.lean_state { + lean.query_mode = true; + lean.query_input.clear(); + } + } + Action::LeanEnterModify => { + if let Some(ref mut lean) = self.lean_state { + lean.modify_mode = true; + lean.modify_input.clear(); + } + } + Action::LeanToggleUpdateLog => { + if let Some(ref mut lean) = self.lean_state { + lean.show_update_log = !lean.show_update_log; + } + } + Action::LeanScrollUp => { + if let Some(ref mut lean) = self.lean_state { + lean.scroll_offset = lean.scroll_offset.saturating_sub(5); + } + } + Action::LeanScrollDown => { + if let Some(ref mut lean) = self.lean_state { + lean.scroll_offset += 5; + } + } + Action::LeanInputChar(c) => { + if let Some(ref mut lean) = self.lean_state { + if lean.query_mode { + lean.query_input.push(c); + } else if lean.modify_mode { + lean.modify_input.push(c); + } + } + } + Action::LeanInputBackspace => { + if let Some(ref mut lean) = self.lean_state { + if lean.query_mode { + lean.query_input.pop(); + } else if lean.modify_mode { + lean.modify_input.pop(); + } + } + } + Action::LeanInputNewline => { + if let Some(ref mut lean) = self.lean_state { + if lean.query_mode { + lean.query_input.push('\n'); + } else if lean.modify_mode { + lean.modify_input.push('\n'); + } + } + } + Action::LeanInputSubmit => { + if let Some(ref mut lean) = self.lean_state { + if lean.query_mode { + let question = lean.query_input.clone(); + lean.query_mode = false; + lean.query_input.clear(); + let session_id = lean.session_id.clone(); + let state = self.state.clone(); + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_query( + state, + session_id, + question, + ) + .await; + }); + } else if lean.modify_mode { + let modification = lean.modify_input.clone(); + lean.modify_mode = false; + lean.modify_input.clear(); + let session_id = lean.session_id.clone(); + let state = self.state.clone(); + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_modify( + state, + session_id, + modification, + ) + .await; + }); + } + } + } + Action::LeanInputCancel => { + if let Some(ref mut lean) = self.lean_state { + lean.query_mode = false; + lean.modify_mode = false; + lean.query_input.clear(); + lean.modify_input.clear(); + } + } + Action::LeanEnd => { + if let Screen::LeanGame { ref spec_id, ref session_id } = self.screen { + let spec_id = spec_id.clone(); + let session_id = session_id.clone(); + self.state.remove_sim_session(&session_id); + self.lean_state = None; + self.screen = Screen::SpecView { spec_id }; + } + } + Action::ToggleHelp => { self.show_help = !self.show_help; } @@ -2346,6 +2521,10 @@ impl App { if let Screen::Simulation { ref session_id, .. } = self.screen { self.poll_sim_status(session_id.clone()); } + // Poll lean game session + if let Screen::LeanGame { ref session_id, .. } = self.screen { + self.poll_lean_status(session_id.clone()); + } // Always poll background simulation sessions for notifications self.poll_background_sims(); @@ -2595,6 +2774,91 @@ impl App { } } + fn poll_lean_status(&mut self, session_id: String) { + if let Some(ref mut lean) = self.lean_state { + lean.tick += 1; + if let Some(status) = self.state.get_sim_session_status(&session_id) { + match status { + spec_forest::simulation::SimStatus::Idle => { + if lean.processing { + lean.processing = false; + // Check for pending report. + if let Some(report) = + self.state.take_sim_pending_report(&session_id) + { + lean.report_overlay = + Some(crate::simulation::ReportOverlay { + explanation: report.explanation, + refs: report.refs, + }); + } + } + // Always sync from session state. + if let Some(session) = self.state.get_sim_session(&session_id) { + if let Some(ref graph) = session.lean_graph { + if let Some(ref current_id) = session.lean_current_node_id { + // Update channel contents. + if let Some(node) = graph.get_node(current_id) { + lean.channel_contents = node.channels.clone(); + } + // Update interactions from edges. + lean.interactions = graph + .get_edges(current_id) + .iter() + .map(|edge| { + let entropy = if edge.edge_kind + != spec_forest::simulation::LeanEdgeKind::Leaf + { + graph + .get_node(&edge.target_node_id) + .map(|n| n.entropy_hint) + .unwrap_or(0.0) + } else { + 0.5 + }; + crate::lean_state::LeanInteractionView { + label: edge.label.clone(), + edge_kind: edge.edge_kind, + entropy_hint: entropy, + } + }) + .collect(); + // Clamp selected interaction. + if lean.selected_interaction >= lean.interactions.len() + && !lean.interactions.is_empty() + { + lean.selected_interaction = 0; + } + } + // Breadcrumbs. + let crumbs = graph + .collect_breadcrumbs(&session.lean_navigation_path); + lean.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); + lean.can_go_back = session.lean_navigation_path.len() > 1; + } + lean.pregenerating = session.lean_generating; + lean.game_spec_updates = + session.game_spec_updates.clone(); + } + } + spec_forest::simulation::SimStatus::Processing => { + lean.processing = true; + } + spec_forest::simulation::SimStatus::Error(ref e) => { + lean.processing = false; + self.message = Some(format!("Lean game error: {e}")); + } + spec_forest::simulation::SimStatus::Ended => { + lean.processing = false; + } + } + } + } + } + async fn start_simulation( &mut self, spec_id: String, @@ -2621,19 +2885,34 @@ impl App { self.state.set_sim_session(session); self.state.update_sim_session(&session_id, |s| { s.game_mode = self.sim_game_mode; + s.lean_mode = self.sim_lean_mode; }); - let mut sim_state = crate::simulation::SimulationState::new( - session_id.clone(), - spec_id.clone(), - channels.clone(), - ); - sim_state.game_mode = self.sim_game_mode; - self.sim_state = Some(sim_state); - self.screen = Screen::Simulation { - spec_id: spec_id.clone(), - session_id: session_id.clone(), - }; + if self.sim_lean_mode { + // Lean game mode: use dedicated state and screen. + let lean_state = crate::lean_state::LeanGameState::new( + session_id.clone(), + spec_id.clone(), + channels.clone(), + ); + self.lean_state = Some(lean_state); + self.screen = Screen::LeanGame { + spec_id: spec_id.clone(), + session_id: session_id.clone(), + }; + } else { + let mut sim_state = crate::simulation::SimulationState::new( + session_id.clone(), + spec_id.clone(), + channels.clone(), + ); + sim_state.game_mode = self.sim_game_mode; + self.sim_state = Some(sim_state); + self.screen = Screen::Simulation { + spec_id: spec_id.clone(), + session_id: session_id.clone(), + }; + } // Spawn the initial simulation turn in background let state = self.state.clone(); @@ -2659,21 +2938,36 @@ impl App { if let Some(ref mut sim) = self.sim_state { sim.processing = true; } + if let Some(ref mut lean) = self.lean_state { + lean.processing = true; + } - tokio::spawn(async move { - commands::run_sim_initial_turn( - state, - sid, - spec_id_for_task, - model, - channels_for_task, - focus_node_for_task, - scenario, - consume_whole_spec, - directory, - ) - .await; - }); + if self.sim_lean_mode { + // Lean game: use dedicated orchestration. + let state = self.state.clone(); + let sid = session_id.clone(); + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_initial_turn( + state, sid, + ) + .await; + }); + } else { + tokio::spawn(async move { + commands::run_sim_initial_turn( + state, + sid, + spec_id_for_task, + model, + channels_for_task, + focus_node_for_task, + scenario, + consume_whole_spec, + directory, + ) + .await; + }); + } } async fn submit_sim_input(&mut self) { diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index c316e22..5c1dd0d 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -34,6 +34,50 @@ pub fn map_key( Screen::SpecMembers { .. } => Action::Noop, // handled by map_spec_members_key Screen::SimScenario { .. } => Action::Noop, // handled by map_sim_scenario_key Screen::Simulation { .. } => Action::Noop, // handled by map_sim_key + Screen::LeanGame { .. } => Action::Noop, // handled by map_lean_game_key + } +} + +/// Maps keys for the lean game screen. Needs modifiers for Shift+Enter. +pub fn map_lean_game_key( + key: KeyCode, + modifiers: KeyModifiers, + in_input_mode: bool, +) -> Action { + if in_input_mode { + return map_lean_input_key(key, modifiers); + } + map_lean_normal_key(key) +} + +fn map_lean_normal_key(key: KeyCode) -> Action { + match key { + KeyCode::Up | KeyCode::Char('k') => Action::LeanSelectUp, + KeyCode::Down | KeyCode::Char('j') => Action::LeanSelectDown, + KeyCode::Char('1') => Action::LeanSelectUp, // Select first + KeyCode::Char('2') => Action::LeanSelectDown, // Select second + KeyCode::Enter => Action::LeanConfirm, + KeyCode::Backspace => Action::LeanGoBack, + KeyCode::Char('i') => Action::LeanEnterQuery, + KeyCode::Char('m') => Action::LeanEnterModify, + KeyCode::Char('u') => Action::LeanToggleUpdateLog, + KeyCode::Char('Q') => Action::LeanEnd, + KeyCode::Esc => Action::LeanEnd, + KeyCode::PageUp => Action::LeanScrollUp, + KeyCode::PageDown => Action::LeanScrollDown, + _ => Action::Noop, + } +} + +fn map_lean_input_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + match key { + KeyCode::Esc => Action::LeanInputCancel, + KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::LeanInputSubmit, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => Action::LeanInputSubmit, + KeyCode::Backspace => Action::LeanInputBackspace, + KeyCode::Char(c) => Action::LeanInputChar(c), + KeyCode::Enter => Action::LeanInputNewline, + _ => Action::Noop, } } @@ -174,6 +218,7 @@ fn map_sim_channel_picker_key(key: KeyCode) -> Action { KeyCode::Tab => Action::SimChannelToggleWholeSpec, KeyCode::BackTab => Action::SimChannelToggleExploreCode, KeyCode::Char('g') => Action::SimChannelToggleGameMode, + KeyCode::Char('l') => Action::SimChannelToggleLeanMode, KeyCode::Enter => Action::SimChannelConfirm, KeyCode::Esc => Action::SimChannelCancel, _ => Action::Noop, diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs new file mode 100644 index 0000000..8e1314c --- /dev/null +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -0,0 +1,68 @@ +use spec_forest::simulation::{ + ChannelContent, GameSpecUpdate, LeanEdgeKind, SimChannel, +}; +use std::collections::HashMap; + +use crate::simulation::ReportOverlay; + +/// TUI-side state for the lean game screen. +pub struct LeanGameState { + pub session_id: String, + pub spec_id: String, + pub channels: Vec, + pub channel_contents: HashMap, + pub interactions: Vec, + pub selected_interaction: usize, + pub breadcrumbs: Vec<(String, String)>, + pub can_go_back: bool, + pub processing: bool, + pub pregenerating: bool, + pub tick: u64, + // Input modes + pub query_mode: bool, + pub query_input: String, + pub modify_mode: bool, + pub modify_input: String, + pub report_overlay: Option, + pub show_update_log: bool, + pub game_spec_updates: Vec, + pub scroll_offset: usize, +} + +/// View model for a single interaction in the lean game panel. +pub struct LeanInteractionView { + pub label: String, + pub edge_kind: LeanEdgeKind, + pub entropy_hint: f64, +} + +impl LeanGameState { + pub fn new(session_id: String, spec_id: String, channels: Vec) -> Self { + Self { + session_id, + spec_id, + channels, + channel_contents: HashMap::new(), + interactions: Vec::new(), + selected_interaction: 0, + breadcrumbs: Vec::new(), + can_go_back: false, + processing: false, + pregenerating: false, + tick: 0, + query_mode: false, + query_input: String::new(), + modify_mode: false, + modify_input: String::new(), + report_overlay: None, + show_update_log: false, + game_spec_updates: Vec::new(), + scroll_offset: 0, + } + } + + /// Whether we're in any text input mode. + pub fn in_input_mode(&self) -> bool { + self.query_mode || self.modify_mode + } +} diff --git a/crates/spec-forest-tui/src/lib.rs b/crates/spec-forest-tui/src/lib.rs index 6c72a02..751fb71 100644 --- a/crates/spec-forest-tui/src/lib.rs +++ b/crates/spec-forest-tui/src/lib.rs @@ -5,6 +5,7 @@ pub mod dir_browser; pub mod editor; pub mod error; pub mod input; +pub mod lean_state; pub mod log_buffer; pub mod notification; pub mod simulation; diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 74b74d0..3cf04d5 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -4,6 +4,7 @@ mod help_popup; mod depth_picker; mod dir_browser; mod input_screen; +mod lean_game; pub(crate) mod log_panel; mod model_config; mod notification_bar; @@ -41,6 +42,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::SimScenario { .. } => sim_scenario::render(app, frame), Screen::Simulation { .. } => simulation::render(app, frame), Screen::ExploreDepthPicker { .. } => depth_picker::render(app, frame), + Screen::LeanGame { .. } => lean_game::render(app, frame), } // Global overlays (drawn last = on top via painter's order) diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs index 1ecf603..f8a47e4 100644 --- a/crates/spec-forest-tui/src/ui/help_popup.rs +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -315,6 +315,19 @@ fn help_sections(app: &App) -> Vec { ], }] } + Screen::LeanGame { .. } => vec![HelpSection { + title: "Lean Game", + bindings: vec![ + ("Up/Down", "Select interaction"), + ("Enter", "Confirm interaction"), + ("Backspace", "Go back"), + ("i", "Query (ask why)"), + ("m", "Modify output"), + ("u", "Toggle update log"), + ("PgUp/PgDn", "Scroll output"), + ("Q/Esc", "End session"), + ], + }], } } diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs new file mode 100644 index 0000000..2396838 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -0,0 +1,359 @@ +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::Frame; +use spec_forest::simulation::LeanEdgeKind; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let lean = match &app.lean_state { + Some(s) => s, + None => { + let msg = Paragraph::new("No lean game session active.") + .block(Block::default().borders(Borders::ALL).title(" Lean Game ")); + frame.render_widget(msg, frame.area()); + return; + } + }; + + let area = frame.area(); + + // Calculate interaction panel height based on number of interactions. + let interaction_lines = lean.interactions.len().max(2) as u16 + 2; // +2 for borders + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Breadcrumbs + Constraint::Min(5), // Output area + Constraint::Length(interaction_lines), // Interactions + Constraint::Length(1), // Status bar + ]) + .split(area); + + // ── Breadcrumbs ───────────────────────────────────────────────── + render_breadcrumbs(app, frame, chunks[0]); + + // ── Output area ───────────────────────────────────────────────── + render_output(app, frame, chunks[1]); + + // ── Interactions ──────────────────────────────────────────────── + render_interactions(app, frame, chunks[2]); + + // ── Status bar ────────────────────────────────────────────────── + render_status_bar(app, frame, chunks[3]); + + // ── Overlays ──────────────────────────────────────────────────── + if lean.query_mode || lean.modify_mode { + render_input_overlay(app, frame); + } + if lean.report_overlay.is_some() { + render_report_overlay(app, frame); + } + if lean.show_update_log { + render_update_log_overlay(app, frame); + } +} + +fn render_breadcrumbs(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let lean = app.lean_state.as_ref().unwrap(); + + let mut spans = vec![Span::styled( + " Lean Game ", + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + )]; + + for (i, (_, label)) in lean.breadcrumbs.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" > ", Style::default().fg(Color::DarkGray))); + } + let style = if i == lean.breadcrumbs.len() - 1 { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + spans.push(Span::styled(label.clone(), style)); + } + + let paragraph = Paragraph::new(Line::from(spans)) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(paragraph, area); +} + +fn render_output(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let lean = app.lean_state.as_ref().unwrap(); + + // Build combined output from all channels. + let mut lines: Vec = Vec::new(); + + // UI channel gets primary display. + if let Some(content) = lean.channel_contents.get("ui") { + for line in content.text.lines() { + lines.push(Line::from(line.to_string())); + } + } + + // Other channels rendered below with prefixes. + for (key, content) in &lean.channel_contents { + if key == "ui" || content.text.is_empty() { + continue; + } + lines.push(Line::from("")); + for line in content.text.lines() { + let prefix = match key.as_str() { + "network" => "[NET] ", + "audio" => "[AUD] ", + "errors" => "[ERR] ", + "logs" => "[LOG] ", + _ => "", + }; + let style = match key.as_str() { + "errors" => Style::default().fg(Color::Red), + "network" => Style::default().fg(Color::Blue), + "audio" => Style::default().fg(Color::Magenta), + "logs" => Style::default().fg(Color::DarkGray), + _ => Style::default(), + }; + lines.push(Line::from(Span::styled( + format!("{prefix}{line}"), + style, + ))); + } + } + + let title = if lean.processing { + " Output (generating...) " + } else { + " Output " + }; + + let border_color = if lean.processing { + Color::Yellow + } else { + Color::Cyan + }; + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(border_color)), + ) + .wrap(Wrap { trim: false }) + .scroll((lean.scroll_offset as u16, 0)); + + frame.render_widget(paragraph, area); +} + +fn render_interactions(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let lean = app.lean_state.as_ref().unwrap(); + + let mut lines: Vec = Vec::new(); + + if lean.interactions.is_empty() { + if lean.processing { + lines.push(Line::from(Span::styled( + " Generating interactions...", + Style::default().fg(Color::Yellow), + ))); + } else { + lines.push(Line::from(Span::styled( + " No interactions available", + Style::default().fg(Color::DarkGray), + ))); + } + } else { + for (i, interaction) in lean.interactions.iter().enumerate() { + let is_selected = i == lean.selected_interaction; + + let marker = if is_selected { "► " } else { " " }; + + // Edge kind indicator. + let (kind_symbol, kind_color) = match interaction.edge_kind { + LeanEdgeKind::Generative => ("●", Color::Green), + LeanEdgeKind::Leaf => ("○", Color::Yellow), + LeanEdgeKind::Shortcut => ("↩", Color::DarkGray), + }; + + // Entropy-based label coloring. + let label_color = if interaction.entropy_hint > 0.7 { + Color::Yellow // High entropy = interesting + } else if is_selected { + Color::Cyan + } else { + Color::White + }; + + let label_style = if is_selected { + Style::default().fg(label_color).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(label_color) + }; + + lines.push(Line::from(vec![ + Span::styled( + format!("{marker}{}. ", i + 1), + if is_selected { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }, + ), + Span::styled(interaction.label.clone(), label_style), + Span::raw(" "), + Span::styled(kind_symbol, Style::default().fg(kind_color)), + ])); + } + } + + let pregen_indicator = if lean.pregenerating { " ⟳" } else { "" }; + let title = format!(" Interactions{pregen_indicator} "); + + let paragraph = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(title)); + frame.render_widget(paragraph, area); +} + +fn render_status_bar(_app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let items = vec![ + ("↑↓", "select"), + ("Enter", "go"), + ("Bksp", "back"), + ("i", "query"), + ("m", "modify"), + ("u", "updates"), + ("Q", "quit"), + ]; + + let spans: Vec = items + .iter() + .enumerate() + .flat_map(|(i, (key, desc))| { + let mut v = vec![ + Span::styled( + format!(" {key}"), + Style::default().fg(Color::Yellow), + ), + Span::styled( + format!(" {desc}"), + Style::default().fg(Color::DarkGray), + ), + ]; + if i < items.len() - 1 { + v.push(Span::styled(" │", Style::default().fg(Color::DarkGray))); + } + v + }) + .collect(); + + let paragraph = Paragraph::new(Line::from(spans)); + frame.render_widget(paragraph, area); +} + +fn render_input_overlay(app: &App, frame: &mut Frame) { + let lean = app.lean_state.as_ref().unwrap(); + let area = frame.area(); + + let overlay_height = 5; + let overlay_area = ratatui::layout::Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(overlay_height + 1), + width: area.width.saturating_sub(2), + height: overlay_height, + }; + + frame.render_widget(Clear, overlay_area); + + let (title, input) = if lean.query_mode { + (" Query (Ctrl+S to submit, Esc to cancel) ", &lean.query_input) + } else { + (" Modify (Ctrl+S to submit, Esc to cancel) ", &lean.modify_input) + }; + + let paragraph = Paragraph::new(input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay_area); +} + +fn render_report_overlay(app: &App, frame: &mut Frame) { + let lean = app.lean_state.as_ref().unwrap(); + let report = match &lean.report_overlay { + Some(r) => r, + None => return, + }; + + let area = frame.area(); + let overlay = super::common::centered_rect( + area.width.saturating_sub(4).min(80), + area.height.saturating_sub(4).min(20), + area, + ); + + frame.render_widget(Clear, overlay); + + let paragraph = Paragraph::new(report.explanation.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Report (any key to close) ") + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay); +} + +fn render_update_log_overlay(app: &App, frame: &mut Frame) { + let lean = app.lean_state.as_ref().unwrap(); + + let area = frame.area(); + let overlay = super::common::centered_rect( + area.width.saturating_sub(4).min(80), + area.height.saturating_sub(4).min(20), + area, + ); + + frame.render_widget(Clear, overlay); + + let mut lines: Vec = Vec::new(); + if lean.game_spec_updates.is_empty() { + lines.push(Line::from(Span::styled( + "No spec updates yet.", + Style::default().fg(Color::DarkGray), + ))); + } else { + for (i, update) in lean.game_spec_updates.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!("{}. ", i + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled(&update.description, Style::default().fg(Color::White)), + ])); + if !update.node_id.is_empty() { + lines.push(Line::from(Span::styled( + format!(" Node: {}", update.node_id), + Style::default().fg(Color::DarkGray), + ))); + } + } + } + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Spec Updates (u to close) ") + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay); +} diff --git a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs index b0e51ed..a3d90e6 100644 --- a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs +++ b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs @@ -18,6 +18,7 @@ pub fn render(app: &App, frame: &mut Frame) { Constraint::Length(3), // whole spec toggle Constraint::Length(3), // explore code toggle Constraint::Length(3), // game mode toggle + Constraint::Length(3), // lean mode toggle Constraint::Length(3), // footer ]) .split(frame.area()); @@ -121,6 +122,20 @@ pub fn render(app: &App, frame: &mut Frame) { .block(Block::default().borders(Borders::ALL)); frame.render_widget(game_mode, chunks[3]); + // Lean mode toggle + let lean_checkbox = if app.sim_lean_mode { "[x]" } else { "[ ]" }; + let lean_style = if app.sim_lean_mode { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + let lean_mode = Paragraph::new(Line::from(Span::styled( + format!(" {lean_checkbox} Lean Game — binary choices, batch generation, low latency"), + lean_style, + ))) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(lean_mode, chunks[4]); + let selected_count = app.sim_channel_selection.len(); let mut footer_spans = vec![ Span::styled( @@ -129,12 +144,12 @@ pub fn render(app: &App, frame: &mut Frame) { ), ]; let badge_line = super::common::render_footer_line( - &[("Space", "Toggle"), ("g", "Game"), ("Enter", "Start"), ("Esc", "Cancel")], + &[("Space", "Toggle"), ("g", "Game"), ("l", "Lean"), ("Enter", "Start"), ("Esc", "Cancel")], None, ); footer_spans.extend(badge_line.spans); let footer = Paragraph::new(Line::from(footer_spans)) .block(Block::default().borders(Borders::ALL)); - frame.render_widget(footer, chunks[4]); + frame.render_widget(footer, chunks[5]); } diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 34f36ee..a40af0f 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -1,3 +1,7 @@ +pub mod lean_graph; +pub mod lean_orchestrate; +pub mod lean_prompt; +pub mod lean_types; mod prompt; pub mod orchestrate; pub mod runner; @@ -14,6 +18,8 @@ pub use prompt::{ }; pub use session::{SimChannel, SimSession, SimStatus}; pub use tree::BreadcrumbEntry; +pub use lean_graph::LeanGraph; +pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanNode}; pub use types::{ ChannelContent, Decision, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, GameTreeRoot, NodeRef, PredictedInteraction, SimInput, SimReport, SimReportResponse, diff --git a/crates/spec-forest/src/simulation/lean_graph.rs b/crates/spec-forest/src/simulation/lean_graph.rs new file mode 100644 index 0000000..5e25f93 --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -0,0 +1,422 @@ +use std::collections::HashMap; + +use super::lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanNode}; +use super::tree::BreadcrumbEntry; +use super::types::SimInput; +use uuid::Uuid; + +/// A directed acyclic graph of lean game nodes and edges. +/// +/// Each node has exactly 2 generative outgoing edges (plus any number of +/// shortcut edges to existing nodes). Leaf edges are generative edges whose +/// targets haven't been generated yet. +#[derive(Debug, Clone)] +pub struct LeanGraph { + /// All nodes keyed by node_id (UUID). + pub nodes: HashMap, + /// Adjacency list: outgoing edges keyed by source node_id. + pub edges: HashMap>, + /// The root node_id (initial output). + pub root_id: String, +} + +impl LeanGraph { + /// Create a new graph from an initial batch response. + /// + /// Assigns UUIDs to all new nodes and wires up edges. + pub fn from_batch(batch: LeanBatchResponse) -> Self { + let mut graph = LeanGraph { + nodes: HashMap::new(), + edges: HashMap::new(), + root_id: String::new(), + }; + + // Map AI-local IDs to UUIDs. + let mut id_map: HashMap = HashMap::new(); + for (i, mut node) in batch.nodes.into_iter().enumerate() { + let uuid = Uuid::new_v4().to_string(); + id_map.insert(node.node_id.clone(), uuid.clone()); + node.node_id = uuid.clone(); + if i == 0 { + graph.root_id = uuid.clone(); + } + graph.nodes.insert(uuid, node); + } + + // Wire up edges. + for edge in batch.edges { + let from_id = id_map.get(&edge.from).cloned().unwrap_or(edge.from.clone()); + let to_id = if edge.is_shortcut { + // Shortcut: `to` is already a UUID in the existing graph. + edge.to.clone() + } else { + id_map.get(&edge.to).cloned().unwrap_or(edge.to.clone()) + }; + + let edge_kind = if edge.is_shortcut { + LeanEdgeKind::Shortcut + } else if graph.nodes.contains_key(&to_id) { + LeanEdgeKind::Generative + } else { + LeanEdgeKind::Leaf + }; + + let lean_edge = LeanEdge { + label: edge.label, + input: edge.input, + target_node_id: to_id, + edge_kind, + }; + + graph.edges.entry(from_id).or_default().push(lean_edge); + } + + graph + } + + /// Merge a new batch into the existing graph. + /// + /// New nodes get UUIDs. Shortcut edges resolve against existing graph nodes. + /// The `anchor_node_id` is the graph node from which this batch was generated; + /// the batch's root node replaces the leaf edge target pointing to it. + pub fn merge_batch(&mut self, batch: LeanBatchResponse, anchor_node_id: &str) { + // Map AI-local IDs to UUIDs. + let mut id_map: HashMap = HashMap::new(); + let mut batch_root_uuid = String::new(); + + for (i, mut node) in batch.nodes.into_iter().enumerate() { + let uuid = Uuid::new_v4().to_string(); + id_map.insert(node.node_id.clone(), uuid.clone()); + node.node_id = uuid.clone(); + if i == 0 { + batch_root_uuid = uuid.clone(); + } + self.nodes.insert(uuid, node); + } + + // Wire up new edges. + for edge in batch.edges { + let from_id = id_map.get(&edge.from).cloned().unwrap_or(edge.from.clone()); + let to_id = if edge.is_shortcut { + edge.to.clone() + } else { + id_map.get(&edge.to).cloned().unwrap_or(edge.to.clone()) + }; + + let edge_kind = if edge.is_shortcut { + LeanEdgeKind::Shortcut + } else if self.nodes.contains_key(&to_id) { + LeanEdgeKind::Generative + } else { + LeanEdgeKind::Leaf + }; + + let lean_edge = LeanEdge { + label: edge.label, + input: edge.input, + target_node_id: to_id, + edge_kind, + }; + + self.edges.entry(from_id).or_default().push(lean_edge); + } + + // Update any leaf edges on the anchor node that now point to the batch root. + if let Some(edges) = self.edges.get_mut(anchor_node_id) { + for edge in edges.iter_mut() { + if edge.edge_kind == LeanEdgeKind::Leaf { + // Rewire the first leaf edge to point to the batch root. + edge.target_node_id = batch_root_uuid.clone(); + edge.edge_kind = LeanEdgeKind::Generative; + break; + } + } + } + } + + /// Get a node by ID. + pub fn get_node(&self, id: &str) -> Option<&LeanNode> { + self.nodes.get(id) + } + + /// Get all outgoing edges from a node. + pub fn get_edges(&self, node_id: &str) -> &[LeanEdge] { + self.edges.get(node_id).map(|v| v.as_slice()).unwrap_or(&[]) + } + + /// Get only the generative edges from a node. + pub fn generative_edges(&self, node_id: &str) -> Vec<&LeanEdge> { + self.get_edges(node_id) + .iter() + .filter(|e| e.edge_kind == LeanEdgeKind::Generative) + .collect() + } + + /// Whether any outgoing edge from this node is a leaf (ungenerated target). + pub fn has_leaf_edges(&self, node_id: &str) -> bool { + self.get_edges(node_id) + .iter() + .any(|e| e.edge_kind == LeanEdgeKind::Leaf) + } + + /// BFS depth of generated nodes reachable via generative edges. + pub fn depth_remaining(&self, node_id: &str) -> u8 { + let mut max_depth: u8 = 0; + let mut queue: Vec<(&str, u8)> = vec![(node_id, 0)]; + let mut visited = std::collections::HashSet::new(); + visited.insert(node_id.to_string()); + + while let Some((current, depth)) = queue.pop() { + for edge in self.get_edges(current) { + if edge.edge_kind == LeanEdgeKind::Generative + && !visited.contains(&edge.target_node_id) + { + let next_depth = depth + 1; + if next_depth > max_depth { + max_depth = next_depth; + } + visited.insert(edge.target_node_id.clone()); + queue.push((&edge.target_node_id, next_depth)); + } + } + } + + max_depth + } + + /// Build breadcrumb entries from a navigation path. + pub fn collect_breadcrumbs(&self, path: &[String]) -> Vec { + let mut crumbs = Vec::new(); + + for (i, node_id) in path.iter().enumerate() { + let label = if i == 0 { + "Start".to_string() + } else { + // Find the edge from path[i-1] to path[i] to get the label. + let prev_id = &path[i - 1]; + self.get_edges(prev_id) + .iter() + .find(|e| e.target_node_id == *node_id) + .map(|e| e.label.clone()) + .unwrap_or_else(|| format!("Node {}", &node_id[..8.min(node_id.len())])) + }; + + crumbs.push(BreadcrumbEntry { + node_id: node_id.clone(), + label, + }); + } + + crumbs + } + + /// Collect path history as (input, node) pairs for AI replay. + pub fn collect_path_history(&self, path: &[String]) -> Vec<(&SimInput, &LeanNode)> { + let mut history = Vec::new(); + + for i in 1..path.len() { + let prev_id = &path[i - 1]; + let curr_id = &path[i]; + + // Find the edge that connects prev to curr. + let input = self + .get_edges(prev_id) + .iter() + .find(|e| e.target_node_id == *curr_id) + .map(|e| &e.input); + + let node = self.nodes.get(curr_id); + + if let (Some(input), Some(node)) = (input, node) { + history.push((input, node)); + } + } + + history + } + + /// All node IDs in the graph (for passing to AI as shortcut targets). + pub fn existing_node_ids(&self) -> Vec { + self.nodes.keys().cloned().collect() + } + + /// All node IDs with a brief summary (first 80 chars of UI channel text). + pub fn existing_node_summaries(&self) -> Vec<(String, String)> { + self.nodes + .iter() + .map(|(id, node)| { + let summary = node + .channels + .get("ui") + .map(|c| { + let text = &c.text; + if text.len() > 80 { + format!("{}...", &text[..text.floor_char_boundary(80)]) + } else { + text.clone() + } + }) + .unwrap_or_default(); + (id.clone(), summary) + }) + .collect() + } +} + +/// Parse a `LeanFlatTree` (AI wire format) into a `LeanBatchResponse`. +pub fn flat_to_batch(flat: super::lean_types::LeanFlatTree) -> LeanBatchResponse { + let nodes = flat + .nodes + .into_iter() + .map(|n| LeanNode { + node_id: n.id, + channels: n.channels, + entropy_hint: n.entropy_hint, + }) + .collect(); + + let edges = flat + .edges + .into_iter() + .map(|e| LeanBatchEdge { + from: e.from, + to: e.to, + label: e.label, + input: e.input, + is_shortcut: e.shortcut, + }) + .collect(); + + LeanBatchResponse { nodes, edges } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::types::ChannelContent; + + fn make_channel(text: &str) -> HashMap { + let mut m = HashMap::new(); + m.insert( + "ui".to_string(), + ChannelContent { + text: text.to_string(), + refs: vec![], + spec_gaps: vec![], + }, + ); + m + } + + fn make_input(label: &str) -> SimInput { + SimInput { + keys: vec![label.to_string()], + raw_text: label.to_string(), + } + } + + #[test] + fn test_from_batch_creates_graph() { + let batch = LeanBatchResponse { + nodes: vec![ + LeanNode { + node_id: "root".into(), + channels: make_channel("Root screen"), + entropy_hint: 0.5, + }, + LeanNode { + node_id: "n1".into(), + channels: make_channel("Screen A"), + entropy_hint: 0.8, + }, + LeanNode { + node_id: "n2".into(), + channels: make_channel("Screen B"), + entropy_hint: 0.3, + }, + ], + edges: vec![ + LeanBatchEdge { + from: "root".into(), + to: "n1".into(), + label: "Click A".into(), + input: make_input("a"), + is_shortcut: false, + }, + LeanBatchEdge { + from: "root".into(), + to: "n2".into(), + label: "Click B".into(), + input: make_input("b"), + is_shortcut: false, + }, + ], + }; + + let graph = LeanGraph::from_batch(batch); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.get_edges(&graph.root_id).len(), 2); + assert_eq!(graph.depth_remaining(&graph.root_id), 1); + } + + #[test] + fn test_breadcrumbs() { + let batch = LeanBatchResponse { + nodes: vec![ + LeanNode { + node_id: "root".into(), + channels: make_channel("Root"), + entropy_hint: 0.0, + }, + LeanNode { + node_id: "n1".into(), + channels: make_channel("Child"), + entropy_hint: 0.0, + }, + ], + edges: vec![LeanBatchEdge { + from: "root".into(), + to: "n1".into(), + label: "Go to child".into(), + input: make_input("enter"), + is_shortcut: false, + }], + }; + + let graph = LeanGraph::from_batch(batch); + let child_id = graph + .get_edges(&graph.root_id) + .first() + .unwrap() + .target_node_id + .clone(); + + let path = vec![graph.root_id.clone(), child_id]; + let crumbs = graph.collect_breadcrumbs(&path); + assert_eq!(crumbs.len(), 2); + assert_eq!(crumbs[0].label, "Start"); + assert_eq!(crumbs[1].label, "Go to child"); + } + + #[test] + fn test_has_leaf_edges() { + let batch = LeanBatchResponse { + nodes: vec![LeanNode { + node_id: "root".into(), + channels: make_channel("Root"), + entropy_hint: 0.0, + }], + edges: vec![LeanBatchEdge { + from: "root".into(), + to: "nonexistent".into(), + label: "Go somewhere".into(), + input: make_input("enter"), + is_shortcut: false, + }], + }; + + let graph = LeanGraph::from_batch(batch); + assert!(graph.has_leaf_edges(&graph.root_id)); + assert_eq!(graph.depth_remaining(&graph.root_id), 0); + } +} diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs new file mode 100644 index 0000000..7968e05 --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -0,0 +1,544 @@ +use std::sync::Arc; +use tracing::{error, info}; + +use super::lean_graph::LeanGraph; +use super::lean_types::LeanEdgeKind; +use super::runner::SimConfig; +use super::session::SimStatus; +use crate::state::AppState; + +/// Orchestrate the initial lean game turn. +/// +/// Loads spec context, builds the lean system prompt, generates the first +/// DAG batch, and stores it in the session. +pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: String) { + info!(session_id, "Starting lean game initial turn"); + + // Read session config. + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let spec_id = session.spec_id.clone(); + let model = session.model.clone(); + let channels = session.channels.clone(); + let scenario = session.scenario.clone(); + let batch_depth = session.lean_batch_depth; + let focus_node_id = match session.root_node_id { + Some(ref id) => id.clone(), + None => { + set_error(&state, &session_id, "No focus node set for lean game"); + return; + } + }; + drop(session); + + // Load spec context. + let focus_node = match crate::api::get_node(&state, &focus_node_id) { + Ok(node) => node, + Err(e) => { + set_error(&state, &session_id, &format!("Failed to load focus node: {e}")); + return; + } + }; + + let summary = match crate::api::get_spec(&state, &spec_id) { + Ok(s) => s, + Err(e) => { + set_error(&state, &session_id, &format!("Spec summary error: {e}")); + return; + } + }; + + let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + + let context_ids: std::collections::HashSet<&str> = ancestors + .iter() + .chain(descendants.iter()) + .map(|n| n.id.as_str()) + .chain(std::iter::once(focus_node_id.as_str())) + .collect(); + let other_roots = crate::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + // Collect high-entropy nodes. + let high_entropy_nodes = collect_high_entropy_nodes(&state, &spec_id, 10); + + // Build system prompt. + let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); + let system_prompt = super::lean_prompt::build_lean_system_prompt( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + &high_entropy_nodes, + &spec_id, + ); + let output_format = super::lean_prompt::build_lean_batch_output_format( + batch_depth, + &channel_list, + &[], // No existing nodes yet. + ); + let full_system_prompt = format!("{system_prompt}\n\n{output_format}"); + + // Build initial prompt. + let initial_prompt = super::lean_prompt::build_lean_initial_prompt(&channels, scenario.as_deref()); + + // Build config and call AI. + let mcp_url = state + .mcp_url() + .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = SimConfig::new(model, full_system_prompt, mcp_url, None); + + match super::runner::start_lean_batch_turn(&config, &initial_prompt).await { + Ok((claude_session_id, batch_response)) => { + let graph = LeanGraph::from_batch(batch_response); + let root_id = graph.root_id.clone(); + + state.update_sim_session(&session_id, |s| { + s.claude_session_id = Some(claude_session_id); + // Populate channel_contents from root for the TUI. + if let Some(node) = graph.get_node(&root_id) { + s.channel_contents = node.channels.clone(); + } + s.lean_current_node_id = Some(root_id.clone()); + s.lean_navigation_path = vec![root_id]; + s.lean_graph = Some(graph); + s.lean_generation += 1; + s.status = SimStatus::Idle; + }); + info!(session_id, "Lean game initial turn complete"); + } + Err(e) => { + set_error(&state, &session_id, &format!("AI generation failed: {e}")); + } + } +} + +/// Navigate to a specific edge from the current node. +/// +/// If the target exists (generative or shortcut), navigation is instant. +/// If the target is a leaf, sets Processing and spawns pregen. +pub async fn orchestrate_lean_navigate( + state: Arc, + session_id: String, + edge_index: usize, +) { + // Read edge info from the graph. + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let graph = match &session.lean_graph { + Some(g) => g, + None => return, + }; + let current_id = match &session.lean_current_node_id { + Some(id) => id.clone(), + None => return, + }; + + let edges = graph.get_edges(¤t_id); + let edge = match edges.get(edge_index) { + Some(e) => e, + None => return, + }; + + let target_node_id = edge.target_node_id.clone(); + let edge_kind = edge.edge_kind; + let edge_label = edge.label.clone(); + drop(session); + + match edge_kind { + LeanEdgeKind::Generative | LeanEdgeKind::Shortcut => { + // Instant navigation. + let should_pregen = { + let session = state.get_sim_session(&session_id); + if let Some(ref s) = session { + if let Some(ref graph) = s.lean_graph { + let depth = graph.depth_remaining(&target_node_id); + !s.lean_generating && depth < 2 + } else { + false + } + } else { + false + } + }; + + state.update_sim_session(&session_id, |s| { + s.lean_current_node_id = Some(target_node_id.clone()); + s.lean_navigation_path.push(target_node_id.clone()); + // Update channel_contents for TUI. + if let Some(ref graph) = s.lean_graph { + if let Some(node) = graph.get_node(&target_node_id) { + s.channel_contents = node.channels.clone(); + } + } + }); + + // Spawn background pregen if needed. + if should_pregen { + let state2 = state.clone(); + let sid2 = session_id.clone(); + let target = target_node_id.clone(); + state.update_sim_session(&session_id, |s| { + s.lean_generating = true; + s.lean_generation_target = Some(target.clone()); + }); + tokio::spawn(async move { + orchestrate_lean_batch_pregen(state2, sid2, target).await; + }); + } + + // Spawn background spec update. + let output_summary = { + let session = state.get_sim_session(&session_id); + session + .and_then(|s| s.lean_graph.as_ref().and_then(|g| g.get_node(&target_node_id).cloned())) + .and_then(|n| n.channels.get("ui").cloned()) + .map(|c| { + if c.text.len() > 200 { + format!("{}...", &c.text[..c.text.floor_char_boundary(200)]) + } else { + c.text + } + }) + .unwrap_or_default() + }; + + let state3 = state.clone(); + let sid3 = session_id.clone(); + tokio::spawn(async move { + orchestrate_lean_spec_update(state3, sid3, edge_label, output_summary).await; + }); + } + LeanEdgeKind::Leaf => { + // Need to generate first. + state.update_sim_session(&session_id, |s| { + s.status = SimStatus::Processing; + s.lean_generating = true; + s.lean_generation_target = Some(current_id.clone()); + }); + + let state2 = state.clone(); + let sid2 = session_id.clone(); + let current = current_id.clone(); + tokio::spawn(async move { + orchestrate_lean_batch_pregen(state2.clone(), sid2.clone(), current).await; + + // After generation, navigate to the newly generated target. + let session = state2.get_sim_session(&sid2); + if let Some(s) = session { + if let Some(ref graph) = s.lean_graph { + if let Some(ref curr) = s.lean_current_node_id { + let edges = graph.get_edges(curr); + if let Some(edge) = edges.get(edge_index) { + if edge.edge_kind != LeanEdgeKind::Leaf { + let target = edge.target_node_id.clone(); + drop(s); + state2.update_sim_session(&sid2, |s| { + s.lean_current_node_id = Some(target.clone()); + s.lean_navigation_path.push(target.clone()); + if let Some(ref graph) = s.lean_graph { + if let Some(node) = graph.get_node(&target) { + s.channel_contents = node.channels.clone(); + } + } + s.status = SimStatus::Idle; + }); + return; + } + } + } + } + } + state2.update_sim_session(&sid2, |s| { + s.status = SimStatus::Idle; + }); + }); + } + } +} + +/// Navigate back one step in the breadcrumb trail. +pub fn orchestrate_lean_go_back(state: &AppState, session_id: &str) { + state.update_sim_session(session_id, |s| { + if s.lean_navigation_path.len() > 1 { + s.lean_navigation_path.pop(); + let prev_id = s.lean_navigation_path.last().cloned(); + s.lean_current_node_id = prev_id.clone(); + // Update channel_contents for TUI. + if let (Some(graph), Some(id)) = (&s.lean_graph, &prev_id) { + if let Some(node) = graph.get_node(id) { + s.channel_contents = node.channels.clone(); + } + } + } + }); +} + +/// Background batch pregeneration from a target node. +async fn orchestrate_lean_batch_pregen( + state: Arc, + session_id: String, + target_node_id: String, +) { + info!(session_id, target_node_id, "Starting lean batch pregen"); + + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + + let claude_session_id = match &session.claude_session_id { + Some(id) => id.clone(), + None => { + error!(session_id, "No claude session ID for resume"); + set_lean_generating_false(&state, &session_id); + return; + } + }; + + let generation = session.lean_generation; + let batch_depth = session.lean_batch_depth; + let channels = session.channels.clone(); + + let existing_summaries = session + .lean_graph + .as_ref() + .map(|g| g.existing_node_summaries()) + .unwrap_or_default(); + + // Collect path history for AI replay. + let path_history_data: Vec<(super::types::SimInput, super::lean_types::LeanNode)> = session + .lean_graph + .as_ref() + .map(|g| { + g.collect_path_history(&session.lean_navigation_path) + .into_iter() + .map(|(i, n)| (i.clone(), n.clone())) + .collect() + }) + .unwrap_or_default(); + drop(session); + + // Build resume prompt. + let history_refs: Vec<(&super::types::SimInput, &super::lean_types::LeanNode)> = + path_history_data.iter().map(|(i, n)| (i, n)).collect(); + let resume_prompt = super::lean_prompt::build_lean_resume_prompt(&history_refs, None); + + let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); + let output_format = super::lean_prompt::build_lean_batch_output_format( + batch_depth, + &channel_list, + &existing_summaries, + ); + let full_prompt = format!("{resume_prompt}\n\n{output_format}"); + + // Call AI to resume. + match super::runner::resume_lean_batch_turn(&claude_session_id, &full_prompt).await { + Ok(batch_response) => { + state.update_sim_session(&session_id, |s| { + // Check generation counter for staleness. + if s.lean_generation != generation { + info!(session_id, "Stale pregen, discarding"); + } else if let Some(ref mut graph) = s.lean_graph { + graph.merge_batch(batch_response, &target_node_id); + } + s.lean_generating = false; + s.lean_generation_target = None; + }); + info!(session_id, "Lean batch pregen complete"); + } + Err(e) => { + error!(session_id, error = %e, "Lean batch pregen failed"); + set_lean_generating_false(&state, &session_id); + } + } +} + +/// Background spec update after a player navigates. +async fn orchestrate_lean_spec_update( + state: Arc, + session_id: String, + interaction_label: String, + output_summary: String, +) { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let spec_id = session.spec_id.clone(); + let claude_session_id = match &session.claude_session_id { + Some(id) => id.clone(), + None => return, + }; + drop(session); + + let prompt = super::lean_prompt::build_lean_spec_update_prompt( + &spec_id, + &interaction_label, + &output_summary, + ); + + match super::runner::resume_lean_spec_update(&claude_session_id, &prompt).await { + Ok(update) => { + if let Some(update) = update { + state.update_sim_session(&session_id, |s| { + s.game_spec_updates.push(update); + }); + } + } + Err(e) => { + error!(session_id, error = %e, "Lean spec update failed"); + } + } +} + +/// Handle a player query about the current state. +pub async fn orchestrate_lean_query( + state: Arc, + session_id: String, + question: String, +) { + state.update_sim_session(&session_id, |s| { + s.status = SimStatus::Processing; + }); + + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let claude_session_id = match &session.claude_session_id { + Some(id) => id.clone(), + None => { + set_error(&state, &session_id, "No claude session for query"); + return; + } + }; + drop(session); + + let prompt = super::lean_prompt::build_lean_query_prompt(&question); + + match super::runner::resume_sim_report_turn(&claude_session_id, &prompt).await { + Ok(report) => { + state.update_sim_session(&session_id, |s| { + s.pending_report = Some(report); + s.status = SimStatus::Idle; + }); + } + Err(e) => { + set_error(&state, &session_id, &format!("Query failed: {e}")); + } + } +} + +/// Handle a player modification request — regenerate batch from current node. +pub async fn orchestrate_lean_modify( + state: Arc, + session_id: String, + modification: String, +) { + state.update_sim_session(&session_id, |s| { + s.status = SimStatus::Processing; + s.lean_generation += 1; // Invalidate in-flight pregens. + }); + + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let claude_session_id = match &session.claude_session_id { + Some(id) => id.clone(), + None => { + set_error(&state, &session_id, "No claude session for modify"); + return; + } + }; + let batch_depth = session.lean_batch_depth; + let channels = session.channels.clone(); + let current_id = session.lean_current_node_id.clone(); + let existing_summaries = session + .lean_graph + .as_ref() + .map(|g| g.existing_node_summaries()) + .unwrap_or_default(); + drop(session); + + let modify_prompt = super::lean_prompt::build_lean_modify_prompt(&modification); + let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); + let output_format = super::lean_prompt::build_lean_batch_output_format( + batch_depth, + &channel_list, + &existing_summaries, + ); + let full_prompt = format!("{modify_prompt}\n\n{output_format}"); + + match super::runner::resume_lean_batch_turn(&claude_session_id, &full_prompt).await { + Ok(batch_response) => { + state.update_sim_session(&session_id, |s| { + if let Some(ref mut graph) = s.lean_graph { + if let Some(ref cid) = current_id { + graph.merge_batch(batch_response, cid); + } + } + s.status = SimStatus::Idle; + }); + } + Err(e) => { + set_error(&state, &session_id, &format!("Modify failed: {e}")); + } + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +fn set_error(state: &AppState, session_id: &str, msg: &str) { + error!(session_id, msg, "Lean game error"); + state.update_sim_session(session_id, |s| { + s.status = SimStatus::Error(msg.to_string()); + s.lean_generating = false; + }); +} + +fn set_lean_generating_false(state: &AppState, session_id: &str) { + state.update_sim_session(session_id, |s| { + s.lean_generating = false; + s.lean_generation_target = None; + }); +} + +/// Collect high-entropy nodes from the spec for prompt guidance. +fn collect_high_entropy_nodes( + state: &AppState, + spec_id: &str, + limit: usize, +) -> Vec<(String, String)> { + let nodes = crate::api::get_spec_nodes(state, spec_id).unwrap_or_default(); + + let mut candidates: Vec<(String, String)> = Vec::new(); + + // Unanswered first. + for node in &nodes { + if node.answer.is_none() { + candidates.push((node.id.clone(), node.question.clone())); + } + } + + // Then nodes needing review. + for node in &nodes { + if node.answer.is_some() && node.state == crate::NodeState::NeedsReview { + candidates.push((node.id.clone(), node.question.clone())); + } + } + + candidates.truncate(limit); + candidates +} diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs new file mode 100644 index 0000000..a33bad7 --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -0,0 +1,349 @@ +use super::session::SimChannel; +use super::types::SimInput; +use crate::Node; +use spec_forest_db::SpecSummary; + +/// Build the system prompt for a lean game simulation. +/// +/// Key differences from regular sim prompt: +/// - Ultra-lightweight node output (no decisions, no spec_gaps, no refs) +/// - Entropy guidance via high-entropy node IDs + questions +/// - MCP tools enabled for spec lookup +/// - DAG format with generative + shortcut edges +pub fn build_lean_system_prompt( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + high_entropy_nodes: &[(String, String)], // (node_id, question) + spec_id: &str, +) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + // Build focus node section (compact). + let mut focus_section = String::new(); + focus_section.push_str(&format!("### Focus Node (ID: {})\n", focus_node.id)); + focus_section.push_str(&format!("**Q:** {}\n", focus_node.question)); + if let Some(ref answer) = focus_node.answer { + focus_section.push_str(&format!("**A:** {}\n", answer)); + } else { + focus_section.push_str("**A:** _(unanswered)_\n"); + } + + // Ancestor chain (compact). + let mut ancestor_section = String::new(); + for node in ancestors { + if node.id == focus_node.id { + continue; + } + ancestor_section.push_str(&format!("- **{}**: {}", node.id, node.question)); + if let Some(ref answer) = node.answer { + ancestor_section.push_str(&format!(" → {}", answer)); + } + ancestor_section.push('\n'); + } + + // Descendants (compact). + let mut descendant_section = String::new(); + for node in descendants { + if node.id == focus_node.id { + continue; + } + descendant_section.push_str(&format!("- **{}**: {}", node.id, node.question)); + if let Some(ref answer) = node.answer { + descendant_section.push_str(&format!(" → {}", answer)); + } + descendant_section.push('\n'); + } + + // Other roots (just questions). + let mut other_roots_section = String::new(); + for node in other_roots { + other_roots_section.push_str(&format!("- {} (ID: {})\n", node.question, node.id)); + } + + // High-entropy guidance. + let mut entropy_section = String::new(); + if !high_entropy_nodes.is_empty() { + entropy_section.push_str("## High-Uncertainty Spec Areas\n"); + entropy_section + .push_str("Steer interactions toward these areas — they need player decisions:\n\n"); + for (id, question) in high_entropy_nodes { + entropy_section.push_str(&format!("- **{}**: {}\n", id, question)); + } + } + + format!( + r#"## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY +You simulate the program that would be built from this spec. The player navigates +outputs and chooses interactions. Your job is to steer them toward the INTERESTING +decisions — places where the spec is silent or ambiguous. + +At each node, generate exactly 2 NEW child outputs via generative edges. +You may also add shortcut edges linking to existing nodes in the DAG. +One of the 2 generative edges should lead toward a high-entropy spec area. +The other should represent the expected/obvious path. + +## CRITICAL: JSON-ONLY OUTPUT +Your ENTIRE response must be a single valid JSON object. Do NOT include any text, +explanation, or markdown before or after the JSON. Do NOT wrap in code fences. +The very first character must be `{{`. + +## Spec Context +Spec ID: {spec_id} +Spec "{spec_name}" — {answered} answered, {unanswered} unanswered, {needs_review} review. + +{focus_section} + +### Ancestors +{ancestor_section} + +### Descendants +{descendant_section} + +### Other Areas +{other_roots_section} + +{entropy_section} + +## Tools +You have access to spec-forest MCP tools. Use them to look up spec details: +- **search_nodes**: Search by text (spec_id: {spec_id}) +- **get_node**: Get a node by ID +- **get_descendants**: Get a node's subtree +- **get_spec_summary**: Get spec overview + +Use tools proactively when generating outputs that touch areas outside the loaded context. + +## Channel Semantics +Active channels: {channel_list} +- "ui": Unicode/box-drawing TUI rendering. Replace entirely each turn. Keep concise. +- "audio": Timestamped audio events, e.g. '[AUDIO] Click sound' +- "network": Network events, e.g. '[NET] POST /api/users -> 201' +- "errors": Error messages from the simulated application +- "logs": Application log output + +Keep channel text concise. No refs, no spec_gaps — just the simulation output."#, + spec_id = spec_id, + spec_name = summary.spec.name, + answered = summary.answered_count, + unanswered = summary.unanswered_count, + needs_review = summary.needs_review_count, + focus_section = focus_section, + ancestor_section = if ancestor_section.is_empty() { + "_(root node)_\n".to_string() + } else { + ancestor_section + }, + descendant_section = if descendant_section.is_empty() { + "_(none)_\n".to_string() + } else { + descendant_section + }, + other_roots_section = if other_roots_section.is_empty() { + "_(none)_\n".to_string() + } else { + other_roots_section + }, + entropy_section = entropy_section, + channel_list = channel_list, + ) +} + +/// Build the lean batch output format section. +/// +/// Describes the DAG wire format: nodes + edges with generative/shortcut distinction. +pub fn build_lean_batch_output_format( + batch_depth: u8, + channel_list: &str, + existing_nodes: &[(String, String)], // (node_id, brief summary) +) -> String { + let mut existing_section = String::new(); + if !existing_nodes.is_empty() { + existing_section.push_str( + "## Existing DAG Nodes (available for shortcut edges)\n\ + You may add shortcut edges (`\"shortcut\": true`) to any of these nodes:\n\n", + ); + for (id, summary) in existing_nodes { + let truncated = if summary.len() > 60 { + format!("{}...", &summary[..summary.floor_char_boundary(60)]) + } else { + summary.clone() + }; + existing_section.push_str(&format!("- `{}`: {}\n", id, truncated)); + } + existing_section.push('\n'); + } + + format!( + r#"## Output Format — Lean DAG (nodes + edges) +Every response must be a JSON object with "nodes" and "edges" arrays. + +Schema: +{{{{ + "nodes": [ + {{{{ + "id": "root", + "channels": {{{{ + "": {{{{"text": "...", "refs": [], "spec_gaps": []}}}} + }}}}, + "entropy_hint": 0.7 + }}}}, + {{{{"id": "n1", "channels": {{{{...}}}}, "entropy_hint": 0.9}}}}, + {{{{"id": "n2", "channels": {{{{...}}}}, "entropy_hint": 0.2}}}} + ], + "edges": [ + {{{{"from": "root", "to": "n1", "label": "Click Submit", "input": {{{{"keys": ["Enter"], "raw_text": "\\n"}}}}}}}}, + {{{{"from": "root", "to": "n2", "label": "Press Tab", "input": {{{{"keys": ["Tab"], "raw_text": "\\t"}}}}}}}}, + {{{{"from": "root", "to": "existing-uuid", "label": "Go Back", "input": {{{{"keys": ["Escape"], "raw_text": ""}}}}, "shortcut": true}}}} + ] +}}}} + +Active channels: {channel_list} + +## DAG Rules +1. Generate {depth} levels deep. Root is level 0, children are level 1, etc. +2. Each non-leaf node MUST have exactly 2 generative edges (creating NEW child nodes). +3. You MAY add any number of shortcut edges (`"shortcut": true`) linking to existing nodes. + Shortcuts should represent interactions that logically lead to an already-explored state. +4. One of the 2 generative edges should lead toward a HIGH-ENTROPY spec area. + The other should represent the expected/obvious behavior. +5. entropy_hint (0.0–1.0): how close this node's state is to unresolved spec decisions. + 0.0 = fully specified, 1.0 = highly ambiguous. +6. Leaf nodes at max depth: include edges but OMIT the target nodes from "nodes" array. +7. Node IDs must be short unique strings ("root", "n1", "n2", etc.). +8. Every node must include entries for ALL active channels. +9. Keep channel text concise — focus on the simulation output, not explanations. + +{existing_section}"#, + channel_list = channel_list, + depth = batch_depth, + existing_section = existing_section, + ) +} + +/// Build the initial prompt for the first lean game turn. +pub fn build_lean_initial_prompt(channels: &[SimChannel], scenario: Option<&str>) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + match scenario { + Some(desc) if !desc.trim().is_empty() => format!( + "Initialize the lean game simulation with this scenario:\n\n\ + {desc}\n\n\ + Render the application state across channels: {channel_list}. \ + Generate the DAG batch from the starting state." + ), + _ => format!( + "Initialize the lean game simulation. Render the application's starting state \ + across channels: {channel_list}. Generate the DAG batch from the starting state." + ), + } +} + +/// Build a resume prompt that replays the player's path and requests the next batch. +pub fn build_lean_resume_prompt( + history: &[(&SimInput, &super::lean_types::LeanNode)], + custom_input: Option<&str>, +) -> String { + let mut prompt = String::new(); + + if !history.is_empty() { + prompt.push_str("The player navigated through these interactions:\n\n"); + for (i, (input, node)) in history.iter().enumerate() { + let ui_summary = node + .channels + .get("ui") + .map(|c| { + let text = &c.text; + if text.len() > 200 { + format!("{}...", &text[..text.floor_char_boundary(200)]) + } else { + text.clone() + } + }) + .unwrap_or_default(); + + prompt.push_str(&format!( + "{}. Input: keys={:?}, raw_text={:?}\n UI: {}\n\n", + i + 1, + input.keys, + input.raw_text, + ui_summary, + )); + } + } + + match custom_input { + Some(input) => { + prompt.push_str(&format!( + "The player provided a custom input: {}\n\n", + input + )); + } + None => { + prompt.push_str("The player reached the end of the generated DAG.\n\n"); + } + } + + prompt.push_str( + "Generate the next DAG batch from the current state. \ + JSON only, no text before or after. First character must be `{`.", + ); + + prompt +} + +/// Build a query prompt for when the player asks a question. +pub fn build_lean_query_prompt(question: &str) -> String { + format!( + "The player asks: \"{question}\"\n\n\ + Answer their question about the current simulation state. Reference spec nodes \ + where relevant. Respond with a JSON object:\n\ + {{\"explanation\": \"...\", \"refs\": [{{\"marker\": \"[^1]\", \"node_id\": \"uuid\"}}]}}\n\n\ + JSON only, no text before or after." + ) +} + +/// Build a modify prompt for when the player wants to change the simulation. +pub fn build_lean_modify_prompt(modification: &str) -> String { + format!( + "The player wants to modify the simulation: \"{modification}\"\n\n\ + Apply this modification and regenerate the DAG batch from the current state. \ + The modification should be reflected in the root node's output and all subsequent nodes. \ + JSON only, no text before or after. First character must be `{{}}`." + ) +} + +/// Build a spec update prompt for background spec refinement. +pub fn build_lean_spec_update_prompt( + spec_id: &str, + interaction_label: &str, + output_summary: &str, +) -> String { + format!( + "You are updating a specification based on a player's navigation in lean game mode.\n\n\ + The player chose interaction: \"{interaction_label}\"\n\ + The resulting output shows: \"{output_summary}\"\n\n\ + This confirms the simulated behavior is correct. Use spec-forest tools to update the \ + spec (spec_id: {spec_id}) if the player's path reveals decisions the spec should record.\n\n\ + Instructions:\n\ + 1. Use get_node to read related spec nodes.\n\ + 2. If the behavior is already covered by the spec, respond with \ + {{\"action\": \"none\", \"reason\": \"...\"}}.\n\ + 3. If it reveals new info, use add_children + answer_question to add a Q&A. \ + Respond with {{\"action\": \"add_qa\", \"node_id\": \"...\", \"description\": \"...\"}}.\n\ + 4. If it refines an existing answer, use answer_question. \ + Respond with {{\"action\": \"update_answer\", \"node_id\": \"...\", \"description\": \"...\"}}.\n\n\ + JSON only, no markdown, no code fences." + ) +} diff --git a/crates/spec-forest/src/simulation/lean_types.rs b/crates/spec-forest/src/simulation/lean_types.rs new file mode 100644 index 0000000..203449e --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use super::types::ChannelContent; +use super::types::SimInput; + +// ── Node ──────────────────────────────────────────────────────────────── + +/// A node in the lean game DAG. +/// +/// Ultra-lightweight: no decisions, no spec_gaps, no refs. +/// Grounding transparency is deferred to on-demand query mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanNode { + /// Unique identifier assigned server-side after parsing. + #[serde(default)] + pub node_id: String, + /// Channel outputs at this point in the simulation (text only). + pub channels: HashMap, + /// How close this node is to high-entropy spec areas (0.0–1.0). + #[serde(default)] + pub entropy_hint: f64, +} + +// ── Edge ──────────────────────────────────────────────────────────────── + +/// An edge in the lean game DAG. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanEdge { + /// Human-readable label for the interaction (e.g., "Click Submit"). + pub label: String, + /// The input this interaction represents. + pub input: SimInput, + /// Node ID this edge leads to. + pub target_node_id: String, + /// What kind of edge this is. + pub edge_kind: LeanEdgeKind, +} + +/// Classifies how an edge was created and whether its target exists. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LeanEdgeKind { + /// Target was created as a new node by this batch. Exactly 2 per node. + Generative, + /// Links to an already-existing node in the DAG. Free, no generation cost. + Shortcut, + /// Generative edge whose target hasn't been generated yet. + /// Triggers batch pre-generation when the player is nearby. + Leaf, +} + +// ── Batch response (parsed from AI output) ────────────────────────────── + +/// Parsed batch of new nodes + edges from a single AI generation call. +#[derive(Debug, Clone)] +pub struct LeanBatchResponse { + pub nodes: Vec, + pub edges: Vec, +} + +/// An edge in a batch response, before being merged into the graph. +#[derive(Debug, Clone)] +pub struct LeanBatchEdge { + /// AI-local node ID (e.g., "root", "n1"). + pub from: String, + /// AI-local node ID or existing graph node UUID. + pub to: String, + pub label: String, + pub input: SimInput, + /// If true, `to` refers to an existing node ID in the DAG. + pub is_shortcut: bool, +} + +// ── Flat wire format (what the AI actually produces) ──────────────────── + +/// Flat adjacency-list format for lean game output. +/// Converted to `LeanBatchResponse` after parsing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanFlatTree { + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanFlatNode { + pub id: String, + pub channels: HashMap, + #[serde(default)] + pub entropy_hint: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanFlatEdge { + pub from: String, + pub to: String, + pub label: String, + pub input: SimInput, + /// If true, `to` refers to an existing node_id in the DAG (not a new node in this batch). + #[serde(default)] + pub shortcut: bool, +} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 38b40fb..0a45fd9 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1,6 +1,7 @@ use super::types::{ - FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameTreeResponse, GameTreeRoot, - PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, + FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, + GameTreeRoot, PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, + SimTreeResponse, }; use std::collections::HashMap; use std::error::Error; @@ -863,6 +864,155 @@ pub async fn resume_game_spec_update_turn( Ok(response_text) } +// ── Lean game mode runner functions ────────────────────────────────── + +/// Start the first lean game turn. Returns (claude_session_id, batch_response). +pub async fn start_lean_batch_turn( + config: &SimConfig, + prompt: &str, +) -> Result<(String, super::lean_types::LeanBatchResponse), Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "type": "http", + "url": config.mcp_url + } + } + }); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + if let Some(ref dir) = config.directory { + cmd.current_dir(dir); + } + + tracing::info!( + model = %config.model, + prompt_chars = prompt.len(), + "Starting lean batch turn" + ); + + let (response_text, session_id) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Lean initial batch turn complete" + ); + + let response = parse_lean_batch_response(&response_text)?; + Ok((session_id, response)) +} + +/// Resume an existing lean game session for the next batch. +pub async fn resume_lean_batch_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming lean batch turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Lean resume batch turn complete" + ); + + parse_lean_batch_response(&response_text) +} + +/// Resume a lean game session for a background spec update. +/// +/// Parses the response as a GameSpecUpdate JSON. +pub async fn resume_lean_spec_update( + claude_session_id: &str, + prompt: &str, +) -> Result, Box> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming lean spec update turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Lean spec update turn complete" + ); + + // Try to parse as a spec update action. + if let Ok(action) = extract_json::(&response_text) { + let action_type = action.get("action").and_then(|a| a.as_str()).unwrap_or("none"); + if action_type == "none" { + return Ok(None); + } + let node_id = action.get("node_id").and_then(|n| n.as_str()).unwrap_or("").to_string(); + let description = action.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string(); + + return Ok(Some(GameSpecUpdate { + interaction_label: String::new(), + outcome_summary: String::new(), + description, + node_id, + })); + } + + Ok(None) +} + +/// Parse the AI's text response into a LeanBatchResponse. +fn parse_lean_batch_response( + text: &str, +) -> Result> { + // Try flat format. + if let Ok(flat) = extract_json::(text) { + let batch = super::lean_graph::flat_to_batch(flat); + tracing::info!("Parsed lean batch from flat format"); + return Ok(batch); + } + + Err(format!( + "Failed to parse lean batch response as JSON.\nRaw response:\n{}", + text.trim() + ) + .into()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index f394dbf..8aa1821 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -1,3 +1,4 @@ +use super::lean_graph::LeanGraph; use super::types::{ ChannelContent, Decision, GameSpecUpdate, GameTreeRoot, SimReportResponse, SimTreeNode, }; @@ -112,6 +113,23 @@ pub struct SimSession { pub game_tree: Option, /// Log of spec updates triggered by game choices during this session. pub game_spec_updates: Vec, + // ── Lean game mode fields ─────────────────────────────────────────── + /// Whether this session is in lean game mode (DAG-based efficient play). + pub lean_mode: bool, + /// The DAG of all generated nodes and edges. + pub lean_graph: Option, + /// Current position in the DAG. + pub lean_current_node_id: Option, + /// Breadcrumb trail for back-navigation. + pub lean_navigation_path: Vec, + /// Depth of each batch generation (default 3). + pub lean_batch_depth: u8, + /// Whether background batch generation is in progress. + pub lean_generating: bool, + /// Node ID where pregeneration is targeting, if any. + pub lean_generation_target: Option, + /// Generation counter, incremented on modifications to invalidate stale pregens. + pub lean_generation: u64, } impl SimSession { @@ -148,6 +166,14 @@ impl SimSession { game_mode: false, game_tree: None, game_spec_updates: Vec::new(), + lean_mode: false, + lean_graph: None, + lean_current_node_id: None, + lean_navigation_path: Vec::new(), + lean_batch_depth: 3, + lean_generating: false, + lean_generation_target: None, + lean_generation: 0, } } } diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index 9bcead7..29352eb 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -361,3 +361,29 @@ pub struct GameRejectOutcomeParams { )] pub correction: String, } + +// -- Lean game mode tools -- + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct LeanNavigateParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Index of the edge/interaction to follow (0-based)")] + pub edge_index: usize, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct LeanQueryParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Question about the current simulation state")] + pub question: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct LeanModifyParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Modification to apply to the simulation output")] + pub modification: String, +} diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 58bda9a..4a38b2e 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1760,6 +1760,248 @@ impl SpecForestServer { .unwrap(), )])) } + + // ── Lean Game Mode Tools ──────────────────────────────────────── + + #[tool(description = "Get the current lean game output (channels, interactions, breadcrumbs). Returns the current node's channels and available edges with their types (generative, shortcut, leaf).")] + fn lean_get_output( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + + let (channels, edges, breadcrumbs) = if let Some(ref graph) = session.lean_graph { + let current_id = session.lean_current_node_id.as_deref().unwrap_or(""); + let channels = graph + .get_node(current_id) + .map(|n| &n.channels) + .cloned() + .unwrap_or_default(); + let edges: Vec = graph + .get_edges(current_id) + .iter() + .enumerate() + .map(|(i, e)| { + serde_json::json!({ + "index": i, + "label": e.label, + "edge_kind": format!("{:?}", e.edge_kind), + "target_node_id": e.target_node_id, + }) + }) + .collect(); + let crumbs = graph.collect_breadcrumbs(&session.lean_navigation_path); + (channels, edges, crumbs) + } else { + (std::collections::HashMap::new(), vec![], vec![]) + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": format!("{:?}", session.status), + "channels": channels, + "edges": edges, + "breadcrumbs": breadcrumbs, + "pregenerating": session.lean_generating, + })) + .unwrap(), + )])) + } + + #[tool(description = "Navigate to an interaction in lean game mode. Provide the edge index (0-based). Generative and shortcut edges navigate instantly. Leaf edges trigger batch generation.")] + fn lean_navigate( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + if session.status != crate::simulation::SimStatus::Idle { + return Err(ErrorData::invalid_params("Session is not idle", None)); + } + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let ei = params.edge_index; + tokio::spawn(async move { + crate::simulation::lean_orchestrate::orchestrate_lean_navigate(state, sid, ei).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "processing", + "session_id": params.session_id, + }) + .to_string(), + )])) + } + + #[tool(description = "Navigate back one step in the lean game breadcrumb trail. Always instant.")] + fn lean_go_back( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + + crate::simulation::lean_orchestrate::orchestrate_lean_go_back( + &self.state, + ¶ms.session_id, + ); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "ok", + "session_id": params.session_id, + }) + .to_string(), + )])) + } + + #[tool(description = "Ask a question about the current lean game simulation state. Returns an explanation with spec node references.")] + fn lean_query( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let question = params.question; + tokio::spawn(async move { + crate::simulation::lean_orchestrate::orchestrate_lean_query(state, sid, question).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "processing", + "session_id": params.session_id, + }) + .to_string(), + )])) + } + + #[tool(description = "Modify the simulation output in lean game mode. Regenerates the DAG batch from the current node with the modification applied.")] + fn lean_modify( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let modification = params.modification; + tokio::spawn(async move { + crate::simulation::lean_orchestrate::orchestrate_lean_modify( + state, + sid, + modification, + ) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "processing", + "session_id": params.session_id, + }) + .to_string(), + )])) + } + + #[tool(description = "Get the log of spec updates triggered during lean game play. Same format as game_get_spec_updates.")] + fn lean_get_spec_updates( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + let updates: Vec = session + .game_spec_updates + .iter() + .map(|u| { + serde_json::json!({ + "description": u.description, + "node_id": u.node_id, + }) + }) + .collect(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "lean_mode": session.lean_mode, + "updates": updates, + })) + .unwrap(), + )])) + } } #[tool_handler] From 07edbf2ba4721a17e34ff9e1d09ed55296efb790 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 09:04:41 +1100 Subject: [PATCH 02/19] fix: restrict lean game AI to read-only spec tools Use spec_read_only config for lean batch generation so the AI can only call search_nodes, get_node, get_descendants, and get_spec_summary. Removes filesystem tools (Read, Glob, Grep) and explicitly tells the AI not to attempt any write or sim/game tools. --- .../src/simulation/lean_orchestrate.rs | 4 ++-- .../spec-forest/src/simulation/lean_prompt.rs | 8 +++++--- crates/spec-forest/src/simulation/runner.rs | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 7968e05..fb7b19c 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -90,11 +90,11 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str // Build initial prompt. let initial_prompt = super::lean_prompt::build_lean_initial_prompt(&channels, scenario.as_deref()); - // Build config and call AI. + // Build config — spec read-only tools only for batch generation. let mcp_url = state .mcp_url() .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); - let config = SimConfig::new(model, full_system_prompt, mcp_url, None); + let config = SimConfig::spec_read_only(model, full_system_prompt, mcp_url); match super::runner::start_lean_batch_turn(&config, &initial_prompt).await { Ok((claude_session_id, batch_response)) => { diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index a33bad7..b940e57 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -112,14 +112,16 @@ Spec "{spec_name}" — {answered} answered, {unanswered} unanswered, {needs_revi {entropy_section} -## Tools -You have access to spec-forest MCP tools. Use them to look up spec details: +## Tools (READ-ONLY) +You have read-only access to spec-forest MCP tools. Use them to look up spec details: - **search_nodes**: Search by text (spec_id: {spec_id}) - **get_node**: Get a node by ID - **get_descendants**: Get a node's subtree - **get_spec_summary**: Get spec overview -Use tools proactively when generating outputs that touch areas outside the loaded context. +These are the ONLY tools available. Do NOT attempt to use any other tools. +Do NOT try to modify the spec, create sessions, or call any sim_* or game_* tools. +Use these read-only tools when generating outputs that touch areas outside the loaded context. ## Channel Semantics Active channels: {channel_list} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 0a45fd9..1ab48c6 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -417,6 +417,24 @@ impl SimConfig { .join(","), } } + + /// Config with only spec read tools (no filesystem access). + /// Used for lean game batch generation. + pub fn spec_read_only(model: String, system_prompt: String, mcp_url: String) -> Self { + Self { + model, + system_prompt, + mcp_url, + directory: None, + allowed_tools: [ + "mcp__spec-forest__search_nodes", + "mcp__spec-forest__get_node", + "mcp__spec-forest__get_descendants", + "mcp__spec-forest__get_spec_summary", + ] + .join(","), + } + } } /// Start the first simulation turn. Returns (claude_session_id, response). From 2d148d19c7c0eb6068a6e59671f1f927bb32f7b9 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 09:05:56 +1100 Subject: [PATCH 03/19] feat: add delete spec from gallery with double-press confirmation --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 45 ++++++++++++- crates/spec-forest-tui/src/commands.rs | 9 +++ crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/ui/help_popup.rs | 1 + crates/spec-forest-tui/src/ui/spec_list.rs | 2 +- docs/tui-missing-features.md | 72 +++++++++++++++++++++ 7 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 docs/tui-missing-features.md diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 6d1666e..4bec96a 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -13,6 +13,7 @@ pub enum Action { OpenSeedFromDir, OpenSyncConfig, OpenModelConfig, + DeleteSpec, // Text input (shared across InputName, SyncPasswordInput) TypeChar(char), diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 65318b6..a17482c 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -412,7 +412,7 @@ impl App { } async fn execute_action(&mut self, action: Action) { - if action != Action::DeleteNode && action != Action::Noop { + if action != Action::DeleteNode && action != Action::DeleteSpec && action != Action::Noop { self.pending_delete = None; } match action { @@ -448,6 +448,8 @@ impl App { self.screen = Screen::ModelConfig; } + Action::DeleteSpec => self.delete_spec().await, + // Text input Action::TypeChar(c) => self.input.push(c), Action::DeleteChar => { self.input.pop(); } @@ -2395,6 +2397,47 @@ impl App { } } + // ── Delete spec ────────────────────────────────────────── + + async fn delete_spec(&mut self) { + if !matches!(self.screen, Screen::SpecList) { + return; + } + + let spec = match self.specs.get(self.selected) { + Some(spec) => spec.clone(), + None => { + self.message = Some("No spec selected".to_string()); + return; + } + }; + + if self.pending_delete.as_deref() == Some(&spec.id) { + self.pending_delete = None; + match commands::delete_spec(&self.state, &spec.id).await { + Ok(_) => { + self.message = Some("Spec deleted".to_string()); + if let Some(specs) = + handle_result(commands::refresh_spec_list(&self.state), &mut self.message) + { + self.specs = specs; + } + if self.selected > 0 && self.selected >= self.specs.len() { + self.selected = self.specs.len().saturating_sub(1); + } + } + Err(e) => { + tracing::error!("Delete spec failed: {e}"); + self.message = Some(e.to_string()); + } + } + } else { + let label = truncate_str(&spec.name, 40); + self.pending_delete = Some(spec.id.clone()); + self.message = Some(format!("Press d again to delete '{label}'")); + } + } + // ── Candidate operations ─────────────────────────────────── pub fn refresh_candidates_if_needed(&mut self) { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 5aab5a4..d01735c 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -175,6 +175,15 @@ pub fn regenerate_feature( .map_err(|e| TuiError::Api(e.to_string())) } +pub async fn delete_spec( + state: &Arc, + spec_id: &str, +) -> Result<(), TuiError> { + spec_forest::api::delete_spec(state, spec_id) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + pub async fn delete_node( state: &Arc, node_id: &str, diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 5c1dd0d..62953e4 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -247,6 +247,7 @@ fn map_spec_list_key(key: KeyCode) -> Action { KeyCode::Char('y') => Action::OpenSyncConfig, KeyCode::Char('m') => Action::OpenModelConfig, KeyCode::Char('g') => Action::OpenConfig, + KeyCode::Char('d') => Action::DeleteSpec, KeyCode::Up => Action::NavigateUp, KeyCode::Down => Action::NavigateDown, KeyCode::Enter => Action::Select, diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs index f8a47e4..3d0aee3 100644 --- a/crates/spec-forest-tui/src/ui/help_popup.rs +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -26,6 +26,7 @@ fn help_sections(app: &App) -> Vec { title: "Actions", bindings: vec![ ("c", "Create spec"), + ("d", "Delete spec"), ("s", "Seed from directory"), ("m", "Model config"), ("y", "Sync config"), diff --git a/crates/spec-forest-tui/src/ui/spec_list.rs b/crates/spec-forest-tui/src/ui/spec_list.rs index 2e450dc..2b12c01 100644 --- a/crates/spec-forest-tui/src/ui/spec_list.rs +++ b/crates/spec-forest-tui/src/ui/spec_list.rs @@ -50,7 +50,7 @@ pub fn render(app: &App, frame: &mut Frame) { Line::from(msg.clone()) } else { super::common::render_footer_line( - &[("Enter", "Open"), ("c", "Create"), ("q", "Quit"), ("?", "Help")], + &[("Enter", "Open"), ("c", "Create"), ("d", "Delete"), ("q", "Quit"), ("?", "Help")], app.sync_disconnect_indicator(), ) }; diff --git a/docs/tui-missing-features.md b/docs/tui-missing-features.md new file mode 100644 index 0000000..128f1da --- /dev/null +++ b/docs/tui-missing-features.md @@ -0,0 +1,72 @@ +# TUI Missing Features + +Features present in the web UI but not yet in the TUI, ordered by importance. + +1. **Search & Filtering** + 1a. Global semantic search across nodes + 1b. Filter outliner by state (unanswered/answered/needs_review/deleted) + 1c. Filter outliner by entropy score + 1d. Filter outliner by tags + 1e. Search specs by name in gallery + 1f. "Next Question" jump to highest-entropy unanswered node + +2. **Branching & Version Control** + 2a. Branch panel (create, switch, merge, delete branches) + 2b. Undo / Redo + 2c. Time travel to any sequence number + 2d. Timeline slider with milestone creation and diff anchors + 2e. Update review status (draft/ready_for_review/approved/merged) + +3. **Review & Diff** + 3a. Review sidebar showing branch diffs (created/edited/deleted/metadata-only) + 3b. Word-level diff view with color coding + 3c. Range diff comparison + 3d. Review banner with read-only mode indicator + +4. **Outputs & Narrative** + 4a. Output panel listing spec-level and node-level outputs (summary, plan, action_items, narrative) + 4b. Generate outputs with context size display + 4c. Narrative panel with interactive node references + 4d. Copy / delete outputs + +5. **Conflict Resolution** + 5a. Display merge conflicts (edit/edit, edit/delete, structural) + 5b. Split-view comparison + 5c. Pick left/right resolution + 5d. Custom edit mode for conflicts + +6. **Annotations** + 6a. Create annotation threads on nodes + 6b. Reply to annotations + 6c. Edit / resolve / unresolve / delete annotations + 6d. Author attribution and timestamps + +7. **Node Display** + 7a. Entropy / Impact / Subtree Entropy badges on nodes + 7b. Tag badges on nodes + 7c. Inline editing of question and answer text (without external editor) + 7d. Breadcrumb ancestor navigation in main workspace + +8. **Dashboard** + 8a. Progress ring showing answered/unanswered/needs_review counts + 8b. High-entropy nodes list + 8c. Nodes needing review list + 8d. Recent outputs view + +9. **Source & Ingest** + 9a. Source panel with multiple ingest modes (answer, recursive, shadow, shadow-regenerate) + 9b. Ingest progress monitoring with pause/resume/cancel + 9c. Active ingests tracking + +10. **Spec Management** + ~~10a. Delete spec from gallery (with confirmation)~~ + 10b. Subscribe to remote specs / browse remote specs modal + +11. **Collaboration** + 11a. Access panel (view members, grant/revoke access) + 11b. Identity modal with register/login flow + +12. **Explore Controls** + 12a. Entire-graph vs subtree toggle + 12b. End-on-answer toggle + 12c. Granular progress tracking (completed, skipped, in_flight) From 20cca4f07c9e1a550ad2cb89577b1b0037738029 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 09:26:26 +1100 Subject: [PATCH 04/19] fix: lean game back-nav during generation, auto-pregen on leaf nodes, and backgrounding Allow pressing back while a scene is generating by updating can_go_back during Processing status and cancelling in-flight leaf generation via the generation counter. Auto-trigger pregen when landing on nodes with shallow depth (after initial turn, navigation, and go-back). Change Esc to background lean games instead of destroying them, with full session restore via the existing session picker. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 138 +++++++++++++++--- crates/spec-forest-tui/src/input.rs | 2 +- .../src/simulation/lean_orchestrate.rs | 88 +++++++---- crates/spec-forest/src/tools.rs | 2 +- 5 files changed, 183 insertions(+), 48 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 4bec96a..47f549a 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -175,6 +175,7 @@ pub enum Action { LeanInputSubmit, LeanInputCancel, LeanInputNewline, + LeanBackground, LeanEnd, // Notification / session picker diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index a17482c..77b18df 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -1449,7 +1449,7 @@ impl App { if let Some(ref lean) = self.lean_state { if lean.can_go_back { spec_forest::simulation::lean_orchestrate::orchestrate_lean_go_back( - &self.state, + self.state.clone(), &lean.session_id, ); } @@ -1550,6 +1550,44 @@ impl App { lean.modify_input.clear(); } } + Action::LeanBackground => { + // Close overlays first if open. + if let Some(ref mut lean) = self.lean_state { + if lean.report_overlay.is_some() { + lean.report_overlay = None; + return; + } + } + if let Screen::LeanGame { + ref spec_id, + ref session_id, + } = self.screen + { + let spec_id = spec_id.clone(); + let session_id = session_id.clone(); + let label = self + .state + .get_sim_session(&session_id) + .and_then(|s| s.scenario.clone()) + .unwrap_or_else(|| { + format!("lean:{}", &session_id[..8.min(session_id.len())]) + }); + let was_processing = self + .lean_state + .as_ref() + .map(|s| s.processing) + .unwrap_or(false); + self.background_sims + .push(crate::notification::BackgroundSimEntry { + session_id, + spec_id: spec_id.clone(), + label, + was_processing, + }); + self.lean_state = None; + self.screen = Screen::SpecView { spec_id }; + } + } Action::LeanEnd => { if let Screen::LeanGame { ref spec_id, ref session_id } = self.screen { let spec_id = spec_id.clone(); @@ -1602,6 +1640,34 @@ impl App { self.sim_state = None; } + // Also background the current lean game if we're viewing one + if let Screen::LeanGame { + ref spec_id, + ref session_id, + } = self.screen + { + let spec_id = spec_id.clone(); + let sid = session_id.clone(); + let label = self + .state + .get_sim_session(&sid) + .and_then(|s| s.scenario.clone()) + .unwrap_or_else(|| format!("lean:{}", &sid[..8.min(sid.len())])); + let was_processing = self + .lean_state + .as_ref() + .map(|s| s.processing) + .unwrap_or(false); + self.background_sims + .push(crate::notification::BackgroundSimEntry { + session_id: sid, + spec_id, + label, + was_processing, + }); + self.lean_state = None; + } + // Remove from background list self.background_sims .retain(|bg| bg.session_id != session_id); @@ -1610,25 +1676,46 @@ impl App { self.sim_notifications .retain(|n| n.session_id != session_id); - // Reconstruct SimulationState from the AppState session data + // Reconstruct state from the AppState session data if let Some(session) = self.state.get_sim_session(session_id) { - let mut sim_state = crate::simulation::SimulationState::new( - session.id.clone(), - session.spec_id.clone(), - session.channels.clone(), - ); - sim_state.channel_contents = session.channel_contents.clone(); - sim_state.decisions = session.decisions.clone(); - sim_state.interactions = - self.state.get_sim_interactions(&session.id); - sim_state.processing = - matches!(session.status, spec_forest::simulation::SimStatus::Processing); - sim_state.scenario_input = session.scenario.clone().unwrap_or_default(); - self.screen = Screen::Simulation { - spec_id: session.spec_id.clone(), - session_id: session.id.clone(), - }; - self.sim_state = Some(sim_state); + if session.lean_mode { + // Restore as lean game. + let mut lean_state = crate::lean_state::LeanGameState::new( + session.id.clone(), + session.spec_id.clone(), + session.channels.clone(), + ); + lean_state.processing = matches!( + session.status, + spec_forest::simulation::SimStatus::Processing + ); + lean_state.game_spec_updates = session.game_spec_updates.clone(); + self.screen = Screen::LeanGame { + spec_id: session.spec_id.clone(), + session_id: session.id.clone(), + }; + self.lean_state = Some(lean_state); + } else { + let mut sim_state = crate::simulation::SimulationState::new( + session.id.clone(), + session.spec_id.clone(), + session.channels.clone(), + ); + sim_state.channel_contents = session.channel_contents.clone(); + sim_state.decisions = session.decisions.clone(); + sim_state.interactions = + self.state.get_sim_interactions(&session.id); + sim_state.processing = matches!( + session.status, + spec_forest::simulation::SimStatus::Processing + ); + sim_state.scenario_input = session.scenario.clone().unwrap_or_default(); + self.screen = Screen::Simulation { + spec_id: session.spec_id.clone(), + session_id: session.id.clone(), + }; + self.sim_state = Some(sim_state); + } } } @@ -2889,6 +2976,19 @@ impl App { } spec_forest::simulation::SimStatus::Processing => { lean.processing = true; + // Update can_go_back during generation so user can navigate back. + if let Some(session) = self.state.get_sim_session(&session_id) { + lean.can_go_back = session.lean_navigation_path.len() > 1; + if let Some(ref graph) = session.lean_graph { + let crumbs = + graph.collect_breadcrumbs(&session.lean_navigation_path); + lean.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); + } + lean.pregenerating = session.lean_generating; + } } spec_forest::simulation::SimStatus::Error(ref e) => { lean.processing = false; diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 62953e4..e87887c 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -62,7 +62,7 @@ fn map_lean_normal_key(key: KeyCode) -> Action { KeyCode::Char('m') => Action::LeanEnterModify, KeyCode::Char('u') => Action::LeanToggleUpdateLog, KeyCode::Char('Q') => Action::LeanEnd, - KeyCode::Esc => Action::LeanEnd, + KeyCode::Esc => Action::LeanBackground, KeyCode::PageUp => Action::LeanScrollUp, KeyCode::PageDown => Action::LeanScrollDown, _ => Action::Noop, diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index fb7b19c..a22168b 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -100,6 +100,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str Ok((claude_session_id, batch_response)) => { let graph = LeanGraph::from_batch(batch_response); let root_id = graph.root_id.clone(); + let root_id_for_pregen = root_id.clone(); state.update_sim_session(&session_id, |s| { s.claude_session_id = Some(claude_session_id); @@ -114,6 +115,8 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str s.status = SimStatus::Idle; }); info!(session_id, "Lean game initial turn complete"); + // Auto-pregen if root has shallow depth. + maybe_trigger_pregen(&state, &session_id, &root_id_for_pregen); } Err(e) => { set_error(&state, &session_id, &format!("AI generation failed: {e}")); @@ -158,20 +161,6 @@ pub async fn orchestrate_lean_navigate( match edge_kind { LeanEdgeKind::Generative | LeanEdgeKind::Shortcut => { // Instant navigation. - let should_pregen = { - let session = state.get_sim_session(&session_id); - if let Some(ref s) = session { - if let Some(ref graph) = s.lean_graph { - let depth = graph.depth_remaining(&target_node_id); - !s.lean_generating && depth < 2 - } else { - false - } - } else { - false - } - }; - state.update_sim_session(&session_id, |s| { s.lean_current_node_id = Some(target_node_id.clone()); s.lean_navigation_path.push(target_node_id.clone()); @@ -184,18 +173,7 @@ pub async fn orchestrate_lean_navigate( }); // Spawn background pregen if needed. - if should_pregen { - let state2 = state.clone(); - let sid2 = session_id.clone(); - let target = target_node_id.clone(); - state.update_sim_session(&session_id, |s| { - s.lean_generating = true; - s.lean_generation_target = Some(target.clone()); - }); - tokio::spawn(async move { - orchestrate_lean_batch_pregen(state2, sid2, target).await; - }); - } + maybe_trigger_pregen(&state, &session_id, &target_node_id); // Spawn background spec update. let output_summary = { @@ -221,6 +199,10 @@ pub async fn orchestrate_lean_navigate( } LeanEdgeKind::Leaf => { // Need to generate first. + let generation = state + .get_sim_session(&session_id) + .map(|s| s.lean_generation) + .unwrap_or(0); state.update_sim_session(&session_id, |s| { s.status = SimStatus::Processing; s.lean_generating = true; @@ -233,6 +215,14 @@ pub async fn orchestrate_lean_navigate( tokio::spawn(async move { orchestrate_lean_batch_pregen(state2.clone(), sid2.clone(), current).await; + // Check if user navigated away during generation. + let current_gen = state2 + .get_sim_session(&sid2) + .map(|s| s.lean_generation); + if current_gen != Some(generation) { + return; + } + // After generation, navigate to the newly generated target. let session = state2.get_sim_session(&sid2); if let Some(s) = session { @@ -268,7 +258,7 @@ pub async fn orchestrate_lean_navigate( } /// Navigate back one step in the breadcrumb trail. -pub fn orchestrate_lean_go_back(state: &AppState, session_id: &str) { +pub fn orchestrate_lean_go_back(state: Arc, session_id: &str) { state.update_sim_session(session_id, |s| { if s.lean_navigation_path.len() > 1 { s.lean_navigation_path.pop(); @@ -280,8 +270,22 @@ pub fn orchestrate_lean_go_back(state: &AppState, session_id: &str) { s.channel_contents = node.channels.clone(); } } + // Cancel any in-flight leaf generation. + if s.status == SimStatus::Processing { + s.lean_generation += 1; + s.status = SimStatus::Idle; + s.lean_generating = false; + s.lean_generation_target = None; + } } }); + // After going back, the destination node might need pregen. + let current_id = state + .get_sim_session(session_id) + .and_then(|s| s.lean_current_node_id.clone()); + if let Some(id) = current_id { + maybe_trigger_pregen(&state, session_id, &id); + } } /// Background batch pregeneration from a target node. @@ -500,6 +504,36 @@ pub async fn orchestrate_lean_modify( // ── Helpers ───────────────────────────────────────────────────────────── +/// Check if the given node needs pregen and spawn it if so. +fn maybe_trigger_pregen(state: &Arc, session_id: &str, node_id: &str) { + let should_pregen = { + let session = state.get_sim_session(session_id); + if let Some(ref s) = session { + if let Some(ref graph) = s.lean_graph { + let depth = graph.depth_remaining(node_id); + !s.lean_generating && depth < 2 + } else { + false + } + } else { + false + } + }; + + if should_pregen { + let state2 = state.clone(); + let sid2 = session_id.to_string(); + let target = node_id.to_string(); + state.update_sim_session(session_id, |s| { + s.lean_generating = true; + s.lean_generation_target = Some(target.clone()); + }); + tokio::spawn(async move { + orchestrate_lean_batch_pregen(state2, sid2, target).await; + }); + } +} + fn set_error(state: &AppState, session_id: &str, msg: &str) { error!(session_id, msg, "Lean game error"); state.update_sim_session(session_id, |s| { diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 4a38b2e..20a4046 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1879,7 +1879,7 @@ impl SpecForestServer { } crate::simulation::lean_orchestrate::orchestrate_lean_go_back( - &self.state, + self.state.clone(), ¶ms.session_id, ); From 3eaf5367dd28facf2d628d6a5b7f73baeb377597 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 10:07:14 +1100 Subject: [PATCH 05/19] feat: lean game spec updates use AI to find best placement across whole spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main lean AI now signals spec-relevant behavior via per-node spec_updates in the batch response. On navigation, a separate background AI session with write tools and full spec context determines the best placement — adding Q&A nodes, updating existing answers, or creating new features as appropriate. --- crates/spec-forest/src/simulation.rs | 2 +- .../spec-forest/src/simulation/lean_graph.rs | 11 +- .../src/simulation/lean_orchestrate.rs | 81 ++++++----- .../spec-forest/src/simulation/lean_prompt.rs | 128 ++++++++++++++---- .../spec-forest/src/simulation/lean_types.rs | 12 ++ .../spec-forest/src/simulation/orchestrate.rs | 4 +- crates/spec-forest/src/simulation/runner.rs | 98 ++++++++++++++ crates/spec-forest/src/simulation/types.rs | 3 + 8 files changed, 276 insertions(+), 63 deletions(-) diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index a40af0f..98dd9db 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -19,7 +19,7 @@ pub use prompt::{ pub use session::{SimChannel, SimSession, SimStatus}; pub use tree::BreadcrumbEntry; pub use lean_graph::LeanGraph; -pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanNode}; +pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanNode, LeanSpecSuggestion}; pub use types::{ ChannelContent, Decision, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, GameTreeRoot, NodeRef, PredictedInteraction, SimInput, SimReport, SimReportResponse, diff --git a/crates/spec-forest/src/simulation/lean_graph.rs b/crates/spec-forest/src/simulation/lean_graph.rs index 5e25f93..35b3695 100644 --- a/crates/spec-forest/src/simulation/lean_graph.rs +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -250,8 +250,8 @@ impl LeanGraph { .get("ui") .map(|c| { let text = &c.text; - if text.len() > 80 { - format!("{}...", &text[..text.floor_char_boundary(80)]) + if text.len() > 150 { + format!("{}...", &text[..text.floor_char_boundary(150)]) } else { text.clone() } @@ -272,6 +272,7 @@ pub fn flat_to_batch(flat: super::lean_types::LeanFlatTree) -> LeanBatchResponse node_id: n.id, channels: n.channels, entropy_hint: n.entropy_hint, + spec_updates: n.spec_updates, }) .collect(); @@ -323,16 +324,19 @@ mod tests { node_id: "root".into(), channels: make_channel("Root screen"), entropy_hint: 0.5, + spec_updates: vec![], }, LeanNode { node_id: "n1".into(), channels: make_channel("Screen A"), entropy_hint: 0.8, + spec_updates: vec![], }, LeanNode { node_id: "n2".into(), channels: make_channel("Screen B"), entropy_hint: 0.3, + spec_updates: vec![], }, ], edges: vec![ @@ -367,11 +371,13 @@ mod tests { node_id: "root".into(), channels: make_channel("Root"), entropy_hint: 0.0, + spec_updates: vec![], }, LeanNode { node_id: "n1".into(), channels: make_channel("Child"), entropy_hint: 0.0, + spec_updates: vec![], }, ], edges: vec![LeanBatchEdge { @@ -405,6 +411,7 @@ mod tests { node_id: "root".into(), channels: make_channel("Root"), entropy_hint: 0.0, + spec_updates: vec![], }], edges: vec![LeanBatchEdge { from: "root".into(), diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index a22168b..105ffc0 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -155,7 +155,6 @@ pub async fn orchestrate_lean_navigate( let target_node_id = edge.target_node_id.clone(); let edge_kind = edge.edge_kind; - let edge_label = edge.label.clone(); drop(session); match edge_kind { @@ -175,27 +174,26 @@ pub async fn orchestrate_lean_navigate( // Spawn background pregen if needed. maybe_trigger_pregen(&state, &session_id, &target_node_id); - // Spawn background spec update. - let output_summary = { + // Spawn background spec updates for any suggestions on this node. + let spec_suggestions = { let session = state.get_sim_session(&session_id); session - .and_then(|s| s.lean_graph.as_ref().and_then(|g| g.get_node(&target_node_id).cloned())) - .and_then(|n| n.channels.get("ui").cloned()) - .map(|c| { - if c.text.len() > 200 { - format!("{}...", &c.text[..c.text.floor_char_boundary(200)]) - } else { - c.text - } + .and_then(|s| { + s.lean_graph + .as_ref() + .and_then(|g| g.get_node(&target_node_id).cloned()) }) + .map(|n| n.spec_updates) .unwrap_or_default() }; - let state3 = state.clone(); - let sid3 = session_id.clone(); - tokio::spawn(async move { - orchestrate_lean_spec_update(state3, sid3, edge_label, output_summary).await; - }); + for suggestion in spec_suggestions { + let state3 = state.clone(); + let sid3 = session_id.clone(); + tokio::spawn(async move { + orchestrate_lean_spec_update(state3, sid3, suggestion.description).await; + }); + } } LeanEdgeKind::Leaf => { // Need to generate first. @@ -368,37 +366,54 @@ async fn orchestrate_lean_batch_pregen( } } -/// Background spec update after a player navigates. +/// Background spec update using a separate AI session with write tools. +/// +/// Loads the full spec tree, builds a compact outline, and spawns a fresh +/// Claude session that can search the entire spec and place updates wherever +/// they belong (not just under the current feature). async fn orchestrate_lean_spec_update( state: Arc, session_id: String, - interaction_label: String, - output_summary: String, + behavior_description: String, ) { let session = match state.get_sim_session(&session_id) { Some(s) => s, None => return, }; let spec_id = session.spec_id.clone(); - let claude_session_id = match &session.claude_session_id { - Some(id) => id.clone(), - None => return, - }; + let model = session.model.clone(); drop(session); - let prompt = super::lean_prompt::build_lean_spec_update_prompt( + // Load full spec tree for the outline. + let roots = crate::api::get_spec_roots(&state, &spec_id).unwrap_or_default(); + let descendants_by_root: Vec> = roots + .iter() + .map(|root| crate::api::get_descendants(&state, &root.id).unwrap_or_default()) + .collect(); + + let spec_outline = super::lean_prompt::build_spec_outline(&roots, &descendants_by_root); + let system_prompt = super::lean_prompt::build_spec_update_system_prompt(&spec_id); + let user_prompt = super::lean_prompt::build_spec_update_user_prompt( &spec_id, - &interaction_label, - &output_summary, + &behavior_description, + &spec_outline, ); - match super::runner::resume_lean_spec_update(&claude_session_id, &prompt).await { - Ok(update) => { - if let Some(update) = update { - state.update_sim_session(&session_id, |s| { - s.game_spec_updates.push(update); - }); - } + let mcp_url = state + .mcp_url() + .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = SimConfig::spec_read_write(model, system_prompt, mcp_url); + + match super::runner::start_spec_update_session(&config, &user_prompt).await { + Ok(Some(mut update)) => { + update.outcome_summary = behavior_description; + state.update_sim_session(&session_id, |s| { + s.game_spec_updates.push(update); + }); + info!(session_id, "Lean spec update applied"); + } + Ok(None) => { + info!(session_id, "Lean spec update: no changes needed"); } Err(e) => { error!(session_id, error = %e, "Lean spec update failed"); diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index b940e57..b767815 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -86,10 +86,14 @@ outputs and chooses interactions. Your job is to steer them toward the INTERESTI decisions — places where the spec is silent or ambiguous. At each node, generate exactly 2 NEW child outputs via generative edges. -You may also add shortcut edges linking to existing nodes in the DAG. One of the 2 generative edges should lead toward a high-entropy spec area. The other should represent the expected/obvious path. +SHORTCUT EDGES ARE ESSENTIAL. Whenever existing nodes are listed, actively add +shortcut edges (`"shortcut": true`) that link to them. Shortcuts create realistic +navigation: back buttons, shared destinations, menu returns, and loop-backs. +A DAG without shortcuts is an unrealistic tree — real applications have convergent paths. + ## CRITICAL: JSON-ONLY OUTPUT Your ENTIRE response must be a single valid JSON object. Do NOT include any text, explanation, or markdown before or after the JSON. Do NOT wrap in code fences. @@ -169,12 +173,14 @@ pub fn build_lean_batch_output_format( let mut existing_section = String::new(); if !existing_nodes.is_empty() { existing_section.push_str( - "## Existing DAG Nodes (available for shortcut edges)\n\ - You may add shortcut edges (`\"shortcut\": true`) to any of these nodes:\n\n", + "## Existing DAG Nodes — ADD SHORTCUTS TO THESE\n\ + These nodes already exist in the DAG. Add shortcut edges (`\"shortcut\": true`) to \ + create realistic navigation paths (back buttons, shared screens, loop-backs). \ + Each non-leaf node should have at least 1 shortcut edge.\n\n", ); for (id, summary) in existing_nodes { - let truncated = if summary.len() > 60 { - format!("{}...", &summary[..summary.floor_char_boundary(60)]) + let truncated = if summary.len() > 120 { + format!("{}...", &summary[..summary.floor_char_boundary(120)]) } else { summary.clone() }; @@ -195,7 +201,8 @@ Schema: "channels": {{{{ "": {{{{"text": "...", "refs": [], "spec_gaps": []}}}} }}}}, - "entropy_hint": 0.7 + "entropy_hint": 0.7, + "spec_updates": [{{{{"description": "User login defaults to OAuth flow when no password is set"}}}}] }}}}, {{{{"id": "n1", "channels": {{{{...}}}}, "entropy_hint": 0.9}}}}, {{{{"id": "n2", "channels": {{{{...}}}}, "entropy_hint": 0.2}}}} @@ -212,8 +219,11 @@ Active channels: {channel_list} ## DAG Rules 1. Generate {depth} levels deep. Root is level 0, children are level 1, etc. 2. Each non-leaf node MUST have exactly 2 generative edges (creating NEW child nodes). -3. You MAY add any number of shortcut edges (`"shortcut": true`) linking to existing nodes. - Shortcuts should represent interactions that logically lead to an already-explored state. +3. You SHOULD add shortcut edges (`"shortcut": true`) linking to existing nodes. + When existing nodes are listed, each non-leaf node SHOULD have at least 1 shortcut edge. + Good shortcut scenarios: "Go Back" / "Return to menu" / "Cancel" leading to a prior screen, + "Submit" leading to a shared confirmation state, navigation tabs leading to already-visited areas, + error-then-retry loops back to an input form. Shortcuts are free — use them generously. 4. One of the 2 generative edges should lead toward a HIGH-ENTROPY spec area. The other should represent the expected/obvious behavior. 5. entropy_hint (0.0–1.0): how close this node's state is to unresolved spec decisions. @@ -222,6 +232,11 @@ Active channels: {channel_list} 7. Node IDs must be short unique strings ("root", "n1", "n2", etc.). 8. Every node must include entries for ALL active channels. 9. Keep channel text concise — focus on the simulation output, not explanations. +10. spec_updates (optional array): Include when a node's behavior reveals something the spec \ + should record — a design decision, a default behavior, an edge case. Describe WHAT was \ + decided/observed, not WHERE it belongs in the spec. Omit if the behavior is already \ + clearly covered by the spec context above. A separate AI will determine the best \ + placement in the spec. {existing_section}"#, channel_list = channel_list, @@ -299,6 +314,9 @@ pub fn build_lean_resume_prompt( prompt.push_str( "Generate the next DAG batch from the current state. \ + IMPORTANT: The existing nodes listed in the output format section are available as \ + shortcut targets. Add shortcut edges generously — back-navigation, shared screens, \ + and loop-backs make the DAG realistic. Aim for at least 1 shortcut per non-leaf node.\n\n\ JSON only, no text before or after. First character must be `{`.", ); @@ -326,26 +344,84 @@ pub fn build_lean_modify_prompt(modification: &str) -> String { ) } -/// Build a spec update prompt for background spec refinement. -pub fn build_lean_spec_update_prompt( +/// Build the system prompt for the background spec update AI. +pub fn build_spec_update_system_prompt(spec_id: &str) -> String { + format!( + "You are a spec placement AI. Your job is to find the best place in a specification \ + to record observed behavior from a simulation.\n\n\ + You have read-write access to the spec (spec_id: {spec_id}) via these tools:\n\ + - **search_nodes**: Semantic search across all spec nodes\n\ + - **search_features**: Find the closest matching feature root\n\ + - **get_node**: Read a specific node's details\n\ + - **get_descendants**: Read a node's subtree\n\ + - **get_spec_summary**: Get spec overview\n\ + - **add_children**: Create new Q&A nodes under a parent\n\ + - **answer_question**: Update or set a node's answer\n\ + - **add_feature**: Create a new root feature\n\n\ + ## Workflow\n\ + 1. Read the behavior description provided.\n\ + 2. Use search_nodes to find spec nodes related to the behavior.\n\ + 3. Decide the best action:\n\ + - **none**: The behavior is already clearly covered by an existing spec node.\n\ + - **update_answer**: An existing node covers this topic but the answer needs \ + updating. Call answer_question.\n\ + - **add_qa**: The behavior belongs under an existing node but no child covers it. \ + Use search_nodes/search_features to find the best parent, then call add_children \ + + answer_question.\n\ + - **add_feature**: The behavior represents an entirely new area not covered by \ + any existing feature. Call add_feature.\n\ + 4. Respond with a raw JSON object describing what you did.\n\n\ + ## Response Format\n\ + {{\"action\": \"none|add_qa|update_answer|add_feature\", \"node_id\": \"...\", \ + \"description\": \"...\"}}\n\n\ + JSON only, no markdown, no code fences." + ) +} + +/// Build the user prompt for a background spec update. +pub fn build_spec_update_user_prompt( spec_id: &str, - interaction_label: &str, - output_summary: &str, + behavior_description: &str, + spec_outline: &str, ) -> String { format!( - "You are updating a specification based on a player's navigation in lean game mode.\n\n\ - The player chose interaction: \"{interaction_label}\"\n\ - The resulting output shows: \"{output_summary}\"\n\n\ - This confirms the simulated behavior is correct. Use spec-forest tools to update the \ - spec (spec_id: {spec_id}) if the player's path reveals decisions the spec should record.\n\n\ - Instructions:\n\ - 1. Use get_node to read related spec nodes.\n\ - 2. If the behavior is already covered by the spec, respond with \ - {{\"action\": \"none\", \"reason\": \"...\"}}.\n\ - 3. If it reveals new info, use add_children + answer_question to add a Q&A. \ - Respond with {{\"action\": \"add_qa\", \"node_id\": \"...\", \"description\": \"...\"}}.\n\ - 4. If it refines an existing answer, use answer_question. \ - Respond with {{\"action\": \"update_answer\", \"node_id\": \"...\", \"description\": \"...\"}}.\n\n\ - JSON only, no markdown, no code fences." + "The following behavior was observed during a lean game simulation and should be \ + recorded in the spec (spec_id: {spec_id}):\n\n\ + **Observed behavior:** {behavior_description}\n\n\ + ## Current Spec Outline\n\ + {spec_outline}\n\n\ + Find the best place in the spec for this behavior. Use the tools to search, read, \ + and modify the spec as needed. Then respond with your action JSON." ) } + +/// Build a compact text outline of the entire spec tree. +pub fn build_spec_outline(roots: &[Node], descendants_by_root: &[Vec]) -> String { + let mut outline = String::new(); + + for (root, descendants) in roots.iter().zip(descendants_by_root.iter()) { + outline.push_str(&format!("Feature: {} ({})\n", root.question, root.id)); + + // Build a simple indented list from descendants. + // Descendants are in depth-first order from get_descendants. + for node in descendants { + if node.id == root.id { + continue; + } + let answer_summary = match &node.answer { + Some(a) if a.len() > 80 => { + format!(" -> {}...", &a[..a.floor_char_boundary(80)]) + } + Some(a) => format!(" -> {a}"), + None => " (unanswered)".to_string(), + }; + outline.push_str(&format!(" Q: {} ({}){}\n", node.question, node.id, answer_summary)); + } + } + + if outline.is_empty() { + "(empty spec)".to_string() + } else { + outline + } +} diff --git a/crates/spec-forest/src/simulation/lean_types.rs b/crates/spec-forest/src/simulation/lean_types.rs index 203449e..7730bb5 100644 --- a/crates/spec-forest/src/simulation/lean_types.rs +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -20,6 +20,16 @@ pub struct LeanNode { /// How close this node is to high-entropy spec areas (0.0–1.0). #[serde(default)] pub entropy_hint: f64, + /// Spec-relevant behaviors observed at this node that should be recorded. + #[serde(default)] + pub spec_updates: Vec, +} + +/// A suggestion from the main lean AI about behavior worth recording in the spec. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanSpecSuggestion { + /// Description of what behavior was observed or decided. + pub description: String, } // ── Edge ──────────────────────────────────────────────────────────────── @@ -88,6 +98,8 @@ pub struct LeanFlatNode { pub channels: HashMap, #[serde(default)] pub entropy_hint: f64, + #[serde(default)] + pub spec_updates: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/spec-forest/src/simulation/orchestrate.rs b/crates/spec-forest/src/simulation/orchestrate.rs index a6819ee..0561a11 100644 --- a/crates/spec-forest/src/simulation/orchestrate.rs +++ b/crates/spec-forest/src/simulation/orchestrate.rs @@ -852,18 +852,20 @@ async fn orchestrate_game_spec_update( match update { Ok(result) if result.action != "none" => { + let action = result.action; let spec_update = simulation::GameSpecUpdate { interaction_label: interaction_label.clone(), outcome_summary: outcome_summary.clone(), description: result.description, node_id: result.node_id, + action: action.clone(), }; state.update_sim_session(&session_id, |s| { s.game_spec_updates.push(spec_update); }); tracing::info!( session_id = %session_id, - action = %result.action, + action = %action, "Game spec update applied" ); } diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 1ab48c6..7e2616f 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -435,6 +435,27 @@ impl SimConfig { .join(","), } } + + /// Config with spec read + write tools for background spec updates. + pub fn spec_read_write(model: String, system_prompt: String, mcp_url: String) -> Self { + Self { + model, + system_prompt, + mcp_url, + directory: None, + allowed_tools: [ + "mcp__spec-forest__search_nodes", + "mcp__spec-forest__search_features", + "mcp__spec-forest__get_node", + "mcp__spec-forest__get_descendants", + "mcp__spec-forest__get_spec_summary", + "mcp__spec-forest__add_children", + "mcp__spec-forest__answer_question", + "mcp__spec-forest__add_feature", + ] + .join(","), + } + } } /// Start the first simulation turn. Returns (claude_session_id, response). @@ -1007,6 +1028,83 @@ pub async fn resume_lean_spec_update( outcome_summary: String::new(), description, node_id, + action: action_type.to_string(), + })); + } + + Ok(None) +} + +/// Start a fresh Claude session for a background spec update. +/// +/// Unlike `resume_lean_spec_update`, this creates a new session with write tools +/// so the AI can search the whole spec and place updates wherever appropriate. +pub async fn start_spec_update_session( + config: &SimConfig, + prompt: &str, +) -> Result, Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "type": "http", + "url": config.mcp_url + } + } + }); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + tracing::info!( + prompt_chars = prompt.len(), + "Starting spec update session" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Spec update session complete" + ); + + // Parse the response as a spec update action. + if let Ok(action) = extract_json::(&response_text) { + let action_type = action + .get("action") + .and_then(|a| a.as_str()) + .unwrap_or("none"); + if action_type == "none" { + return Ok(None); + } + let node_id = action + .get("node_id") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let description = action + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("") + .to_string(); + + return Ok(Some(GameSpecUpdate { + interaction_label: String::new(), + outcome_summary: String::new(), + description, + node_id, + action: action_type.to_string(), })); } diff --git a/crates/spec-forest/src/simulation/types.rs b/crates/spec-forest/src/simulation/types.rs index e010e33..883df9c 100644 --- a/crates/spec-forest/src/simulation/types.rs +++ b/crates/spec-forest/src/simulation/types.rs @@ -159,6 +159,9 @@ pub struct GameSpecUpdate { pub description: String, /// Which spec node was affected. pub node_id: String, + /// What action was taken: "add_qa", "update_answer", "add_feature", or "none". + #[serde(default)] + pub action: String, } /// Structured input for behavior reporting. From 8aba3eab891e20ea5f9526867bb5a643c7fe1109 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 10:44:57 +1100 Subject: [PATCH 06/19] feat: replace background spec updates with manual send-actions flow Remove automatic background Claude sessions for spec updates during lean game navigation. Instead, navigation history accumulates as unsent actions that the user explicitly sends (press 's') with notes to the main AI session via --resume. The AI then uses write MCP tools to update the spec. Key changes: - Track unsent action count via lean_sent_path_len on SimSession - Queue leaf generation and send-actions when session is busy - Warn on quit if unsent actions remain (double-press Q to confirm) - Show send(N) in status bar, spec update progress in output title - Remove LeanSpecSuggestion, background spec update orchestration, and separate spec update Claude sessions --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 61 ++++++ crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/lean_state.rs | 17 +- crates/spec-forest-tui/src/ui/lean_game.rs | 75 +++++-- crates/spec-forest/src/simulation.rs | 2 +- .../spec-forest/src/simulation/lean_graph.rs | 12 +- .../src/simulation/lean_orchestrate.rs | 202 +++++++++++++----- .../spec-forest/src/simulation/lean_prompt.rs | 100 ++++----- .../spec-forest/src/simulation/lean_types.rs | 12 -- crates/spec-forest/src/simulation/runner.rs | 128 +---------- crates/spec-forest/src/simulation/session.rs | 13 ++ 12 files changed, 356 insertions(+), 268 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 47f549a..a524f24 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -167,6 +167,7 @@ pub enum Action { LeanGoBack, LeanEnterQuery, LeanEnterModify, + LeanEnterSendActions, LeanToggleUpdateLog, LeanScrollUp, LeanScrollDown, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 77b18df..7d24042 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -415,6 +415,12 @@ impl App { if action != Action::DeleteNode && action != Action::DeleteSpec && action != Action::Noop { self.pending_delete = None; } + // Reset lean quit_pending on any action that isn't LeanEnd. + if action != Action::LeanEnd { + if let Some(ref mut lean) = self.lean_state { + lean.quit_pending = false; + } + } match action { Action::Noop => {} Action::Quit => self.should_quit = true, @@ -1467,6 +1473,14 @@ impl App { lean.modify_input.clear(); } } + Action::LeanEnterSendActions => { + if let Some(ref mut lean) = self.lean_state { + if !lean.spec_updating && lean.unsent_action_count > 0 { + lean.send_actions_mode = true; + lean.send_actions_input.clear(); + } + } + } Action::LeanToggleUpdateLog => { if let Some(ref mut lean) = self.lean_state { lean.show_update_log = !lean.show_update_log; @@ -1488,6 +1502,8 @@ impl App { lean.query_input.push(c); } else if lean.modify_mode { lean.modify_input.push(c); + } else if lean.send_actions_mode { + lean.send_actions_input.push(c); } } } @@ -1497,6 +1513,8 @@ impl App { lean.query_input.pop(); } else if lean.modify_mode { lean.modify_input.pop(); + } else if lean.send_actions_mode { + lean.send_actions_input.pop(); } } } @@ -1506,6 +1524,8 @@ impl App { lean.query_input.push('\n'); } else if lean.modify_mode { lean.modify_input.push('\n'); + } else if lean.send_actions_mode { + lean.send_actions_input.push('\n'); } } } @@ -1539,6 +1559,28 @@ impl App { ) .await; }); + } else if lean.send_actions_mode { + let notes = lean.send_actions_input.clone(); + lean.send_actions_mode = false; + lean.send_actions_input.clear(); + let session_id = lean.session_id.clone(); + let state = self.state.clone(); + // If pregen is running, queue the send for after it finishes. + if lean.pregenerating { + state.update_sim_session(&session_id, |s| { + s.lean_queued_send = Some(notes); + }); + } else { + lean.spec_updating = true; + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_send_actions( + state, + session_id, + notes, + ) + .await; + }); + } } } } @@ -1546,8 +1588,10 @@ impl App { if let Some(ref mut lean) = self.lean_state { lean.query_mode = false; lean.modify_mode = false; + lean.send_actions_mode = false; lean.query_input.clear(); lean.modify_input.clear(); + lean.send_actions_input.clear(); } } Action::LeanBackground => { @@ -1589,6 +1633,13 @@ impl App { } } Action::LeanEnd => { + // Warn if there are unsent actions. + if let Some(ref mut lean) = self.lean_state { + if lean.unsent_action_count > 0 && !lean.quit_pending { + lean.quit_pending = true; + return; + } + } if let Screen::LeanGame { ref spec_id, ref session_id } = self.screen { let spec_id = spec_id.clone(); let session_id = session_id.clone(); @@ -2972,6 +3023,11 @@ impl App { lean.pregenerating = session.lean_generating; lean.game_spec_updates = session.game_spec_updates.clone(); + lean.unsent_action_count = session + .lean_navigation_path + .len() + .saturating_sub(session.lean_sent_path_len); + lean.spec_updating = session.lean_spec_updating; } } spec_forest::simulation::SimStatus::Processing => { @@ -2979,6 +3035,11 @@ impl App { // Update can_go_back during generation so user can navigate back. if let Some(session) = self.state.get_sim_session(&session_id) { lean.can_go_back = session.lean_navigation_path.len() > 1; + lean.unsent_action_count = session + .lean_navigation_path + .len() + .saturating_sub(session.lean_sent_path_len); + lean.spec_updating = session.lean_spec_updating; if let Some(ref graph) = session.lean_graph { let crumbs = graph.collect_breadcrumbs(&session.lean_navigation_path); diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index e87887c..ca61287 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -60,6 +60,7 @@ fn map_lean_normal_key(key: KeyCode) -> Action { KeyCode::Backspace => Action::LeanGoBack, KeyCode::Char('i') => Action::LeanEnterQuery, KeyCode::Char('m') => Action::LeanEnterModify, + KeyCode::Char('s') => Action::LeanEnterSendActions, KeyCode::Char('u') => Action::LeanToggleUpdateLog, KeyCode::Char('Q') => Action::LeanEnd, KeyCode::Esc => Action::LeanBackground, diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs index 8e1314c..585c318 100644 --- a/crates/spec-forest-tui/src/lean_state.rs +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -1,6 +1,4 @@ -use spec_forest::simulation::{ - ChannelContent, GameSpecUpdate, LeanEdgeKind, SimChannel, -}; +use spec_forest::simulation::{ChannelContent, GameSpecUpdate, LeanEdgeKind, SimChannel}; use std::collections::HashMap; use crate::simulation::ReportOverlay; @@ -27,6 +25,12 @@ pub struct LeanGameState { pub show_update_log: bool, pub game_spec_updates: Vec, pub scroll_offset: usize, + // Send actions + pub send_actions_mode: bool, + pub send_actions_input: String, + pub spec_updating: bool, + pub unsent_action_count: usize, + pub quit_pending: bool, } /// View model for a single interaction in the lean game panel. @@ -58,11 +62,16 @@ impl LeanGameState { show_update_log: false, game_spec_updates: Vec::new(), scroll_offset: 0, + send_actions_mode: false, + send_actions_input: String::new(), + spec_updating: false, + unsent_action_count: 0, + quit_pending: false, } } /// Whether we're in any text input mode. pub fn in_input_mode(&self) -> bool { - self.query_mode || self.modify_mode + self.query_mode || self.modify_mode || self.send_actions_mode } } diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs index 2396838..a7d498b 100644 --- a/crates/spec-forest-tui/src/ui/lean_game.rs +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -46,7 +46,7 @@ pub fn render(app: &App, frame: &mut Frame) { render_status_bar(app, frame, chunks[3]); // ── Overlays ──────────────────────────────────────────────────── - if lean.query_mode || lean.modify_mode { + if lean.query_mode || lean.modify_mode || lean.send_actions_mode { render_input_overlay(app, frame); } if lean.report_overlay.is_some() { @@ -125,12 +125,16 @@ fn render_output(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { let title = if lean.processing { " Output (generating...) " + } else if lean.spec_updating { + " Output (updating spec...) " } else { " Output " }; let border_color = if lean.processing { Color::Yellow + } else if lean.spec_updating { + Color::Magenta } else { Color::Cyan }; @@ -217,31 +221,59 @@ fn render_interactions(app: &App, frame: &mut Frame, area: ratatui::layout::Rect frame.render_widget(paragraph, area); } -fn render_status_bar(_app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { - let items = vec![ - ("↑↓", "select"), - ("Enter", "go"), - ("Bksp", "back"), - ("i", "query"), - ("m", "modify"), - ("u", "updates"), - ("Q", "quit"), +fn render_status_bar(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let lean = app.lean_state.as_ref().unwrap(); + + // Show quit warning if pending. + if lean.quit_pending { + let warning = Paragraph::new(Line::from(vec![ + Span::styled( + format!(" Q again to quit ({} unsent actions) ", lean.unsent_action_count), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + ])); + frame.render_widget(warning, area); + return; + } + + let mut items: Vec<(&str, String)> = vec![ + ("↑↓", "select".into()), + ("Enter", "go".into()), + ("Bksp", "back".into()), + ("i", "query".into()), + ("m", "modify".into()), ]; + if lean.unsent_action_count > 0 { + items.push(("s", format!("send({})", lean.unsent_action_count))); + } + + if lean.spec_updating { + items.push(("", "updating spec...".into())); + } + + items.push(("u", "updates".into())); + items.push(("Q", "quit".into())); + let spans: Vec = items .iter() .enumerate() .flat_map(|(i, (key, desc))| { - let mut v = vec![ - Span::styled( + let mut v = Vec::new(); + if !key.is_empty() { + v.push(Span::styled( format!(" {key}"), Style::default().fg(Color::Yellow), - ), - Span::styled( - format!(" {desc}"), - Style::default().fg(Color::DarkGray), - ), - ]; + )); + } + v.push(Span::styled( + format!(" {desc}"), + if *key == "" { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }, + )); if i < items.len() - 1 { v.push(Span::styled(" │", Style::default().fg(Color::DarkGray))); } @@ -269,8 +301,13 @@ fn render_input_overlay(app: &App, frame: &mut Frame) { let (title, input) = if lean.query_mode { (" Query (Ctrl+S to submit, Esc to cancel) ", &lean.query_input) - } else { + } else if lean.modify_mode { (" Modify (Ctrl+S to submit, Esc to cancel) ", &lean.modify_input) + } else { + ( + " Send Actions — Add Notes (Ctrl+S to submit, Esc to cancel) ", + &lean.send_actions_input, + ) }; let paragraph = Paragraph::new(input.as_str()) diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 98dd9db..a40af0f 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -19,7 +19,7 @@ pub use prompt::{ pub use session::{SimChannel, SimSession, SimStatus}; pub use tree::BreadcrumbEntry; pub use lean_graph::LeanGraph; -pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanNode, LeanSpecSuggestion}; +pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanNode}; pub use types::{ ChannelContent, Decision, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, GameTreeRoot, NodeRef, PredictedInteraction, SimInput, SimReport, SimReportResponse, diff --git a/crates/spec-forest/src/simulation/lean_graph.rs b/crates/spec-forest/src/simulation/lean_graph.rs index 35b3695..e61f6d1 100644 --- a/crates/spec-forest/src/simulation/lean_graph.rs +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -272,7 +272,6 @@ pub fn flat_to_batch(flat: super::lean_types::LeanFlatTree) -> LeanBatchResponse node_id: n.id, channels: n.channels, entropy_hint: n.entropy_hint, - spec_updates: n.spec_updates, }) .collect(); @@ -324,19 +323,19 @@ mod tests { node_id: "root".into(), channels: make_channel("Root screen"), entropy_hint: 0.5, - spec_updates: vec![], + }, LeanNode { node_id: "n1".into(), channels: make_channel("Screen A"), entropy_hint: 0.8, - spec_updates: vec![], + }, LeanNode { node_id: "n2".into(), channels: make_channel("Screen B"), entropy_hint: 0.3, - spec_updates: vec![], + }, ], edges: vec![ @@ -371,13 +370,13 @@ mod tests { node_id: "root".into(), channels: make_channel("Root"), entropy_hint: 0.0, - spec_updates: vec![], + }, LeanNode { node_id: "n1".into(), channels: make_channel("Child"), entropy_hint: 0.0, - spec_updates: vec![], + }, ], edges: vec![LeanBatchEdge { @@ -411,7 +410,6 @@ mod tests { node_id: "root".into(), channels: make_channel("Root"), entropy_hint: 0.0, - spec_updates: vec![], }], edges: vec![LeanBatchEdge { from: "root".into(), diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 105ffc0..0e7e8a5 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -94,7 +94,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str let mcp_url = state .mcp_url() .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); - let config = SimConfig::spec_read_only(model, full_system_prompt, mcp_url); + let config = SimConfig::spec_read_write(model, full_system_prompt, mcp_url); match super::runner::start_lean_batch_turn(&config, &initial_prompt).await { Ok((claude_session_id, batch_response)) => { @@ -173,29 +173,20 @@ pub async fn orchestrate_lean_navigate( // Spawn background pregen if needed. maybe_trigger_pregen(&state, &session_id, &target_node_id); - - // Spawn background spec updates for any suggestions on this node. - let spec_suggestions = { - let session = state.get_sim_session(&session_id); - session - .and_then(|s| { - s.lean_graph - .as_ref() - .and_then(|g| g.get_node(&target_node_id).cloned()) - }) - .map(|n| n.spec_updates) - .unwrap_or_default() - }; - - for suggestion in spec_suggestions { - let state3 = state.clone(); - let sid3 = session_id.clone(); - tokio::spawn(async move { - orchestrate_lean_spec_update(state3, sid3, suggestion.description).await; - }); - } } LeanEdgeKind::Leaf => { + // If a spec update is running, queue this leaf navigation for later. + let spec_updating = state + .get_sim_session(&session_id) + .map(|s| s.lean_spec_updating) + .unwrap_or(false); + if spec_updating { + state.update_sim_session(&session_id, |s| { + s.lean_queued_leaf = Some((current_id.clone(), edge_index)); + }); + return; + } + // Need to generate first. let generation = state .get_sim_session(&session_id) @@ -358,67 +349,172 @@ async fn orchestrate_lean_batch_pregen( s.lean_generation_target = None; }); info!(session_id, "Lean batch pregen complete"); + + // Check for queued work now that pregen is done. + spawn_queued_work(state, session_id); } Err(e) => { error!(session_id, error = %e, "Lean batch pregen failed"); set_lean_generating_false(&state, &session_id); + + // Check for queued work even on pregen failure. + spawn_queued_work(state, session_id); } } } -/// Background spec update using a separate AI session with write tools. +/// Send accumulated navigation actions to the main Claude session for spec updates. /// -/// Loads the full spec tree, builds a compact outline, and spawns a fresh -/// Claude session that can search the entire spec and place updates wherever -/// they belong (not just under the current feature). -async fn orchestrate_lean_spec_update( +/// Builds a prompt with the navigation history since the last send, the user's +/// notes, and the current spec outline. Resumes the main session which then +/// uses write MCP tools to update the spec. +pub async fn orchestrate_lean_send_actions( state: Arc, session_id: String, - behavior_description: String, + user_notes: String, ) { - let session = match state.get_sim_session(&session_id) { - Some(s) => s, - None => return, + info!(session_id, "Starting lean send actions"); + + // 1. Snapshot unsent path range and set spec_updating flag. + let (claude_sid, spec_id, unsent_history_text, new_sent_len) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let claude_sid = match &session.claude_session_id { + Some(id) => id.clone(), + None => { + set_error(&state, &session_id, "No claude session for send actions"); + return; + } + }; + let spec_id = session.spec_id.clone(); + let path = &session.lean_navigation_path; + let sent = session.lean_sent_path_len; + let new_sent_len = path.len(); + + // Build history text for unsent portion. + // Include the last sent node as context for the first transition. + let unsent_path: Vec = path[sent.saturating_sub(1)..].to_vec(); + let history_text = session + .lean_graph + .as_ref() + .map(|g| format_navigation_history(g, &unsent_path)) + .unwrap_or_default(); + + drop(session); + (claude_sid, spec_id, history_text, new_sent_len) }; - let spec_id = session.spec_id.clone(); - let model = session.model.clone(); - drop(session); - // Load full spec tree for the outline. + state.update_sim_session(&session_id, |s| { + s.lean_spec_updating = true; + }); + + // 2. Build spec outline for context. let roots = crate::api::get_spec_roots(&state, &spec_id).unwrap_or_default(); let descendants_by_root: Vec> = roots .iter() .map(|root| crate::api::get_descendants(&state, &root.id).unwrap_or_default()) .collect(); - let spec_outline = super::lean_prompt::build_spec_outline(&roots, &descendants_by_root); - let system_prompt = super::lean_prompt::build_spec_update_system_prompt(&spec_id); - let user_prompt = super::lean_prompt::build_spec_update_user_prompt( + + // 3. Build prompt. + let prompt = super::lean_prompt::build_send_actions_prompt( + &unsent_history_text, + &user_notes, &spec_id, - &behavior_description, &spec_outline, ); - let mcp_url = state - .mcp_url() - .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); - let config = SimConfig::spec_read_write(model, system_prompt, mcp_url); - - match super::runner::start_spec_update_session(&config, &user_prompt).await { - Ok(Some(mut update)) => { - update.outcome_summary = behavior_description; + // 4. Resume main session with spec update prompt. + match super::runner::resume_lean_spec_update_turn(&claude_sid, &prompt).await { + Ok(response) => { + let sent = state + .get_sim_session(&session_id) + .map(|s| s.lean_sent_path_len) + .unwrap_or(0); state.update_sim_session(&session_id, |s| { - s.game_spec_updates.push(update); + s.lean_spec_updating = false; + s.lean_sent_path_len = new_sent_len; + s.game_spec_updates.push(super::types::GameSpecUpdate { + interaction_label: String::new(), + outcome_summary: format!("{} actions sent", new_sent_len.saturating_sub(sent)), + description: response, + node_id: String::new(), + action: "send_actions".to_string(), + }); }); - info!(session_id, "Lean spec update applied"); - } - Ok(None) => { - info!(session_id, "Lean spec update: no changes needed"); + info!(session_id, "Lean send actions complete"); + + // Process any queued work. + spawn_queued_work(state, session_id); } Err(e) => { - error!(session_id, error = %e, "Lean spec update failed"); + error!(session_id, error = %e, "Lean send actions failed"); + state.update_sim_session(&session_id, |s| { + s.lean_spec_updating = false; + }); + } + } +} + +/// Spawn queued work after a spec update or pregen completes. +fn spawn_queued_work(state: Arc, session_id: String) { + // Check for queued leaf navigation. + let queued_leaf = state + .get_sim_session(&session_id) + .and_then(|s| s.lean_queued_leaf.clone()); + if let Some((_node_id, edge_index)) = queued_leaf { + state.update_sim_session(&session_id, |s| { + s.lean_queued_leaf = None; + }); + tokio::spawn(async move { + orchestrate_lean_navigate(state, session_id, edge_index).await; + }); + return; + } + + // Check for queued send actions. + let queued_send = state + .get_sim_session(&session_id) + .and_then(|s| s.lean_queued_send.clone()); + if let Some(notes) = queued_send { + state.update_sim_session(&session_id, |s| { + s.lean_queued_send = None; + }); + tokio::spawn(async move { + orchestrate_lean_send_actions(state, session_id, notes).await; + }); + } +} + +/// Format navigation history for the send actions prompt. +fn format_navigation_history( + graph: &super::lean_graph::LeanGraph, + path: &[String], +) -> String { + let history = graph.collect_path_history(path); + let mut text = String::new(); + for (i, (input, node)) in history.iter().enumerate() { + text.push_str(&format!("### Step {}\n", i + 1)); + text.push_str(&format!( + "**Action:** {}\n", + if input.raw_text.trim().is_empty() { + "(default/enter)" + } else { + input.raw_text.trim() + } + )); + if let Some(ui) = node.channels.get("ui") { + let output = if ui.text.len() > 500 { + format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) + } else { + ui.text.clone() + }; + text.push_str(&format!("**Output:**\n{}\n\n", output)); } } + text } /// Handle a player query about the current state. diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index b767815..3272fa1 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -201,8 +201,7 @@ Schema: "channels": {{{{ "": {{{{"text": "...", "refs": [], "spec_gaps": []}}}} }}}}, - "entropy_hint": 0.7, - "spec_updates": [{{{{"description": "User login defaults to OAuth flow when no password is set"}}}}] + "entropy_hint": 0.7 }}}}, {{{{"id": "n1", "channels": {{{{...}}}}, "entropy_hint": 0.9}}}}, {{{{"id": "n2", "channels": {{{{...}}}}, "entropy_hint": 0.2}}}} @@ -232,11 +231,6 @@ Active channels: {channel_list} 7. Node IDs must be short unique strings ("root", "n1", "n2", etc.). 8. Every node must include entries for ALL active channels. 9. Keep channel text concise — focus on the simulation output, not explanations. -10. spec_updates (optional array): Include when a node's behavior reveals something the spec \ - should record — a design decision, a default behavior, an edge case. Describe WHAT was \ - decided/observed, not WHERE it belongs in the spec. Omit if the behavior is already \ - clearly covered by the spec context above. A separate AI will determine the best \ - placement in the spec. {existing_section}"#, channel_list = channel_list, @@ -344,55 +338,55 @@ pub fn build_lean_modify_prompt(modification: &str) -> String { ) } -/// Build the system prompt for the background spec update AI. -pub fn build_spec_update_system_prompt(spec_id: &str) -> String { - format!( - "You are a spec placement AI. Your job is to find the best place in a specification \ - to record observed behavior from a simulation.\n\n\ - You have read-write access to the spec (spec_id: {spec_id}) via these tools:\n\ - - **search_nodes**: Semantic search across all spec nodes\n\ - - **search_features**: Find the closest matching feature root\n\ - - **get_node**: Read a specific node's details\n\ - - **get_descendants**: Read a node's subtree\n\ - - **get_spec_summary**: Get spec overview\n\ - - **add_children**: Create new Q&A nodes under a parent\n\ - - **answer_question**: Update or set a node's answer\n\ - - **add_feature**: Create a new root feature\n\n\ - ## Workflow\n\ - 1. Read the behavior description provided.\n\ - 2. Use search_nodes to find spec nodes related to the behavior.\n\ - 3. Decide the best action:\n\ - - **none**: The behavior is already clearly covered by an existing spec node.\n\ - - **update_answer**: An existing node covers this topic but the answer needs \ - updating. Call answer_question.\n\ - - **add_qa**: The behavior belongs under an existing node but no child covers it. \ - Use search_nodes/search_features to find the best parent, then call add_children \ - + answer_question.\n\ - - **add_feature**: The behavior represents an entirely new area not covered by \ - any existing feature. Call add_feature.\n\ - 4. Respond with a raw JSON object describing what you did.\n\n\ - ## Response Format\n\ - {{\"action\": \"none|add_qa|update_answer|add_feature\", \"node_id\": \"...\", \ - \"description\": \"...\"}}\n\n\ - JSON only, no markdown, no code fences." - ) -} - -/// Build the user prompt for a background spec update. -pub fn build_spec_update_user_prompt( +/// Build the prompt for sending accumulated navigation actions to update the spec. +/// +/// Includes the navigation history, user notes, and the full spec outline. +/// The AI uses write tools to apply updates and responds with a plain text summary. +pub fn build_send_actions_prompt( + navigation_history: &str, + user_notes: &str, spec_id: &str, - behavior_description: &str, spec_outline: &str, ) -> String { - format!( - "The following behavior was observed during a lean game simulation and should be \ - recorded in the spec (spec_id: {spec_id}):\n\n\ - **Observed behavior:** {behavior_description}\n\n\ - ## Current Spec Outline\n\ - {spec_outline}\n\n\ - Find the best place in the spec for this behavior. Use the tools to search, read, \ - and modify the spec as needed. Then respond with your action JSON." - ) + let mut prompt = String::new(); + + prompt.push_str("## Spec Update Request\n\n"); + prompt.push_str( + "The player has been navigating through the simulation and wants to update the spec \ + based on their observations. Below is their navigation history showing each interaction \ + they chose and the resulting output.\n\n", + ); + + prompt.push_str("### Navigation History\n"); + prompt.push_str(navigation_history); + + if !user_notes.trim().is_empty() { + prompt.push_str(&format!("\n### Player Notes\n{}\n\n", user_notes)); + } + + prompt.push_str( + "Remember: the player may have also asked you questions or requested modifications \ + during the session — take those into account as well when deciding what to update.\n\n", + ); + + prompt.push_str(&format!( + "### Current Spec Outline (spec_id: {})\n{}\n\n", + spec_id, spec_outline + )); + + prompt.push_str( + "Based on the navigation history and player notes, use the spec tools to update \ + the specification. You can:\n\ + - **search_nodes / search_features**: Find related spec areas\n\ + - **get_node / get_descendants**: Read details\n\ + - **answer_question**: Update an existing node's answer\n\ + - **add_children**: Add new Q&A under an existing node\n\ + - **add_feature**: Create a new feature root\n\n\ + Skip any behavior already clearly covered by existing spec content.\n\n\ + After making all updates, respond with a brief summary of what you changed.", + ); + + prompt } /// Build a compact text outline of the entire spec tree. diff --git a/crates/spec-forest/src/simulation/lean_types.rs b/crates/spec-forest/src/simulation/lean_types.rs index 7730bb5..203449e 100644 --- a/crates/spec-forest/src/simulation/lean_types.rs +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -20,16 +20,6 @@ pub struct LeanNode { /// How close this node is to high-entropy spec areas (0.0–1.0). #[serde(default)] pub entropy_hint: f64, - /// Spec-relevant behaviors observed at this node that should be recorded. - #[serde(default)] - pub spec_updates: Vec, -} - -/// A suggestion from the main lean AI about behavior worth recording in the spec. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LeanSpecSuggestion { - /// Description of what behavior was observed or decided. - pub description: String, } // ── Edge ──────────────────────────────────────────────────────────────── @@ -98,8 +88,6 @@ pub struct LeanFlatNode { pub channels: HashMap, #[serde(default)] pub entropy_hint: f64, - #[serde(default)] - pub spec_updates: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 7e2616f..9058216 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1,5 +1,5 @@ use super::types::{ - FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, + FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameTreeResponse, GameTreeRoot, PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, }; @@ -418,25 +418,9 @@ impl SimConfig { } } - /// Config with only spec read tools (no filesystem access). - /// Used for lean game batch generation. - pub fn spec_read_only(model: String, system_prompt: String, mcp_url: String) -> Self { - Self { - model, - system_prompt, - mcp_url, - directory: None, - allowed_tools: [ - "mcp__spec-forest__search_nodes", - "mcp__spec-forest__get_node", - "mcp__spec-forest__get_descendants", - "mcp__spec-forest__get_spec_summary", - ] - .join(","), - } - } - - /// Config with spec read + write tools for background spec updates. + /// Config with spec read + write tools (no filesystem access). + /// Used for lean game batch generation and spec updates. + /// The system prompt controls when write tools are used. pub fn spec_read_write(model: String, system_prompt: String, mcp_url: String) -> Self { Self { model, @@ -985,13 +969,13 @@ pub async fn resume_lean_batch_turn( parse_lean_batch_response(&response_text) } -/// Resume a lean game session for a background spec update. +/// Resume the main lean session for a spec update turn. /// -/// Parses the response as a GameSpecUpdate JSON. -pub async fn resume_lean_spec_update( +/// Returns the AI's plain text response (summary of changes made). +pub async fn resume_lean_spec_update_turn( claude_session_id: &str, prompt: &str, -) -> Result, Box> { +) -> Result> { let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") @@ -1014,101 +998,7 @@ pub async fn resume_lean_spec_update( "Lean spec update turn complete" ); - // Try to parse as a spec update action. - if let Ok(action) = extract_json::(&response_text) { - let action_type = action.get("action").and_then(|a| a.as_str()).unwrap_or("none"); - if action_type == "none" { - return Ok(None); - } - let node_id = action.get("node_id").and_then(|n| n.as_str()).unwrap_or("").to_string(); - let description = action.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string(); - - return Ok(Some(GameSpecUpdate { - interaction_label: String::new(), - outcome_summary: String::new(), - description, - node_id, - action: action_type.to_string(), - })); - } - - Ok(None) -} - -/// Start a fresh Claude session for a background spec update. -/// -/// Unlike `resume_lean_spec_update`, this creates a new session with write tools -/// so the AI can search the whole spec and place updates wherever appropriate. -pub async fn start_spec_update_session( - config: &SimConfig, - prompt: &str, -) -> Result, Box> { - let mcp_config = serde_json::json!({ - "mcpServers": { - "spec-forest": { - "type": "http", - "url": config.mcp_url - } - } - }); - - let mut cmd = tokio::process::Command::new("claude"); - cmd.arg("--print") - .arg("--output-format") - .arg("stream-json") - .arg("--verbose") - .arg("--model") - .arg(&config.model) - .arg("--system-prompt") - .arg(&config.system_prompt) - .arg("--mcp-config") - .arg(mcp_config.to_string()) - .arg("--allowedTools") - .arg(&config.allowed_tools) - .arg("-p") - .arg(prompt); - - tracing::info!( - prompt_chars = prompt.len(), - "Starting spec update session" - ); - - let (response_text, _) = run_claude_streaming(cmd).await?; - tracing::info!( - response_chars = response_text.len(), - "Spec update session complete" - ); - - // Parse the response as a spec update action. - if let Ok(action) = extract_json::(&response_text) { - let action_type = action - .get("action") - .and_then(|a| a.as_str()) - .unwrap_or("none"); - if action_type == "none" { - return Ok(None); - } - let node_id = action - .get("node_id") - .and_then(|n| n.as_str()) - .unwrap_or("") - .to_string(); - let description = action - .get("description") - .and_then(|d| d.as_str()) - .unwrap_or("") - .to_string(); - - return Ok(Some(GameSpecUpdate { - interaction_label: String::new(), - outcome_summary: String::new(), - description, - node_id, - action: action_type.to_string(), - })); - } - - Ok(None) + Ok(response_text) } /// Parse the AI's text response into a LeanBatchResponse. diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 8aa1821..917d371 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -130,6 +130,15 @@ pub struct SimSession { pub lean_generation_target: Option, /// Generation counter, incremented on modifications to invalidate stale pregens. pub lean_generation: u64, + /// How many entries in lean_navigation_path have been sent via "send actions." + /// New (unsent) actions are lean_navigation_path[lean_sent_path_len..]. + pub lean_sent_path_len: usize, + /// Whether a spec update prompt is running on the main session. + pub lean_spec_updating: bool, + /// Queued leaf navigation (current_node_id, edge_index) to run after spec update. + pub lean_queued_leaf: Option<(String, usize)>, + /// Queued send-actions request (user_notes) waiting for pregen to finish. + pub lean_queued_send: Option, } impl SimSession { @@ -174,6 +183,10 @@ impl SimSession { lean_generating: false, lean_generation_target: None, lean_generation: 0, + lean_sent_path_len: 1, + lean_spec_updating: false, + lean_queued_leaf: None, + lean_queued_send: None, } } } From 062666c63b146ca75ad6c47a3c982d029dec4277 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 11:00:19 +1100 Subject: [PATCH 07/19] fix: lean game modify updates display, send actions shows edge labels - Add LeanGraph::replace_at() for modify: replaces current node's content and edges with the new batch so the modified output is immediately visible - Update format_navigation_history to use edge labels (e.g. "Click Submit") instead of raw_text for clearer action descriptions - Send actions overlay now lists all unsent actions by label before the notes input, so users can see what they're sending --- crates/spec-forest-tui/src/app.rs | 17 +++- crates/spec-forest-tui/src/lean_state.rs | 2 + crates/spec-forest-tui/src/ui/lean_game.rs | 98 ++++++++++++++----- .../spec-forest/src/simulation/lean_graph.rs | 72 ++++++++++++++ .../src/simulation/lean_orchestrate.rs | 20 ++-- 5 files changed, 170 insertions(+), 39 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 7d24042..82eb235 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -3023,10 +3023,22 @@ impl App { lean.pregenerating = session.lean_generating; lean.game_spec_updates = session.game_spec_updates.clone(); - lean.unsent_action_count = session + let unsent_count = session .lean_navigation_path .len() .saturating_sub(session.lean_sent_path_len); + lean.unsent_action_count = unsent_count; + // Collect edge labels for unsent actions. + if let Some(ref graph) = session.lean_graph { + let sent = session.lean_sent_path_len; + let path = &session.lean_navigation_path; + let unsent_path = &path[sent.saturating_sub(1)..]; + lean.unsent_action_labels = graph + .collect_labeled_path_history(unsent_path) + .into_iter() + .map(|(label, _)| label) + .collect(); + } lean.spec_updating = session.lean_spec_updating; } } @@ -3035,10 +3047,11 @@ impl App { // Update can_go_back during generation so user can navigate back. if let Some(session) = self.state.get_sim_session(&session_id) { lean.can_go_back = session.lean_navigation_path.len() > 1; - lean.unsent_action_count = session + let unsent_count = session .lean_navigation_path .len() .saturating_sub(session.lean_sent_path_len); + lean.unsent_action_count = unsent_count; lean.spec_updating = session.lean_spec_updating; if let Some(ref graph) = session.lean_graph { let crumbs = diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs index 585c318..be98db9 100644 --- a/crates/spec-forest-tui/src/lean_state.rs +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -30,6 +30,7 @@ pub struct LeanGameState { pub send_actions_input: String, pub spec_updating: bool, pub unsent_action_count: usize, + pub unsent_action_labels: Vec, pub quit_pending: bool, } @@ -66,6 +67,7 @@ impl LeanGameState { send_actions_input: String::new(), spec_updating: false, unsent_action_count: 0, + unsent_action_labels: Vec::new(), quit_pending: false, } } diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs index a7d498b..97cb77f 100644 --- a/crates/spec-forest-tui/src/ui/lean_game.rs +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -289,36 +289,82 @@ fn render_input_overlay(app: &App, frame: &mut Frame) { let lean = app.lean_state.as_ref().unwrap(); let area = frame.area(); - let overlay_height = 5; - let overlay_area = ratatui::layout::Rect { - x: area.x + 1, - y: area.y + area.height.saturating_sub(overlay_height + 1), - width: area.width.saturating_sub(2), - height: overlay_height, - }; + if lean.send_actions_mode { + // Send actions overlay: show action list + notes input. + let action_lines = lean.unsent_action_labels.len() as u16; + // 2 for border + 1 header + actions + 1 blank + 3 for notes input area + let overlay_height = (4 + action_lines + 3).min(area.height.saturating_sub(2)); + let overlay_area = ratatui::layout::Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(overlay_height + 1), + width: area.width.saturating_sub(2), + height: overlay_height, + }; - frame.render_widget(Clear, overlay_area); + frame.render_widget(Clear, overlay_area); - let (title, input) = if lean.query_mode { - (" Query (Ctrl+S to submit, Esc to cancel) ", &lean.query_input) - } else if lean.modify_mode { - (" Modify (Ctrl+S to submit, Esc to cancel) ", &lean.modify_input) + let mut lines: Vec = Vec::new(); + lines.push(Line::from(Span::styled( + format!("Actions to send ({}):", lean.unsent_action_labels.len()), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ))); + for (i, label) in lean.unsent_action_labels.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!(" {}. ", i + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled(label, Style::default().fg(Color::White)), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Notes (optional):", + Style::default().fg(Color::Gray), + ))); + lines.push(Line::from(if lean.send_actions_input.is_empty() { + Span::styled("(type to add notes)", Style::default().fg(Color::DarkGray)) + } else { + Span::raw(&lean.send_actions_input) + })); + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Send Actions (Ctrl+S to submit, Esc to cancel) ") + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay_area); } else { - ( - " Send Actions — Add Notes (Ctrl+S to submit, Esc to cancel) ", - &lean.send_actions_input, - ) - }; + // Query or modify overlay. + let overlay_height = 5; + let overlay_area = ratatui::layout::Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(overlay_height + 1), + width: area.width.saturating_sub(2), + height: overlay_height, + }; - let paragraph = Paragraph::new(input.as_str()) - .block( - Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::default().fg(Color::Cyan)), - ) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, overlay_area); + frame.render_widget(Clear, overlay_area); + + let (title, input) = if lean.query_mode { + (" Query (Ctrl+S to submit, Esc to cancel) ", &lean.query_input) + } else { + (" Modify (Ctrl+S to submit, Esc to cancel) ", &lean.modify_input) + }; + + let paragraph = Paragraph::new(input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay_area); + } } fn render_report_overlay(app: &App, frame: &mut Frame) { diff --git a/crates/spec-forest/src/simulation/lean_graph.rs b/crates/spec-forest/src/simulation/lean_graph.rs index e61f6d1..06ecf4b 100644 --- a/crates/spec-forest/src/simulation/lean_graph.rs +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -134,6 +134,55 @@ impl LeanGraph { } } + /// Replace a node's content and edges with a new batch. + /// + /// Used by modify: the batch root replaces the anchor node's channels and + /// edges, so the player sees the modified output at the same position. + pub fn replace_at(&mut self, batch: LeanBatchResponse, anchor_node_id: &str) { + let mut id_map: HashMap = HashMap::new(); + + for (i, mut node) in batch.nodes.into_iter().enumerate() { + let uuid = if i == 0 { + // Reuse the anchor node's ID for the batch root. + anchor_node_id.to_string() + } else { + Uuid::new_v4().to_string() + }; + id_map.insert(node.node_id.clone(), uuid.clone()); + node.node_id = uuid.clone(); + self.nodes.insert(uuid, node); + } + + // Replace the anchor node's edges entirely. + self.edges.remove(anchor_node_id); + + for edge in batch.edges { + let from_id = id_map.get(&edge.from).cloned().unwrap_or(edge.from.clone()); + let to_id = if edge.is_shortcut { + edge.to.clone() + } else { + id_map.get(&edge.to).cloned().unwrap_or(edge.to.clone()) + }; + + let edge_kind = if edge.is_shortcut { + LeanEdgeKind::Shortcut + } else if self.nodes.contains_key(&to_id) { + LeanEdgeKind::Generative + } else { + LeanEdgeKind::Leaf + }; + + let lean_edge = LeanEdge { + label: edge.label, + input: edge.input, + target_node_id: to_id, + edge_kind, + }; + + self.edges.entry(from_id).or_default().push(lean_edge); + } + } + /// Get a node by ID. pub fn get_node(&self, id: &str) -> Option<&LeanNode> { self.nodes.get(id) @@ -235,6 +284,29 @@ impl LeanGraph { history } + /// Collect path history as (edge_label, node) pairs for display. + pub fn collect_labeled_path_history(&self, path: &[String]) -> Vec<(String, &LeanNode)> { + let mut history = Vec::new(); + + for i in 1..path.len() { + let prev_id = &path[i - 1]; + let curr_id = &path[i]; + + let label = self + .get_edges(prev_id) + .iter() + .find(|e| e.target_node_id == *curr_id) + .map(|e| e.label.clone()) + .unwrap_or_else(|| "???".to_string()); + + if let Some(node) = self.nodes.get(curr_id) { + history.push((label, node)); + } + } + + history + } + /// All node IDs in the graph (for passing to AI as shortcut targets). pub fn existing_node_ids(&self) -> Vec { self.nodes.keys().cloned().collect() diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 0e7e8a5..b8464e6 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -493,18 +493,11 @@ fn format_navigation_history( graph: &super::lean_graph::LeanGraph, path: &[String], ) -> String { - let history = graph.collect_path_history(path); + let history = graph.collect_labeled_path_history(path); let mut text = String::new(); - for (i, (input, node)) in history.iter().enumerate() { + for (i, (label, node)) in history.iter().enumerate() { text.push_str(&format!("### Step {}\n", i + 1)); - text.push_str(&format!( - "**Action:** {}\n", - if input.raw_text.trim().is_empty() { - "(default/enter)" - } else { - input.raw_text.trim() - } - )); + text.push_str(&format!("**Action:** {}\n", label)); if let Some(ui) = node.channels.get("ui") { let output = if ui.text.len() > 500 { format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) @@ -601,7 +594,12 @@ pub async fn orchestrate_lean_modify( state.update_sim_session(&session_id, |s| { if let Some(ref mut graph) = s.lean_graph { if let Some(ref cid) = current_id { - graph.merge_batch(batch_response, cid); + // Replace the current node's content and edges with the modified batch. + graph.replace_at(batch_response, cid); + // Update channel_contents so the TUI shows the modified output. + if let Some(node) = graph.get_node(cid) { + s.channel_contents = node.channels.clone(); + } } } s.status = SimStatus::Idle; From bca11f0e46b753cbc207c1c67947345fe94623b8 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 11:20:28 +1100 Subject: [PATCH 08/19] fix: lean game frontier indicator and auto-pregen after leaf navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show "◐" yellow indicator on edges approaching ungenerated frontier nodes instead of the normal "●" green. Trigger background pregen immediately after navigating via a leaf edge so the next level generates without delay. --- crates/spec-forest-tui/src/app.rs | 4 ++++ crates/spec-forest-tui/src/lean_state.rs | 2 ++ crates/spec-forest-tui/src/ui/lean_game.rs | 1 + crates/spec-forest/src/simulation/lean_orchestrate.rs | 2 ++ crates/spec-forest/src/tools.rs | 4 ++++ 5 files changed, 13 insertions(+) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 82eb235..488530c 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -2997,10 +2997,14 @@ impl App { } else { 0.5 }; + let at_frontier = edge.edge_kind + == spec_forest::simulation::LeanEdgeKind::Generative + && graph.has_leaf_edges(&edge.target_node_id); crate::lean_state::LeanInteractionView { label: edge.label.clone(), edge_kind: edge.edge_kind, entropy_hint: entropy, + at_frontier, } }) .collect(); diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs index be98db9..72cc549 100644 --- a/crates/spec-forest-tui/src/lean_state.rs +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -39,6 +39,8 @@ pub struct LeanInteractionView { pub label: String, pub edge_kind: LeanEdgeKind, pub entropy_hint: f64, + /// True if this edge's target node has only leaf (ungenerated) children. + pub at_frontier: bool, } impl LeanGameState { diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs index 97cb77f..9536f43 100644 --- a/crates/spec-forest-tui/src/ui/lean_game.rs +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -177,6 +177,7 @@ fn render_interactions(app: &App, frame: &mut Frame, area: ratatui::layout::Rect // Edge kind indicator. let (kind_symbol, kind_color) = match interaction.edge_kind { + LeanEdgeKind::Generative if interaction.at_frontier => ("◐", Color::Yellow), LeanEdgeKind::Generative => ("●", Color::Green), LeanEdgeKind::Leaf => ("○", Color::Yellow), LeanEdgeKind::Shortcut => ("↩", Color::DarkGray), diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index b8464e6..30fbcea 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -232,6 +232,8 @@ pub async fn orchestrate_lean_navigate( } s.status = SimStatus::Idle; }); + // Trigger pregen on the new node so next level starts generating. + maybe_trigger_pregen(&state2, &sid2, &target); return; } } diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 20a4046..d315fd5 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1794,11 +1794,15 @@ impl SpecForestServer { .iter() .enumerate() .map(|(i, e)| { + let at_frontier = e.edge_kind + == crate::simulation::LeanEdgeKind::Generative + && graph.has_leaf_edges(&e.target_node_id); serde_json::json!({ "index": i, "label": e.label, "edge_kind": format!("{:?}", e.edge_kind), "target_node_id": e.target_node_id, + "at_frontier": at_frontier, }) }) .collect(); From 763a4a502c7f60f0b8116de2e30d8b0dc11d3963 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 11:29:25 +1100 Subject: [PATCH 09/19] feat: lean send-actions prompt treats player journey as spec truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent navigation now signals acceptance — the AI is instructed to treat unmodified/unqueried outputs as correct and use them to fill unspecified gaps in the spec. --- .../spec-forest/src/simulation/lean_prompt.rs | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index 3272fa1..debec0d 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -352,9 +352,25 @@ pub fn build_send_actions_prompt( prompt.push_str("## Spec Update Request\n\n"); prompt.push_str( - "The player has been navigating through the simulation and wants to update the spec \ - based on their observations. Below is their navigation history showing each interaction \ - they chose and the resulting output.\n\n", + "The player has been navigating through the simulation. Their journey is a source of \ + truth for updating the spec. Below is their navigation history — each action they chose \ + and the resulting output.\n\n", + ); + + prompt.push_str( + "### How to interpret the journey\n\n\ + - **Silent navigation = acceptance.** If the player navigated to or past an output \ + without modifying or querying it, treat that action and its output as correct behavior. \ + The player is implicitly validating that the simulation behaved as expected.\n\ + - **New information fills spec gaps.** Where the spec is unspecified or underspecified \ + and the player's journey demonstrates concrete behavior for those areas, update the spec \ + to capture that new information. The journey is evidence of how the system should work.\n\ + - **Modifications and queries matter too.** The player may have asked you questions or \ + requested modifications during the session — those interactions (already in your session \ + context) should also inform what you update.\n\ + - **Don't duplicate existing coverage.** If the spec already clearly describes the \ + observed behavior, skip it. Only add or update where there is genuinely new information \ + from the journey.\n\n", ); prompt.push_str("### Navigation History\n"); @@ -364,26 +380,22 @@ pub fn build_send_actions_prompt( prompt.push_str(&format!("\n### Player Notes\n{}\n\n", user_notes)); } - prompt.push_str( - "Remember: the player may have also asked you questions or requested modifications \ - during the session — take those into account as well when deciding what to update.\n\n", - ); - prompt.push_str(&format!( "### Current Spec Outline (spec_id: {})\n{}\n\n", spec_id, spec_outline )); prompt.push_str( - "Based on the navigation history and player notes, use the spec tools to update \ - the specification. You can:\n\ + "Based on the navigation history, player notes, and any prior modifications or queries \ + from this session, use the spec tools to update the specification. You can:\n\ - **search_nodes / search_features**: Find related spec areas\n\ - **get_node / get_descendants**: Read details\n\ - **answer_question**: Update an existing node's answer\n\ - **add_children**: Add new Q&A under an existing node\n\ - **add_feature**: Create a new feature root\n\n\ - Skip any behavior already clearly covered by existing spec content.\n\n\ - After making all updates, respond with a brief summary of what you changed.", + Focus on capturing new information revealed by the player's journey — especially \ + behaviors that were previously unspecified. After making all updates, respond with a \ + brief summary of what you changed and why.", ); prompt From 79a034feb79a107431a562f8d094b19c758dbc79 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:04:12 +1100 Subject: [PATCH 10/19] fix: refresh spec nodes when returning from LeanGame/Simulation screens Op notifications were silently dropped while on LeanGame or Simulation screens, leaving self.nodes stale. Nodes added via MCP during those sessions would appear empty or missing when navigating back to SpecView. --- crates/spec-forest-tui/src/app.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 488530c..4783718 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -992,7 +992,10 @@ impl App { was_processing, }); self.sim_state = None; + let sid = spec_id.clone(); self.screen = Screen::SpecView { spec_id }; + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); } } Action::SimEndSimulation => { @@ -1018,7 +1021,10 @@ impl App { let session_id = session_id.clone(); self.state.remove_sim_session(&session_id); self.sim_state = None; + let sid = spec_id.clone(); self.screen = Screen::SpecView { spec_id }; + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); } } Action::SimCaptureKey(key) => { @@ -1629,7 +1635,10 @@ impl App { was_processing, }); self.lean_state = None; + let sid = spec_id.clone(); self.screen = Screen::SpecView { spec_id }; + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); } } Action::LeanEnd => { @@ -1645,7 +1654,10 @@ impl App { let session_id = session_id.clone(); self.state.remove_sim_session(&session_id); self.lean_state = None; + let sid = spec_id.clone(); self.screen = Screen::SpecView { spec_id }; + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); } } From b8b3851d71a5b39a0eb44f975a0e4a2f59456cff Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:11:12 +1100 Subject: [PATCH 11/19] fix: prevent spec questions from leaking into lean game simulation outputs Add explicit instructions across system prompt, channel semantics, and DAG rules telling the AI to render concrete application output rather than surfacing spec-level questions or uncertainty markers in channel text. --- crates/spec-forest/src/simulation/lean_prompt.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index debec0d..813441b 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -94,6 +94,12 @@ shortcut edges (`"shortcut": true`) that link to them. Shortcuts create realisti navigation: back buttons, shared destinations, menu returns, and loop-backs. A DAG without shortcuts is an unrealistic tree — real applications have convergent paths. +## CRITICAL: NO SPEC QUESTIONS IN OUTPUTS +Channel outputs must read like a REAL, FINISHED application. Never include spec questions, +uncertainty markers, or placeholder text like "What does this component do?" in any channel. +If the spec is silent on something, MAKE A CONCRETE CHOICE and render it confidently. +The entropy_hint field is where you signal uncertainty — not the channel text itself. + ## CRITICAL: JSON-ONLY OUTPUT Your ENTIRE response must be a single valid JSON object. Do NOT include any text, explanation, or markdown before or after the JSON. Do NOT wrap in code fences. @@ -135,7 +141,9 @@ Active channels: {channel_list} - "errors": Error messages from the simulated application - "logs": Application log output -Keep channel text concise. No refs, no spec_gaps — just the simulation output."#, +Keep channel text concise. No refs, no spec_gaps, no spec questions, no uncertainty \ +markers — just concrete simulation output as a real application would display it. \ +If the spec is ambiguous, make a definitive choice and reflect it in the output."#, spec_id = spec_id, spec_name = summary.spec.name, answered = summary.answered_count, @@ -231,6 +239,9 @@ Active channels: {channel_list} 7. Node IDs must be short unique strings ("root", "n1", "n2", etc.). 8. Every node must include entries for ALL active channels. 9. Keep channel text concise — focus on the simulation output, not explanations. +10. Channel text must NEVER contain spec questions, uncertainty markers, or placeholders. + Render every output as if the application is fully built. Use entropy_hint to signal + ambiguity — never leak it into the visible output. {existing_section}"#, channel_list = channel_list, From dcf0ad66ba9e11133baebad90ffb6e8b4280138f Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:21:29 +1100 Subject: [PATCH 12/19] feat: preserve full navigation history for lean game send-actions Back navigation no longer removes entries from the send-actions history. A separate append-only lean_action_history records every forward and back navigation chronologically, so send-actions reflects the complete player journey including backtracking. --- crates/spec-forest-tui/src/app.rs | 24 ++---- crates/spec-forest/src/simulation.rs | 2 +- .../src/simulation/lean_orchestrate.rs | 85 ++++++++++++------- .../spec-forest/src/simulation/lean_types.rs | 11 +++ crates/spec-forest/src/simulation/session.rs | 11 ++- 5 files changed, 84 insertions(+), 49 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 4783718..77f5268 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -3040,21 +3040,15 @@ impl App { lean.game_spec_updates = session.game_spec_updates.clone(); let unsent_count = session - .lean_navigation_path + .lean_action_history .len() - .saturating_sub(session.lean_sent_path_len); + .saturating_sub(session.lean_sent_history_len); lean.unsent_action_count = unsent_count; - // Collect edge labels for unsent actions. - if let Some(ref graph) = session.lean_graph { - let sent = session.lean_sent_path_len; - let path = &session.lean_navigation_path; - let unsent_path = &path[sent.saturating_sub(1)..]; - lean.unsent_action_labels = graph - .collect_labeled_path_history(unsent_path) - .into_iter() - .map(|(label, _)| label) - .collect(); - } + lean.unsent_action_labels = session.lean_action_history + [session.lean_sent_history_len..] + .iter() + .map(|e| e.label.clone()) + .collect(); lean.spec_updating = session.lean_spec_updating; } } @@ -3064,9 +3058,9 @@ impl App { if let Some(session) = self.state.get_sim_session(&session_id) { lean.can_go_back = session.lean_navigation_path.len() > 1; let unsent_count = session - .lean_navigation_path + .lean_action_history .len() - .saturating_sub(session.lean_sent_path_len); + .saturating_sub(session.lean_sent_history_len); lean.unsent_action_count = unsent_count; lean.spec_updating = session.lean_spec_updating; if let Some(ref graph) = session.lean_graph { diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index a40af0f..ad4ac8b 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -19,7 +19,7 @@ pub use prompt::{ pub use session::{SimChannel, SimSession, SimStatus}; pub use tree::BreadcrumbEntry; pub use lean_graph::LeanGraph; -pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanNode}; +pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanHistoryEntry, LeanNode}; pub use types::{ ChannelContent, Decision, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, GameTreeRoot, NodeRef, PredictedInteraction, SimInput, SimReport, SimReportResponse, diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 30fbcea..56cceac 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -155,14 +155,22 @@ pub async fn orchestrate_lean_navigate( let target_node_id = edge.target_node_id.clone(); let edge_kind = edge.edge_kind; + let edge_label = edge.label.clone(); drop(session); match edge_kind { LeanEdgeKind::Generative | LeanEdgeKind::Shortcut => { // Instant navigation. + let from_id = current_id.clone(); state.update_sim_session(&session_id, |s| { s.lean_current_node_id = Some(target_node_id.clone()); s.lean_navigation_path.push(target_node_id.clone()); + s.lean_action_history.push(super::lean_types::LeanHistoryEntry { + from_node_id: from_id.clone(), + to_node_id: target_node_id.clone(), + label: edge_label.clone(), + is_back: false, + }); // Update channel_contents for TUI. if let Some(ref graph) = s.lean_graph { if let Some(node) = graph.get_node(&target_node_id) { @@ -221,10 +229,18 @@ pub async fn orchestrate_lean_navigate( if let Some(edge) = edges.get(edge_index) { if edge.edge_kind != LeanEdgeKind::Leaf { let target = edge.target_node_id.clone(); + let edge_label = edge.label.clone(); + let from_id = curr.clone(); drop(s); state2.update_sim_session(&sid2, |s| { s.lean_current_node_id = Some(target.clone()); s.lean_navigation_path.push(target.clone()); + s.lean_action_history.push(super::lean_types::LeanHistoryEntry { + from_node_id: from_id, + to_node_id: target.clone(), + label: edge_label, + is_back: false, + }); if let Some(ref graph) = s.lean_graph { if let Some(node) = graph.get_node(&target) { s.channel_contents = node.channels.clone(); @@ -252,9 +268,17 @@ pub async fn orchestrate_lean_navigate( pub fn orchestrate_lean_go_back(state: Arc, session_id: &str) { state.update_sim_session(session_id, |s| { if s.lean_navigation_path.len() > 1 { + let from_id = s.lean_current_node_id.clone().unwrap_or_default(); s.lean_navigation_path.pop(); let prev_id = s.lean_navigation_path.last().cloned(); s.lean_current_node_id = prev_id.clone(); + let to_id = prev_id.clone().unwrap_or_default(); + s.lean_action_history.push(super::lean_types::LeanHistoryEntry { + from_node_id: from_id, + to_node_id: to_id, + label: "← Back".to_string(), + is_back: true, + }); // Update channel_contents for TUI. if let (Some(graph), Some(id)) = (&s.lean_graph, &prev_id) { if let Some(node) = graph.get_node(id) { @@ -377,7 +401,7 @@ pub async fn orchestrate_lean_send_actions( ) { info!(session_id, "Starting lean send actions"); - // 1. Snapshot unsent path range and set spec_updating flag. + // 1. Snapshot unsent history range and set spec_updating flag. let (claude_sid, spec_id, unsent_history_text, new_sent_len) = { let session = match state.get_sim_session(&session_id) { Some(s) => s, @@ -391,18 +415,14 @@ pub async fn orchestrate_lean_send_actions( } }; let spec_id = session.spec_id.clone(); - let path = &session.lean_navigation_path; - let sent = session.lean_sent_path_len; - let new_sent_len = path.len(); - - // Build history text for unsent portion. - // Include the last sent node as context for the first transition. - let unsent_path: Vec = path[sent.saturating_sub(1)..].to_vec(); - let history_text = session - .lean_graph - .as_ref() - .map(|g| format_navigation_history(g, &unsent_path)) - .unwrap_or_default(); + let sent = session.lean_sent_history_len; + let new_sent_len = session.lean_action_history.len(); + + // Build history text from unsent action history entries. + let unsent_entries: Vec = + session.lean_action_history[sent..].to_vec(); + let history_text = + format_history_from_entries(session.lean_graph.as_ref(), &unsent_entries); drop(session); (claude_sid, spec_id, history_text, new_sent_len) @@ -433,11 +453,11 @@ pub async fn orchestrate_lean_send_actions( Ok(response) => { let sent = state .get_sim_session(&session_id) - .map(|s| s.lean_sent_path_len) + .map(|s| s.lean_sent_history_len) .unwrap_or(0); state.update_sim_session(&session_id, |s| { s.lean_spec_updating = false; - s.lean_sent_path_len = new_sent_len; + s.lean_sent_history_len = new_sent_len; s.game_spec_updates.push(super::types::GameSpecUpdate { interaction_label: String::new(), outcome_summary: format!("{} actions sent", new_sent_len.saturating_sub(sent)), @@ -490,23 +510,30 @@ fn spawn_queued_work(state: Arc, session_id: String) { } } -/// Format navigation history for the send actions prompt. -fn format_navigation_history( - graph: &super::lean_graph::LeanGraph, - path: &[String], +/// Format action history entries for the send actions prompt. +fn format_history_from_entries( + graph: Option<&super::lean_graph::LeanGraph>, + entries: &[super::lean_types::LeanHistoryEntry], ) -> String { - let history = graph.collect_labeled_path_history(path); let mut text = String::new(); - for (i, (label, node)) in history.iter().enumerate() { + for (i, entry) in entries.iter().enumerate() { text.push_str(&format!("### Step {}\n", i + 1)); - text.push_str(&format!("**Action:** {}\n", label)); - if let Some(ui) = node.channels.get("ui") { - let output = if ui.text.len() > 500 { - format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) - } else { - ui.text.clone() - }; - text.push_str(&format!("**Output:**\n{}\n\n", output)); + if entry.is_back { + text.push_str("**Action:** ← Back (returned to previous state)\n"); + } else { + text.push_str(&format!("**Action:** {}\n", entry.label)); + } + if let Some(graph) = graph { + if let Some(node) = graph.get_node(&entry.to_node_id) { + if let Some(ui) = node.channels.get("ui") { + let output = if ui.text.len() > 500 { + format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) + } else { + ui.text.clone() + }; + text.push_str(&format!("**Output:**\n{}\n\n", output)); + } + } } } text diff --git a/crates/spec-forest/src/simulation/lean_types.rs b/crates/spec-forest/src/simulation/lean_types.rs index 203449e..ffcfd3b 100644 --- a/crates/spec-forest/src/simulation/lean_types.rs +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -100,3 +100,14 @@ pub struct LeanFlatEdge { #[serde(default)] pub shortcut: bool, } + +// ── Action history ───────────────────────────────────────────────────── + +/// A single entry in the chronological action history for send-actions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanHistoryEntry { + pub from_node_id: String, + pub to_node_id: String, + pub label: String, + pub is_back: bool, +} diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 917d371..050f079 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -130,9 +130,11 @@ pub struct SimSession { pub lean_generation_target: Option, /// Generation counter, incremented on modifications to invalidate stale pregens. pub lean_generation: u64, - /// How many entries in lean_navigation_path have been sent via "send actions." - /// New (unsent) actions are lean_navigation_path[lean_sent_path_len..]. - pub lean_sent_path_len: usize, + /// Chronological history of all navigation actions (forward and back). + /// Append-only. Used for send-actions. + pub lean_action_history: Vec, + /// How many entries in lean_action_history have been sent via "send actions." + pub lean_sent_history_len: usize, /// Whether a spec update prompt is running on the main session. pub lean_spec_updating: bool, /// Queued leaf navigation (current_node_id, edge_index) to run after spec update. @@ -183,7 +185,8 @@ impl SimSession { lean_generating: false, lean_generation_target: None, lean_generation: 0, - lean_sent_path_len: 1, + lean_action_history: Vec::new(), + lean_sent_history_len: 0, lean_spec_updating: false, lean_queued_leaf: None, lean_queued_send: None, From 57150e66997f84a8060f1150cced96db617aa66c Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:22:54 +1100 Subject: [PATCH 13/19] fix: eager pregen when navigating to frontier nodes in lean game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pregen was not triggering when navigating to a leaf node because: 1) A prior pregen (from an ancestor) held lean_generating=true, and after completing it never re-checked the current position. 2) The pregen anchor was the navigated-to node which might have only generative edges — merge_batch couldn't attach the new batch. Now find_pregen_target BFS-walks to the nearest node with leaf edges, and spawn_queued_work re-checks the current position after pregen ends. --- .../spec-forest/src/simulation/lean_graph.rs | 24 +++++++++++++++++ .../src/simulation/lean_orchestrate.rs | 27 ++++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_graph.rs b/crates/spec-forest/src/simulation/lean_graph.rs index 06ecf4b..d43e01a 100644 --- a/crates/spec-forest/src/simulation/lean_graph.rs +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -208,6 +208,30 @@ impl LeanGraph { .any(|e| e.edge_kind == LeanEdgeKind::Leaf) } + /// Find the nearest descendant (via generative edges) that has leaf edges. + /// Returns the node_id suitable as a pregen anchor, or `None` if no frontier found. + pub fn find_pregen_target(&self, node_id: &str) -> Option { + let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); + let mut visited = std::collections::HashSet::new(); + queue.push_back(node_id.to_string()); + visited.insert(node_id.to_string()); + + while let Some(current) = queue.pop_front() { + if self.has_leaf_edges(¤t) { + return Some(current); + } + for edge in self.get_edges(¤t) { + if edge.edge_kind == LeanEdgeKind::Generative + && !visited.contains(&edge.target_node_id) + { + visited.insert(edge.target_node_id.clone()); + queue.push_back(edge.target_node_id.clone()); + } + } + } + None + } + /// BFS depth of generated nodes reachable via generative edges. pub fn depth_remaining(&self, node_id: &str) -> u8 { let mut max_depth: u8 = 0; diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 56cceac..321c767 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -507,6 +507,15 @@ fn spawn_queued_work(state: Arc, session_id: String) { tokio::spawn(async move { orchestrate_lean_send_actions(state, session_id, notes).await; }); + return; + } + + // Re-check if current position needs pregen (user may have moved during prior pregen). + let current_id = state + .get_sim_session(&session_id) + .and_then(|s| s.lean_current_node_id.clone()); + if let Some(id) = current_id { + maybe_trigger_pregen(&state, &session_id, &id); } } @@ -643,25 +652,31 @@ pub async fn orchestrate_lean_modify( // ── Helpers ───────────────────────────────────────────────────────────── /// Check if the given node needs pregen and spawn it if so. +/// +/// Finds the nearest descendant with leaf edges to use as the actual pregen +/// anchor, so the generated batch attaches at the right frontier node. fn maybe_trigger_pregen(state: &Arc, session_id: &str, node_id: &str) { - let should_pregen = { + let pregen_target = { let session = state.get_sim_session(session_id); if let Some(ref s) = session { if let Some(ref graph) = s.lean_graph { let depth = graph.depth_remaining(node_id); - !s.lean_generating && depth < 2 + if !s.lean_generating && depth < 2 { + graph.find_pregen_target(node_id) + } else { + None + } } else { - false + None } } else { - false + None } }; - if should_pregen { + if let Some(target) = pregen_target { let state2 = state.clone(); let sid2 = session_id.to_string(); - let target = node_id.to_string(); state.update_sim_session(session_id, |s| { s.lean_generating = true; s.lean_generation_target = Some(target.clone()); From 3919b73e5f9212e9b2caea2b53ecf4f151fe6d84 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:40:34 +1100 Subject: [PATCH 14/19] fix: display interactions when navigating to existing nodes in lean game Generative/Shortcut navigation now explicitly sets status to Idle, and the Processing branch syncs interactions from the graph so existing nodes always show their edges regardless of status timing. --- crates/spec-forest-tui/src/app.rs | 38 ++++++++++++++++++- .../src/simulation/lean_orchestrate.rs | 2 + 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 77f5268..3a9779e 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -3054,7 +3054,6 @@ impl App { } spec_forest::simulation::SimStatus::Processing => { lean.processing = true; - // Update can_go_back during generation so user can navigate back. if let Some(session) = self.state.get_sim_session(&session_id) { lean.can_go_back = session.lean_navigation_path.len() > 1; let unsent_count = session @@ -3064,6 +3063,43 @@ impl App { lean.unsent_action_count = unsent_count; lean.spec_updating = session.lean_spec_updating; if let Some(ref graph) = session.lean_graph { + if let Some(ref current_id) = session.lean_current_node_id { + // Sync interactions even during processing so + // navigating to an existing node always shows edges. + lean.interactions = graph + .get_edges(current_id) + .iter() + .map(|edge| { + let entropy = if edge.edge_kind + != spec_forest::simulation::LeanEdgeKind::Leaf + { + graph + .get_node(&edge.target_node_id) + .map(|n| n.entropy_hint) + .unwrap_or(0.0) + } else { + 0.5 + }; + let at_frontier = edge.edge_kind + == spec_forest::simulation::LeanEdgeKind::Generative + && graph.has_leaf_edges(&edge.target_node_id); + crate::lean_state::LeanInteractionView { + label: edge.label.clone(), + edge_kind: edge.edge_kind, + entropy_hint: entropy, + at_frontier, + } + }) + .collect(); + if lean.selected_interaction >= lean.interactions.len() + && !lean.interactions.is_empty() + { + lean.selected_interaction = 0; + } + if let Some(node) = graph.get_node(current_id) { + lean.channel_contents = node.channels.clone(); + } + } let crumbs = graph.collect_breadcrumbs(&session.lean_navigation_path); lean.breadcrumbs = crumbs diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 321c767..cfc3056 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -177,6 +177,8 @@ pub async fn orchestrate_lean_navigate( s.channel_contents = node.channels.clone(); } } + // Ensure status is Idle for instant navigation. + s.status = SimStatus::Idle; }); // Spawn background pregen if needed. From 9a58d9363bc241c95d5210c3656c8a82bb06921e Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:44:49 +1100 Subject: [PATCH 15/19] fix: lean game spec updates now cover all features, not just UI Include all simulation channels (network, audio, errors, logs) in the navigation history sent to the spec update prompt, and add guidance for the AI to reason about system-wide implications of UI interactions. --- .../src/simulation/lean_orchestrate.rs | 29 ++++++++++++++----- .../spec-forest/src/simulation/lean_prompt.rs | 4 +++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index cfc3056..d26776f 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -536,13 +536,28 @@ fn format_history_from_entries( } if let Some(graph) = graph { if let Some(node) = graph.get_node(&entry.to_node_id) { - if let Some(ui) = node.channels.get("ui") { - let output = if ui.text.len() > 500 { - format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) - } else { - ui.text.clone() - }; - text.push_str(&format!("**Output:**\n{}\n\n", output)); + const CHANNEL_ORDER: &[&str] = &["ui", "audio", "network", "errors", "logs"]; + let mut has_output = false; + for &channel_name in CHANNEL_ORDER { + if let Some(content) = node.channels.get(channel_name) { + if content.text.is_empty() { + continue; + } + let limit = if channel_name == "ui" { 500 } else { 200 }; + let output = if content.text.len() > limit { + format!( + "{}...", + &content.text[..content.text.floor_char_boundary(limit)] + ) + } else { + content.text.clone() + }; + text.push_str(&format!("**[{}]:**\n{}\n\n", channel_name, output)); + has_output = true; + } + } + if !has_output { + text.push('\n'); } } } diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index 813441b..42b8e72 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -373,6 +373,10 @@ pub fn build_send_actions_prompt( - **Silent navigation = acceptance.** If the player navigated to or past an output \ without modifying or querying it, treat that action and its output as correct behavior. \ The player is implicitly validating that the simulation behaved as expected.\n\ + - **Think beyond the UI.** Each interaction implies behavior across the full system. \ + If the player submits a form, that confirms not just the UI layout but also the API \ + endpoint, validation rules, data persistence, and any side effects. Update specs for \ + ALL relevant features — not just the screen the player was looking at.\n\ - **New information fills spec gaps.** Where the spec is unspecified or underspecified \ and the player's journey demonstrates concrete behavior for those areas, update the spec \ to capture that new information. The journey is evidence of how the system should work.\n\ From d5c1c477ddefc6f5b5447470982d93387061ce95 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 13:36:11 +1100 Subject: [PATCH 16/19] fix: add spec fidelity instructions to lean game system prompt The lean game AI was not grounding its outputs in the spec, making different choices even when the spec clearly specified behavior. Add SPEC FIDELITY and WHEN THE SPEC IS SILENT sections to both system prompt builders to prioritize faithful spec rendering over entropy exploration. --- .../spec-forest/src/simulation/lean_prompt.rs | 183 +++++++++++++++++- 1 file changed, 178 insertions(+), 5 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index 42b8e72..59a8e7d 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -80,7 +80,26 @@ pub fn build_lean_system_prompt( } format!( - r#"## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY + r#"## SPEC FIDELITY — YOUR PRIMARY OBLIGATION +- When the spec provides an answer for a behavior, you MUST render output that + matches that answer exactly. Do not improvise, reinterpret, or simplify. +- Think of yourself as an implementer following a spec document. If the spec says + the login page has email and password fields with a "Sign In" button, that is + what you render — not a variation. +- Before generating output for ANY area, use the MCP tools (search_nodes, + get_node, get_descendants) to verify what the spec says. Do not rely solely + on the context provided below — search for related nodes proactively. +- Do not invent behavior that contradicts what the spec says. When in doubt, + look it up. + +## WHEN THE SPEC IS SILENT +Only when the spec is genuinely silent or ambiguous on a topic should you make +implementation choices. In that case, make choices as a thoughtful implementer +would — pick reasonable defaults and render them confidently. The entropy_hint +field is where you signal that you made an unspecified choice, not the channel +text itself. + +## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY You simulate the program that would be built from this spec. The player navigates outputs and chooses interactions. Your job is to steer them toward the INTERESTING decisions — places where the spec is silent or ambiguous. @@ -135,7 +154,9 @@ Use these read-only tools when generating outputs that touch areas outside the l ## Channel Semantics Active channels: {channel_list} -- "ui": Unicode/box-drawing TUI rendering. Replace entirely each turn. Keep concise. +- "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would \ + build it. Replace entirely each turn. Use box-drawing characters, borders, and layout \ + to approximate any UI type (web, desktop, mobile, TUI). Keep concise. - "audio": Timestamped audio events, e.g. '[AUDIO] Click sound' - "network": Network events, e.g. '[NET] POST /api/users -> 201' - "errors": Error messages from the simulated application @@ -170,6 +191,158 @@ If the spec is ambiguous, make a definitive choice and reflect it in the output. ) } +/// Build the system prompt for a lean game simulation with the ENTIRE spec loaded. +/// +/// Similar to `build_lean_system_prompt` but includes all spec nodes instead of +/// just ancestors/descendants/other roots. +pub fn build_lean_system_prompt_whole_spec( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + high_entropy_nodes: &[(String, String)], // (node_id, question) + spec_id: &str, +) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + // Build focus node section (compact). + let mut focus_section = String::new(); + focus_section.push_str(&format!("### Focus Node (ID: {})\n", focus_node.id)); + focus_section.push_str(&format!("**Q:** {}\n", focus_node.question)); + if let Some(ref answer) = focus_node.answer { + focus_section.push_str(&format!("**A:** {}\n", answer)); + } else { + focus_section.push_str("**A:** _(unanswered)_\n"); + } + + // Build complete spec section with all nodes. + let mut all_nodes_section = String::new(); + for node in all_nodes { + if node.id == focus_node.id { + continue; + } + all_nodes_section.push_str(&format!("- **{}**: {}", node.id, node.question)); + if let Some(ref answer) = node.answer { + all_nodes_section.push_str(&format!(" → {}", answer)); + } else { + all_nodes_section.push_str(" _(unanswered)_"); + } + all_nodes_section.push('\n'); + } + + // High-entropy guidance. + let mut entropy_section = String::new(); + if !high_entropy_nodes.is_empty() { + entropy_section.push_str("## High-Uncertainty Spec Areas\n"); + entropy_section + .push_str("Steer interactions toward these areas — they need player decisions:\n\n"); + for (id, question) in high_entropy_nodes { + entropy_section.push_str(&format!("- **{}**: {}\n", id, question)); + } + } + + format!( + r#"## SPEC FIDELITY — YOUR PRIMARY OBLIGATION +- When the spec provides an answer for a behavior, you MUST render output that + matches that answer exactly. Do not improvise, reinterpret, or simplify. +- Think of yourself as an implementer following a spec document. If the spec says + the login page has email and password fields with a "Sign In" button, that is + what you render — not a variation. +- Before generating output for ANY area, use the MCP tools (search_nodes, + get_node, get_descendants) to verify what the spec says. Do not rely solely + on the context provided below — search for related nodes proactively. +- Do not invent behavior that contradicts what the spec says. When in doubt, + look it up. + +## WHEN THE SPEC IS SILENT +Only when the spec is genuinely silent or ambiguous on a topic should you make +implementation choices. In that case, make choices as a thoughtful implementer +would — pick reasonable defaults and render them confidently. The entropy_hint +field is where you signal that you made an unspecified choice, not the channel +text itself. + +## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY +You simulate the program that would be built from this spec. The player navigates +outputs and chooses interactions. Your job is to steer them toward the INTERESTING +decisions — places where the spec is silent or ambiguous. + +At each node, generate exactly 2 NEW child outputs via generative edges. +One of the 2 generative edges should lead toward a high-entropy spec area. +The other should represent the expected/obvious path. + +SHORTCUT EDGES ARE ESSENTIAL. Whenever existing nodes are listed, actively add +shortcut edges (`"shortcut": true`) that link to them. Shortcuts create realistic +navigation: back buttons, shared destinations, menu returns, and loop-backs. +A DAG without shortcuts is an unrealistic tree — real applications have convergent paths. + +## CRITICAL: NO SPEC QUESTIONS IN OUTPUTS +Channel outputs must read like a REAL, FINISHED application. Never include spec questions, +uncertainty markers, or placeholder text like "What does this component do?" in any channel. +If the spec is silent on something, MAKE A CONCRETE CHOICE and render it confidently. +The entropy_hint field is where you signal uncertainty — not the channel text itself. + +## CRITICAL: JSON-ONLY OUTPUT +Your ENTIRE response must be a single valid JSON object. Do NOT include any text, +explanation, or markdown before or after the JSON. Do NOT wrap in code fences. +The very first character must be `{{`. + +## Spec Context +Spec ID: {spec_id} +Spec "{spec_name}" — {answered} answered, {unanswered} unanswered, {needs_review} review. + +{focus_section} + +## Complete Specification (All Nodes) +The entire spec has been loaded. All nodes are listed below: + +{all_nodes_section} + +{entropy_section} + +## Tools (READ-ONLY) +You have read-only access to spec-forest MCP tools. Use them to look up spec details: +- **search_nodes**: Search by text (spec_id: {spec_id}) +- **get_node**: Get a node by ID +- **get_descendants**: Get a node's subtree +- **get_spec_summary**: Get spec overview + +These are the ONLY tools available. Do NOT attempt to use any other tools. +Do NOT try to modify the spec, create sessions, or call any sim_* or game_* tools. +Use these read-only tools when generating outputs that touch areas outside the loaded context. + +## Channel Semantics +Active channels: {channel_list} +- "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would \ + build it. Replace entirely each turn. Use box-drawing characters, borders, and layout \ + to approximate any UI type (web, desktop, mobile, TUI). Keep concise. +- "audio": Timestamped audio events, e.g. '[AUDIO] Click sound' +- "network": Network events, e.g. '[NET] POST /api/users -> 201' +- "errors": Error messages from the simulated application +- "logs": Application log output + +Keep channel text concise. No refs, no spec_gaps, no spec questions, no uncertainty \ +markers — just concrete simulation output as a real application would display it. \ +If the spec is ambiguous, make a definitive choice and reflect it in the output."#, + spec_id = spec_id, + spec_name = summary.spec.name, + answered = summary.answered_count, + unanswered = summary.unanswered_count, + needs_review = summary.needs_review_count, + focus_section = focus_section, + all_nodes_section = if all_nodes_section.is_empty() { + "_(no other nodes)_\n".to_string() + } else { + all_nodes_section + }, + entropy_section = entropy_section, + channel_list = channel_list, + ) +} + /// Build the lean batch output format section. /// /// Describes the DAG wire format: nodes + edges with generative/shortcut distinction. @@ -215,9 +388,9 @@ Schema: {{{{"id": "n2", "channels": {{{{...}}}}, "entropy_hint": 0.2}}}} ], "edges": [ - {{{{"from": "root", "to": "n1", "label": "Click Submit", "input": {{{{"keys": ["Enter"], "raw_text": "\\n"}}}}}}}}, - {{{{"from": "root", "to": "n2", "label": "Press Tab", "input": {{{{"keys": ["Tab"], "raw_text": "\\t"}}}}}}}}, - {{{{"from": "root", "to": "existing-uuid", "label": "Go Back", "input": {{{{"keys": ["Escape"], "raw_text": ""}}}}, "shortcut": true}}}} + {{{{"from": "root", "to": "n1", "label": "Click Submit button", "input": {{{{"keys": ["Enter"], "raw_text": ""}}}}}}}}, + {{{{"from": "root", "to": "n2", "label": "Open Settings", "input": {{{{"keys": ["click"], "raw_text": ""}}}}}}}}, + {{{{"from": "root", "to": "existing-uuid", "label": "Navigate back", "input": {{{{"keys": ["back"], "raw_text": ""}}}}, "shortcut": true}}}} ] }}}} From 9bf504b00a595d8be55f708803c89ae362855362 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 14:03:49 +1100 Subject: [PATCH 17/19] feat: add warmup interactions while lean game loads Spawn fast Haiku-based text scenarios in parallel with the slow initial lean game turn so the player has something to do while waiting. Warmup picks high-entropy spec nodes, generates short situational prompts, and captures player responses for later spec updates via send-actions. --- crates/spec-forest-tui/src/app.rs | 9 + crates/spec-forest/src/simulation.rs | 2 + .../src/simulation/lean_orchestrate.rs | 101 ++++++++--- crates/spec-forest/src/simulation/runner.rs | 32 ++++ crates/spec-forest/src/simulation/session.rs | 23 +++ .../src/simulation/warmup_orchestrate.rs | 169 ++++++++++++++++++ .../src/simulation/warmup_types.rs | 27 +++ crates/spec-forest/src/tool_types.rs | 8 + crates/spec-forest/src/tools.rs | 65 +++++++ 9 files changed, 408 insertions(+), 28 deletions(-) create mode 100644 crates/spec-forest/src/simulation/warmup_orchestrate.rs create mode 100644 crates/spec-forest/src/simulation/warmup_types.rs diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 3a9779e..3d2f3a6 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -3215,6 +3215,15 @@ impl App { ) .await; }); + // Spawn warmup interactions in parallel. + let warmup_state = self.state.clone(); + let warmup_sid = session_id.clone(); + tokio::spawn(async move { + spec_forest::simulation::warmup_orchestrate::start_warmup( + warmup_state, warmup_sid, + ) + .await; + }); } else { tokio::spawn(async move { commands::run_sim_initial_turn( diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index ad4ac8b..5884c40 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -8,6 +8,8 @@ pub mod runner; pub mod session; pub mod tree; pub mod types; +pub mod warmup_orchestrate; +pub mod warmup_types; pub use prompt::{ append_code_aware_section, build_game_resume_prompt, build_game_spec_update_prompt, diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index d26776f..564ed4d 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -24,6 +24,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str let channels = session.channels.clone(); let scenario = session.scenario.clone(); let batch_depth = session.lean_batch_depth; + let whole_spec = session.whole_spec; let focus_node_id = match session.root_node_id { Some(ref id) => id.clone(), None => { @@ -50,36 +51,48 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str } }; - let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); - let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); - - let context_ids: std::collections::HashSet<&str> = ancestors - .iter() - .chain(descendants.iter()) - .map(|n| n.id.as_str()) - .chain(std::iter::once(focus_node_id.as_str())) - .collect(); - let other_roots = crate::api::get_spec_roots(&state, &spec_id) - .unwrap_or_default() - .into_iter() - .filter(|n| !context_ids.contains(n.id.as_str())) - .collect::>(); - // Collect high-entropy nodes. let high_entropy_nodes = collect_high_entropy_nodes(&state, &spec_id, 10); - // Build system prompt. + // Build system prompt — whole-spec or focused. let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); - let system_prompt = super::lean_prompt::build_lean_system_prompt( - &channels, - &focus_node, - &ancestors, - &descendants, - &summary, - &other_roots, - &high_entropy_nodes, - &spec_id, - ); + let system_prompt = if whole_spec { + let all_nodes = crate::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); + super::lean_prompt::build_lean_system_prompt_whole_spec( + &channels, + &focus_node, + &all_nodes, + &summary, + &high_entropy_nodes, + &spec_id, + ) + } else { + let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + + let context_ids: std::collections::HashSet<&str> = ancestors + .iter() + .chain(descendants.iter()) + .map(|n| n.id.as_str()) + .chain(std::iter::once(focus_node_id.as_str())) + .collect(); + let other_roots = crate::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + super::lean_prompt::build_lean_system_prompt( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + &high_entropy_nodes, + &spec_id, + ) + }; let output_format = super::lean_prompt::build_lean_batch_output_format( batch_depth, &channel_list, @@ -115,6 +128,8 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str s.status = SimStatus::Idle; }); info!(session_id, "Lean game initial turn complete"); + // Signal warmup that the real game is ready. + super::warmup_orchestrate::signal_game_ready(&state, &session_id); // Auto-pregen if root has shallow depth. maybe_trigger_pregen(&state, &session_id, &root_id_for_pregen); } @@ -442,10 +457,40 @@ pub async fn orchestrate_lean_send_actions( .collect(); let spec_outline = super::lean_prompt::build_spec_outline(&roots, &descendants_by_root); + // 2b. Include any warmup captures as additional context. + let warmup_section = { + let captures = state + .get_sim_session(&session_id) + .map(|s| s.warmup_captures.clone()) + .unwrap_or_default(); + if captures.is_empty() { + String::new() + } else { + let mut section = String::from( + "\n## Pre-game Warmup Feedback\n\ + The player provided these responses during warmup (before the game started). \ + Consider these when updating the spec:\n\n", + ); + for cap in &captures { + section.push_str(&format!( + "- **Re: {}**\n Player said: \"{}\"\n", + cap.node_question, cap.player_response + )); + } + section.push('\n'); + section + } + }; + // 3. Build prompt. + let full_notes = if warmup_section.is_empty() { + user_notes + } else { + format!("{user_notes}{warmup_section}") + }; let prompt = super::lean_prompt::build_send_actions_prompt( &unsent_history_text, - &user_notes, + &full_notes, &spec_id, &spec_outline, ); @@ -720,7 +765,7 @@ fn set_lean_generating_false(state: &AppState, session_id: &str) { } /// Collect high-entropy nodes from the spec for prompt guidance. -fn collect_high_entropy_nodes( +pub(crate) fn collect_high_entropy_nodes( state: &AppState, spec_id: &str, limit: usize, diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 9058216..c3b0de9 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1001,6 +1001,38 @@ pub async fn resume_lean_spec_update_turn( Ok(response_text) } +const WARMUP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Run a minimal Haiku call for warmup scenarios. No system prompt, no MCP tools. +pub async fn run_warmup_haiku( + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg("claude-haiku-4-5-20251001") + .arg("-p") + .arg(prompt); + + tracing::info!(prompt_chars = prompt.len(), "Starting warmup Haiku call"); + + let stream_future = run_claude_streaming(cmd); + match tokio::time::timeout(WARMUP_TIMEOUT, stream_future).await { + Ok(Ok((response_text, _session_id))) => { + tracing::info!( + response_chars = response_text.len(), + "Warmup Haiku call complete" + ); + Ok(response_text) + } + Ok(Err(e)) => Err(e), + Err(_) => Err("warmup Haiku call timed out after 30 seconds".into()), + } +} + /// Parse the AI's text response into a LeanBatchResponse. fn parse_lean_batch_response( text: &str, diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 050f079..33756a3 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -2,6 +2,7 @@ use super::lean_graph::LeanGraph; use super::types::{ ChannelContent, Decision, GameSpecUpdate, GameTreeRoot, SimReportResponse, SimTreeNode, }; +use super::warmup_types::{WarmupCapture, WarmupScenario}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; @@ -141,6 +142,21 @@ pub struct SimSession { pub lean_queued_leaf: Option<(String, usize)>, /// Queued send-actions request (user_notes) waiting for pregen to finish. pub lean_queued_send: Option, + // ── Warmup fields (fast Haiku interactions while main game loads) ── + /// Whether warmup interactions are active. + pub warmup_active: bool, + /// Current warmup scenario shown to the player. + pub warmup_scenario: Option, + /// Whether a warmup Haiku call is in flight. + pub warmup_generating: bool, + /// Whether the real game is ready but the player hasn't transitioned yet. + pub warmup_game_ready: bool, + /// Collected warmup Q&A pairs for later spec feeding. + pub warmup_captures: Vec, + /// Generation counter for warmup, used to discard stale responses. + pub warmup_generation: u64, + /// Remaining spec node IDs + questions for warmup scenarios. + pub warmup_remaining_nodes: Vec<(String, String)>, } impl SimSession { @@ -190,6 +206,13 @@ impl SimSession { lean_spec_updating: false, lean_queued_leaf: None, lean_queued_send: None, + warmup_active: false, + warmup_scenario: None, + warmup_generating: false, + warmup_game_ready: false, + warmup_captures: Vec::new(), + warmup_generation: 0, + warmup_remaining_nodes: Vec::new(), } } } diff --git a/crates/spec-forest/src/simulation/warmup_orchestrate.rs b/crates/spec-forest/src/simulation/warmup_orchestrate.rs new file mode 100644 index 0000000..32538a4 --- /dev/null +++ b/crates/spec-forest/src/simulation/warmup_orchestrate.rs @@ -0,0 +1,169 @@ +use std::sync::Arc; +use tracing::{error, info}; + +use super::session::SimStatus; +use super::warmup_types::{WarmupCapture, WarmupScenario}; +use crate::state::AppState; + +/// Start warmup interactions while the main lean game loads. +/// +/// Collects high-entropy spec nodes and generates the first warmup scenario +/// via Haiku. +pub async fn start_warmup(state: Arc, session_id: String) { + info!(session_id, "Starting warmup interactions"); + + let spec_id = match state.get_sim_session(&session_id) { + Some(s) => s.spec_id.clone(), + None => return, + }; + + // Collect candidate nodes (already prioritised: unanswered first, then needs-review). + let mut candidates = + super::lean_orchestrate::collect_high_entropy_nodes(&state, &spec_id, 20); + if candidates.is_empty() { + info!(session_id, "No candidate nodes for warmup"); + return; + } + + // Simple deterministic shuffle: reverse to start from the tail of the priority list, + // giving a mix of unanswered and needs-review nodes. + candidates.reverse(); + + let (node_id, node_question) = candidates.remove(0); + + state.update_sim_session(&session_id, |s| { + s.warmup_active = true; + s.warmup_generating = true; + s.warmup_generation = 1; + s.warmup_remaining_nodes = candidates; + }); + + generate_warmup_scenario(state, session_id, node_id, node_question).await; +} + +/// Generate a single warmup scenario from a spec node using Haiku. +async fn generate_warmup_scenario( + state: Arc, + session_id: String, + node_id: String, + node_question: String, +) { + let warmup_gen = match state.get_sim_session(&session_id) { + Some(s) => s.warmup_generation, + None => return, + }; + + let prompt = build_warmup_prompt(&node_question); + + match super::runner::run_warmup_haiku(&prompt).await { + Ok(scenario_text) => { + state.update_sim_session(&session_id, |s| { + // Discard if generation changed (game loaded or warmup cancelled). + if s.warmup_generation != warmup_gen { + return; + } + // If the real game already loaded while we were generating, skip. + if s.status == SimStatus::Idle && s.lean_graph.is_some() { + s.warmup_active = false; + s.warmup_generating = false; + return; + } + s.warmup_scenario = Some(WarmupScenario { + node_id: node_id.clone(), + node_question: node_question.clone(), + scenario_text, + responded: false, + }); + s.warmup_generating = false; + }); + info!(session_id, "Warmup scenario generated"); + } + Err(e) => { + error!(session_id, error = %e, "Warmup Haiku call failed"); + state.update_sim_session(&session_id, |s| { + s.warmup_active = false; + s.warmup_generating = false; + }); + } + } +} + +/// Handle a player's response to a warmup scenario. +/// +/// Captures the response, then either transitions to the real game (if ready) +/// or cycles to the next warmup scenario. +pub async fn handle_warmup_response(state: Arc, session_id: String, response: String) { + let (should_transition, next_node) = { + let mut transition = false; + let mut next = None; + + state.update_sim_session(&session_id, |s| { + if let Some(scenario) = s.warmup_scenario.take() { + s.warmup_captures.push(WarmupCapture { + node_id: scenario.node_id, + node_question: scenario.node_question, + scenario_text: scenario.scenario_text, + player_response: response.clone(), + }); + } + + if s.warmup_game_ready { + // Real game is ready — transition. + s.warmup_active = false; + transition = true; + } else if let Some(node) = s.warmup_remaining_nodes.pop() { + // Cycle to next scenario. + s.warmup_generating = true; + next = Some(node); + } else { + // No more nodes — deactivate warmup. + s.warmup_active = false; + } + }); + + (transition, next) + }; + + if should_transition { + info!(session_id, "Warmup transitioning to real game"); + return; + } + + if let Some((node_id, node_question)) = next_node { + generate_warmup_scenario(state, session_id, node_id, node_question).await; + } +} + +/// Signal that the real game has loaded. If no active warmup interaction, +/// deactivate immediately. Otherwise, set the flag for transition after +/// the player finishes their current scenario. +pub fn signal_game_ready(state: &AppState, session_id: &str) { + state.update_sim_session(session_id, |s| { + s.warmup_game_ready = true; + // If no scenario is active (or already responded), transition now. + let scenario_pending = s + .warmup_scenario + .as_ref() + .is_some_and(|sc| !sc.responded); + if !scenario_pending && !s.warmup_generating { + s.warmup_active = false; + } + }); + info!(session_id, "Warmup: real game ready signal sent"); +} + +fn build_warmup_prompt(node_question: &str) -> String { + format!( + r#"You are running a quick scenario for a software specification exploration game. + +The player is exploring a software specification. Present a SHORT scenario (2-3 sentences) that puts the player in a concrete situation where this question matters: + +"{node_question}" + +Rules: +- Text only, no markdown formatting +- Present a specific situation, then ask what the player would do or decide +- Under 100 words +- Be direct and specific, not abstract"# + ) +} diff --git a/crates/spec-forest/src/simulation/warmup_types.rs b/crates/spec-forest/src/simulation/warmup_types.rs new file mode 100644 index 0000000..9061ee5 --- /dev/null +++ b/crates/spec-forest/src/simulation/warmup_types.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +/// A warmup scenario currently being shown to the player while the main game loads. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WarmupScenario { + /// The spec node ID this warmup explores. + pub node_id: String, + /// The spec node's question text. + pub node_question: String, + /// AI-generated scenario text shown to the player. + pub scenario_text: String, + /// Whether the player has responded to this scenario. + pub responded: bool, +} + +/// A completed warmup exchange, captured for later spec updates. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WarmupCapture { + /// Spec node ID this exchange was about. + pub node_id: String, + /// The spec question being explored. + pub node_question: String, + /// The scenario presented to the player. + pub scenario_text: String, + /// The player's response. + pub player_response: String, +} diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index 29352eb..1c946e4 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -387,3 +387,11 @@ pub struct LeanModifyParams { #[schemars(description = "Modification to apply to the simulation output")] pub modification: String, } + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct LeanWarmupRespondParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Player's response to the warmup scenario")] + pub response: String, +} diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index d315fd5..e4fb976 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1782,6 +1782,28 @@ impl SpecForestServer { return Err(ErrorData::invalid_params("Session is not in lean mode", None)); } + // If warmup is active (main game still loading), return warmup content. + if session.warmup_active { + let mut response = serde_json::json!({ + "session_id": params.session_id, + "mode": "warmup", + "status": format!("{:?}", session.status), + "game_ready": session.warmup_game_ready, + }); + if let Some(ref scenario) = session.warmup_scenario { + response["warmup_scenario"] = serde_json::json!({ + "scenario_text": scenario.scenario_text, + "node_question": scenario.node_question, + "responded": scenario.responded, + }); + } else if session.warmup_generating { + response["warmup_generating"] = serde_json::json!(true); + } + return Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])); + } + let (channels, edges, breadcrumbs) = if let Some(ref graph) = session.lean_graph { let current_id = session.lean_current_node_id.as_deref().unwrap_or(""); let channels = graph @@ -1971,6 +1993,49 @@ impl SpecForestServer { )])) } + #[tool(description = "Respond to a warmup scenario while the main lean game loads. Your response will be captured as spec feedback. The warmup will cycle to a new scenario or transition to the real game when ready.")] + fn lean_warmup_respond( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + if !session.warmup_active { + return Err(ErrorData::invalid_params( + "Warmup is not active. The main game may have already loaded.", + None, + )); + } + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let response = params.response; + tokio::spawn(async move { + crate::simulation::warmup_orchestrate::handle_warmup_response(state, sid, response) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "ok", + "session_id": params.session_id, + "message": "Response captured. Poll lean_get_output for the next warmup scenario or the real game." + }) + .to_string(), + )])) + } + #[tool(description = "Get the log of spec updates triggered during lean game play. Same format as game_get_spec_updates.")] fn lean_get_spec_updates( &self, From c54c6a7d8b03aa59217b9d0e21428e631e8df331 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 14:10:29 +1100 Subject: [PATCH 18/19] feat: scenario-driven spec gap exploration in lean game Restructure lean game prompts so the AI acts as a scenario designer rather than a generic simulator. The AI now identifies high-entropy decisions it had to make and designs DAG paths as mini-scenarios that force the player to confront those assumptions. Key changes: - Activate spec_gaps field: AI logs implementer assumptions per channel - Feature-scoped entropy: high-entropy nodes filtered to focus feature - Scenario design framing: CARDINAL RULE rewritten for gap exploration - spec_gaps flow into send-actions prompt as validation evidence - Resume prompt guides continued scenario exploration --- .../src/simulation/lean_orchestrate.rs | 71 ++++++- .../spec-forest/src/simulation/lean_prompt.rs | 186 ++++++++++++------ .../src/simulation/warmup_orchestrate.rs | 2 +- 3 files changed, 198 insertions(+), 61 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 564ed4d..1df6267 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -51,8 +51,18 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str } }; - // Collect high-entropy nodes. - let high_entropy_nodes = collect_high_entropy_nodes(&state, &spec_id, 10); + // Detect if focus is a spec root (e.g., "What are we building?"). + let is_root_focus = focus_node.question.starts_with("What are we building") + || focus_node.question.starts_with("What are we exploring"); + + // Collect high-entropy nodes, scoped to focus feature when applicable. + let high_entropy_nodes = collect_high_entropy_nodes( + &state, + &spec_id, + 10, + Some(&focus_node_id), + is_root_focus, + ); // Build system prompt — whole-spec or focused. let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); @@ -65,6 +75,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str &summary, &high_entropy_nodes, &spec_id, + is_root_focus, ) } else { let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); @@ -91,6 +102,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str &other_roots, &high_entropy_nodes, &spec_id, + is_root_focus, ) }; let output_format = super::lean_prompt::build_lean_batch_output_format( @@ -598,6 +610,13 @@ fn format_history_from_entries( content.text.clone() }; text.push_str(&format!("**[{}]:**\n{}\n\n", channel_name, output)); + if !content.spec_gaps.is_empty() { + text.push_str(&format!( + "**[{} assumptions]:** {}\n\n", + channel_name, + content.spec_gaps.join("; ") + )); + } has_output = true; } } @@ -765,29 +784,73 @@ fn set_lean_generating_false(state: &AppState, session_id: &str) { } /// Collect high-entropy nodes from the spec for prompt guidance. +/// +/// When `focus_node_id` is provided and the focus node is not a spec root, +/// candidates are scoped to descendants of the focus node. Falls back to +/// unscoped collection if no descendants match. pub(crate) fn collect_high_entropy_nodes( state: &AppState, spec_id: &str, limit: usize, + focus_node_id: Option<&str>, + is_root_focus: bool, ) -> Vec<(String, String)> { let nodes = crate::api::get_spec_nodes(state, spec_id).unwrap_or_default(); + // When focused on a non-root feature, scope to its descendants. + let scope_ids: Option> = + if !is_root_focus { + if let Some(fid) = focus_node_id { + let descendants = crate::api::get_descendants(state, fid).unwrap_or_default(); + if !descendants.is_empty() { + Some(descendants.into_iter().map(|n| n.id).collect()) + } else { + None + } + } else { + None + } + } else { + None + }; + + let in_scope = |id: &str| -> bool { + match &scope_ids { + Some(ids) => ids.contains(id), + None => true, + } + }; + let mut candidates: Vec<(String, String)> = Vec::new(); // Unanswered first. for node in &nodes { - if node.answer.is_none() { + if node.answer.is_none() && in_scope(&node.id) { candidates.push((node.id.clone(), node.question.clone())); } } // Then nodes needing review. for node in &nodes { - if node.answer.is_some() && node.state == crate::NodeState::NeedsReview { + if node.answer.is_some() && node.state == crate::NodeState::NeedsReview && in_scope(&node.id) { candidates.push((node.id.clone(), node.question.clone())); } } + // Fall back to unscoped if feature-scoped search found nothing. + if candidates.is_empty() && scope_ids.is_some() { + for node in &nodes { + if node.answer.is_none() { + candidates.push((node.id.clone(), node.question.clone())); + } + } + for node in &nodes { + if node.answer.is_some() && node.state == crate::NodeState::NeedsReview { + candidates.push((node.id.clone(), node.question.clone())); + } + } + } + candidates.truncate(limit); candidates } diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index 59a8e7d..c853b25 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -19,6 +19,7 @@ pub fn build_lean_system_prompt( other_roots: &[Node], high_entropy_nodes: &[(String, String)], // (node_id, question) spec_id: &str, + is_root_focus: bool, ) -> String { let channel_list = channels .iter() @@ -68,15 +69,35 @@ pub fn build_lean_system_prompt( other_roots_section.push_str(&format!("- {} (ID: {})\n", node.question, node.id)); } - // High-entropy guidance. - let mut entropy_section = String::new(); + // Scenario design guidance with high-entropy nodes. + let mut scenario_section = String::new(); + if is_root_focus { + scenario_section.push_str( + "## Scenario Focus\n\ + This simulation was launched from the spec root. You may design scenarios \ + exploring spec gaps from ANY area of the specification.\n\n", + ); + } else { + scenario_section.push_str(&format!( + "## Scenario Focus\n\ + This simulation was launched from a specific feature: **{}**. \ + Design scenarios that explore gaps WITHIN this feature's scope. \ + Only venture outside this feature if a gap naturally depends on \ + cross-cutting behavior.\n\n", + focus_node.question + )); + } if !high_entropy_nodes.is_empty() { - entropy_section.push_str("## High-Uncertainty Spec Areas\n"); - entropy_section - .push_str("Steer interactions toward these areas — they need player decisions:\n\n"); + scenario_section.push_str( + "### Known Spec Gaps\n\ + These spec areas have unresolved or uncertain answers. Design scenarios \ + that naturally lead the player through situations where these questions \ + matter:\n\n", + ); for (id, question) in high_entropy_nodes { - entropy_section.push_str(&format!("- **{}**: {}\n", id, question)); + scenario_section.push_str(&format!("- **{}**: {}\n", id, question)); } + scenario_section.push('\n'); } format!( @@ -95,29 +116,39 @@ pub fn build_lean_system_prompt( ## WHEN THE SPEC IS SILENT Only when the spec is genuinely silent or ambiguous on a topic should you make implementation choices. In that case, make choices as a thoughtful implementer -would — pick reasonable defaults and render them confidently. The entropy_hint -field is where you signal that you made an unspecified choice, not the channel -text itself. +would — pick reasonable defaults and render them confidently. The channel text +must always read like a finished application. Use the spec_gaps array to log +each assumption you made, and entropy_hint to signal overall uncertainty. + +## CARDINAL RULE: YOU ARE A SCENARIO DESIGNER +You simulate the program that would be built from this spec. But your real job +is designing scenarios that EXPLORE SPEC GAPS. -## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY -You simulate the program that would be built from this spec. The player navigates -outputs and chooses interactions. Your job is to steer them toward the INTERESTING -decisions — places where the spec is silent or ambiguous. +Before generating the DAG, mentally identify 3–5 high-entropy decisions you must +make where the spec is silent. Then design the DAG so that each generative path +is a mini-scenario that forces the player to experience one of these decisions. -At each node, generate exactly 2 NEW child outputs via generative edges. -One of the 2 generative edges should lead toward a high-entropy spec area. -The other should represent the expected/obvious path. +At each node, generate exactly 2 NEW child outputs via generative edges: +- At least one edge should present a scenario that explores a spec gap — a place + where you had to make an assumption the player needs to validate or reject. +- The other can present an alternative scenario for a different gap, or the + expected/obvious path. SHORTCUT EDGES ARE ESSENTIAL. Whenever existing nodes are listed, actively add shortcut edges (`"shortcut": true`) that link to them. Shortcuts create realistic navigation: back buttons, shared destinations, menu returns, and loop-backs. A DAG without shortcuts is an unrealistic tree — real applications have convergent paths. -## CRITICAL: NO SPEC QUESTIONS IN OUTPUTS -Channel outputs must read like a REAL, FINISHED application. Never include spec questions, -uncertainty markers, or placeholder text like "What does this component do?" in any channel. -If the spec is silent on something, MAKE A CONCRETE CHOICE and render it confidently. -The entropy_hint field is where you signal uncertainty — not the channel text itself. +## SPEC_GAPS: LOG YOUR ASSUMPTIONS +For every node, populate the `spec_gaps` array on each channel with short notes +about assumptions you made for that output. Examples: +- "Assumed password minimum is 8 chars — spec silent on validation rules" +- "Chose to show inline error — spec doesn't specify error display pattern" +- "Defaulted to email-only login — spec doesn't mention social auth" + +These notes are your implementer log. They are NOT shown to the player but are +used later to determine which assumptions were validated through play. The channel +text itself must remain clean — no uncertainty markers, no spec questions. ## CRITICAL: JSON-ONLY OUTPUT Your ENTIRE response must be a single valid JSON object. Do NOT include any text, @@ -139,7 +170,7 @@ Spec "{spec_name}" — {answered} answered, {unanswered} unanswered, {needs_revi ### Other Areas {other_roots_section} -{entropy_section} +{scenario_section} ## Tools (READ-ONLY) You have read-only access to spec-forest MCP tools. Use them to look up spec details: @@ -162,9 +193,9 @@ Active channels: {channel_list} - "errors": Error messages from the simulated application - "logs": Application log output -Keep channel text concise. No refs, no spec_gaps, no spec questions, no uncertainty \ -markers — just concrete simulation output as a real application would display it. \ -If the spec is ambiguous, make a definitive choice and reflect it in the output."#, +Keep channel text concise — concrete simulation output as a real application would display it. \ +No spec questions or uncertainty markers in channel text. DO populate the spec_gaps array \ +with short implementer notes for each assumption you made."#, spec_id = spec_id, spec_name = summary.spec.name, answered = summary.answered_count, @@ -186,7 +217,7 @@ If the spec is ambiguous, make a definitive choice and reflect it in the output. } else { other_roots_section }, - entropy_section = entropy_section, + scenario_section = scenario_section, channel_list = channel_list, ) } @@ -202,6 +233,7 @@ pub fn build_lean_system_prompt_whole_spec( summary: &SpecSummary, high_entropy_nodes: &[(String, String)], // (node_id, question) spec_id: &str, + is_root_focus: bool, ) -> String { let channel_list = channels .iter() @@ -234,15 +266,35 @@ pub fn build_lean_system_prompt_whole_spec( all_nodes_section.push('\n'); } - // High-entropy guidance. - let mut entropy_section = String::new(); + // Scenario design guidance with high-entropy nodes. + let mut scenario_section = String::new(); + if is_root_focus { + scenario_section.push_str( + "## Scenario Focus\n\ + This simulation was launched from the spec root. You may design scenarios \ + exploring spec gaps from ANY area of the specification.\n\n", + ); + } else { + scenario_section.push_str(&format!( + "## Scenario Focus\n\ + This simulation was launched from a specific feature: **{}**. \ + Design scenarios that explore gaps WITHIN this feature's scope. \ + Only venture outside this feature if a gap naturally depends on \ + cross-cutting behavior.\n\n", + focus_node.question + )); + } if !high_entropy_nodes.is_empty() { - entropy_section.push_str("## High-Uncertainty Spec Areas\n"); - entropy_section - .push_str("Steer interactions toward these areas — they need player decisions:\n\n"); + scenario_section.push_str( + "### Known Spec Gaps\n\ + These spec areas have unresolved or uncertain answers. Design scenarios \ + that naturally lead the player through situations where these questions \ + matter:\n\n", + ); for (id, question) in high_entropy_nodes { - entropy_section.push_str(&format!("- **{}**: {}\n", id, question)); + scenario_section.push_str(&format!("- **{}**: {}\n", id, question)); } + scenario_section.push('\n'); } format!( @@ -261,29 +313,39 @@ pub fn build_lean_system_prompt_whole_spec( ## WHEN THE SPEC IS SILENT Only when the spec is genuinely silent or ambiguous on a topic should you make implementation choices. In that case, make choices as a thoughtful implementer -would — pick reasonable defaults and render them confidently. The entropy_hint -field is where you signal that you made an unspecified choice, not the channel -text itself. +would — pick reasonable defaults and render them confidently. The channel text +must always read like a finished application. Use the spec_gaps array to log +each assumption you made, and entropy_hint to signal overall uncertainty. + +## CARDINAL RULE: YOU ARE A SCENARIO DESIGNER +You simulate the program that would be built from this spec. But your real job +is designing scenarios that EXPLORE SPEC GAPS. -## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY -You simulate the program that would be built from this spec. The player navigates -outputs and chooses interactions. Your job is to steer them toward the INTERESTING -decisions — places where the spec is silent or ambiguous. +Before generating the DAG, mentally identify 3–5 high-entropy decisions you must +make where the spec is silent. Then design the DAG so that each generative path +is a mini-scenario that forces the player to experience one of these decisions. -At each node, generate exactly 2 NEW child outputs via generative edges. -One of the 2 generative edges should lead toward a high-entropy spec area. -The other should represent the expected/obvious path. +At each node, generate exactly 2 NEW child outputs via generative edges: +- At least one edge should present a scenario that explores a spec gap — a place + where you had to make an assumption the player needs to validate or reject. +- The other can present an alternative scenario for a different gap, or the + expected/obvious path. SHORTCUT EDGES ARE ESSENTIAL. Whenever existing nodes are listed, actively add shortcut edges (`"shortcut": true`) that link to them. Shortcuts create realistic navigation: back buttons, shared destinations, menu returns, and loop-backs. A DAG without shortcuts is an unrealistic tree — real applications have convergent paths. -## CRITICAL: NO SPEC QUESTIONS IN OUTPUTS -Channel outputs must read like a REAL, FINISHED application. Never include spec questions, -uncertainty markers, or placeholder text like "What does this component do?" in any channel. -If the spec is silent on something, MAKE A CONCRETE CHOICE and render it confidently. -The entropy_hint field is where you signal uncertainty — not the channel text itself. +## SPEC_GAPS: LOG YOUR ASSUMPTIONS +For every node, populate the `spec_gaps` array on each channel with short notes +about assumptions you made for that output. Examples: +- "Assumed password minimum is 8 chars — spec silent on validation rules" +- "Chose to show inline error — spec doesn't specify error display pattern" +- "Defaulted to email-only login — spec doesn't mention social auth" + +These notes are your implementer log. They are NOT shown to the player but are +used later to determine which assumptions were validated through play. The channel +text itself must remain clean — no uncertainty markers, no spec questions. ## CRITICAL: JSON-ONLY OUTPUT Your ENTIRE response must be a single valid JSON object. Do NOT include any text, @@ -301,7 +363,7 @@ The entire spec has been loaded. All nodes are listed below: {all_nodes_section} -{entropy_section} +{scenario_section} ## Tools (READ-ONLY) You have read-only access to spec-forest MCP tools. Use them to look up spec details: @@ -324,9 +386,9 @@ Active channels: {channel_list} - "errors": Error messages from the simulated application - "logs": Application log output -Keep channel text concise. No refs, no spec_gaps, no spec questions, no uncertainty \ -markers — just concrete simulation output as a real application would display it. \ -If the spec is ambiguous, make a definitive choice and reflect it in the output."#, +Keep channel text concise — concrete simulation output as a real application would display it. \ +No spec questions or uncertainty markers in channel text. DO populate the spec_gaps array \ +with short implementer notes for each assumption you made."#, spec_id = spec_id, spec_name = summary.spec.name, answered = summary.answered_count, @@ -338,7 +400,7 @@ If the spec is ambiguous, make a definitive choice and reflect it in the output. } else { all_nodes_section }, - entropy_section = entropy_section, + scenario_section = scenario_section, channel_list = channel_list, ) } @@ -404,8 +466,10 @@ Active channels: {channel_list} Good shortcut scenarios: "Go Back" / "Return to menu" / "Cancel" leading to a prior screen, "Submit" leading to a shared confirmation state, navigation tabs leading to already-visited areas, error-then-retry loops back to an input form. Shortcuts are free — use them generously. -4. One of the 2 generative edges should lead toward a HIGH-ENTROPY spec area. - The other should represent the expected/obvious behavior. +4. Each generative edge should represent a distinct scenario path. At least one should + explore a spec gap — a place where you had to make an assumption. The edge label + should hint at the scenario without revealing spec internals (e.g., "Submit with + short password" not "Test spec gap: password validation unspecified"). 5. entropy_hint (0.0–1.0): how close this node's state is to unresolved spec decisions. 0.0 = fully specified, 1.0 = highly ambiguous. 6. Leaf nodes at max depth: include edges but OMIT the target nodes from "nodes" array. @@ -413,8 +477,10 @@ Active channels: {channel_list} 8. Every node must include entries for ALL active channels. 9. Keep channel text concise — focus on the simulation output, not explanations. 10. Channel text must NEVER contain spec questions, uncertainty markers, or placeholders. - Render every output as if the application is fully built. Use entropy_hint to signal - ambiguity — never leak it into the visible output. + Render every output as if the application is fully built. +11. Populate spec_gaps on each channel with short notes about assumptions you made + for that output. These are your implementer log — they help track which decisions + need spec coverage. {existing_section}"#, channel_list = channel_list, @@ -492,6 +558,9 @@ pub fn build_lean_resume_prompt( prompt.push_str( "Generate the next DAG batch from the current state. \ + Continue designing scenario paths that explore spec gaps. Each new batch should \ + introduce scenarios for assumptions not yet explored. Populate spec_gaps on new nodes \ + with the assumptions you made.\n\n\ IMPORTANT: The existing nodes listed in the output format section are available as \ shortcut targets. Add shortcut edges generously — back-navigation, shared screens, \ and loop-backs make the DAG realistic. Aim for at least 1 shortcut per non-leaf node.\n\n\ @@ -556,6 +625,11 @@ pub fn build_send_actions_prompt( - **Modifications and queries matter too.** The player may have asked you questions or \ requested modifications during the session — those interactions (already in your session \ context) should also inform what you update.\n\ + - **Implementer assumptions (spec_gaps) are evidence.** Each step includes the \ + assumptions the simulator made (listed as \"assumptions\" after the channel output). \ + If the player navigated past without modifying, those assumptions are validated — \ + capture them as new spec answers. If the player modified or queried, the assumption \ + was wrong — do NOT add it.\n\ - **Don't duplicate existing coverage.** If the spec already clearly describes the \ observed behavior, skip it. Only add or update where there is genuinely new information \ from the journey.\n\n", diff --git a/crates/spec-forest/src/simulation/warmup_orchestrate.rs b/crates/spec-forest/src/simulation/warmup_orchestrate.rs index 32538a4..b7265b5 100644 --- a/crates/spec-forest/src/simulation/warmup_orchestrate.rs +++ b/crates/spec-forest/src/simulation/warmup_orchestrate.rs @@ -19,7 +19,7 @@ pub async fn start_warmup(state: Arc, session_id: String) { // Collect candidate nodes (already prioritised: unanswered first, then needs-review). let mut candidates = - super::lean_orchestrate::collect_high_entropy_nodes(&state, &spec_id, 20); + super::lean_orchestrate::collect_high_entropy_nodes(&state, &spec_id, 20, None, true); if candidates.is_empty() { info!(session_id, "No candidate nodes for warmup"); return; From a6e9f82c3b85837691240480f701957a4ba408cf Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 14:21:14 +1100 Subject: [PATCH 19/19] feat: add TUI rendering and input handling for lean game warmup Show warmup scenarios in the output panel while the main game loads. Players press 'r' to enter response mode and Ctrl+S to submit. The status bar shows warmup-specific hints and a "game ready!" indicator when the real game has loaded. Warmup state is synced from the session and cleared on transition to the real game. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 52 ++++++++ crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/lean_state.rs | 17 ++- crates/spec-forest-tui/src/ui/lean_game.rs | 142 ++++++++++++++------- 5 files changed, 169 insertions(+), 44 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index a524f24..a869d2e 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -168,6 +168,7 @@ pub enum Action { LeanEnterQuery, LeanEnterModify, LeanEnterSendActions, + LeanEnterWarmupRespond, LeanToggleUpdateLog, LeanScrollUp, LeanScrollDown, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 3d2f3a6..0f8716f 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -1479,6 +1479,14 @@ impl App { lean.modify_input.clear(); } } + Action::LeanEnterWarmupRespond => { + if let Some(ref mut lean) = self.lean_state { + if lean.warmup_active && lean.warmup_scenario_text.is_some() { + lean.warmup_mode = true; + lean.warmup_input.clear(); + } + } + } Action::LeanEnterSendActions => { if let Some(ref mut lean) = self.lean_state { if !lean.spec_updating && lean.unsent_action_count > 0 { @@ -1510,6 +1518,8 @@ impl App { lean.modify_input.push(c); } else if lean.send_actions_mode { lean.send_actions_input.push(c); + } else if lean.warmup_mode { + lean.warmup_input.push(c); } } } @@ -1521,6 +1531,8 @@ impl App { lean.modify_input.pop(); } else if lean.send_actions_mode { lean.send_actions_input.pop(); + } else if lean.warmup_mode { + lean.warmup_input.pop(); } } } @@ -1532,6 +1544,8 @@ impl App { lean.modify_input.push('\n'); } else if lean.send_actions_mode { lean.send_actions_input.push('\n'); + } else if lean.warmup_mode { + lean.warmup_input.push('\n'); } } } @@ -1587,6 +1601,22 @@ impl App { .await; }); } + } else if lean.warmup_mode { + let response = lean.warmup_input.clone(); + lean.warmup_mode = false; + lean.warmup_input.clear(); + if !response.trim().is_empty() { + let session_id = lean.session_id.clone(); + let state = self.state.clone(); + tokio::spawn(async move { + spec_forest::simulation::warmup_orchestrate::handle_warmup_response( + state, + session_id, + response, + ) + .await; + }); + } } } } @@ -1595,9 +1625,11 @@ impl App { lean.query_mode = false; lean.modify_mode = false; lean.send_actions_mode = false; + lean.warmup_mode = false; lean.query_input.clear(); lean.modify_input.clear(); lean.send_actions_input.clear(); + lean.warmup_input.clear(); } } Action::LeanBackground => { @@ -2975,6 +3007,14 @@ impl App { spec_forest::simulation::SimStatus::Idle => { if lean.processing { lean.processing = false; + // Clear warmup state. + lean.warmup_active = false; + lean.warmup_scenario_text = None; + lean.warmup_node_question = None; + lean.warmup_generating = false; + lean.warmup_game_ready = false; + lean.warmup_mode = false; + lean.warmup_input.clear(); // Check for pending report. if let Some(report) = self.state.take_sim_pending_report(&session_id) @@ -3055,6 +3095,18 @@ impl App { spec_forest::simulation::SimStatus::Processing => { lean.processing = true; if let Some(session) = self.state.get_sim_session(&session_id) { + // Sync warmup state. + lean.warmup_active = session.warmup_active; + lean.warmup_generating = session.warmup_generating; + lean.warmup_game_ready = session.warmup_game_ready; + lean.warmup_scenario_text = session + .warmup_scenario + .as_ref() + .map(|s| s.scenario_text.clone()); + lean.warmup_node_question = session + .warmup_scenario + .as_ref() + .map(|s| s.node_question.clone()); lean.can_go_back = session.lean_navigation_path.len() > 1; let unsent_count = session .lean_action_history diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index ca61287..ee08cf2 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -60,6 +60,7 @@ fn map_lean_normal_key(key: KeyCode) -> Action { KeyCode::Backspace => Action::LeanGoBack, KeyCode::Char('i') => Action::LeanEnterQuery, KeyCode::Char('m') => Action::LeanEnterModify, + KeyCode::Char('r') => Action::LeanEnterWarmupRespond, KeyCode::Char('s') => Action::LeanEnterSendActions, KeyCode::Char('u') => Action::LeanToggleUpdateLog, KeyCode::Char('Q') => Action::LeanEnd, diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs index 72cc549..c169aa2 100644 --- a/crates/spec-forest-tui/src/lean_state.rs +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -32,6 +32,14 @@ pub struct LeanGameState { pub unsent_action_count: usize, pub unsent_action_labels: Vec, pub quit_pending: bool, + // Warmup + pub warmup_active: bool, + pub warmup_scenario_text: Option, + pub warmup_node_question: Option, + pub warmup_generating: bool, + pub warmup_game_ready: bool, + pub warmup_mode: bool, + pub warmup_input: String, } /// View model for a single interaction in the lean game panel. @@ -71,11 +79,18 @@ impl LeanGameState { unsent_action_count: 0, unsent_action_labels: Vec::new(), quit_pending: false, + warmup_active: false, + warmup_scenario_text: None, + warmup_node_question: None, + warmup_generating: false, + warmup_game_ready: false, + warmup_mode: false, + warmup_input: String::new(), } } /// Whether we're in any text input mode. pub fn in_input_mode(&self) -> bool { - self.query_mode || self.modify_mode || self.send_actions_mode + self.query_mode || self.modify_mode || self.send_actions_mode || self.warmup_mode } } diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs index 9536f43..98ffe4b 100644 --- a/crates/spec-forest-tui/src/ui/lean_game.rs +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -46,7 +46,7 @@ pub fn render(app: &App, frame: &mut Frame) { render_status_bar(app, frame, chunks[3]); // ── Overlays ──────────────────────────────────────────────────── - if lean.query_mode || lean.modify_mode || lean.send_actions_mode { + if lean.query_mode || lean.modify_mode || lean.send_actions_mode || lean.warmup_mode { render_input_overlay(app, frame); } if lean.report_overlay.is_some() { @@ -88,42 +88,79 @@ fn render_output(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { // Build combined output from all channels. let mut lines: Vec = Vec::new(); - // UI channel gets primary display. - if let Some(content) = lean.channel_contents.get("ui") { - for line in content.text.lines() { - lines.push(Line::from(line.to_string())); - } - } - - // Other channels rendered below with prefixes. - for (key, content) in &lean.channel_contents { - if key == "ui" || content.text.is_empty() { - continue; - } - lines.push(Line::from("")); - for line in content.text.lines() { - let prefix = match key.as_str() { - "network" => "[NET] ", - "audio" => "[AUD] ", - "errors" => "[ERR] ", - "logs" => "[LOG] ", - _ => "", - }; - let style = match key.as_str() { - "errors" => Style::default().fg(Color::Red), - "network" => Style::default().fg(Color::Blue), - "audio" => Style::default().fg(Color::Magenta), - "logs" => Style::default().fg(Color::DarkGray), - _ => Style::default(), - }; + // If warmup is active, show warmup content instead of channels. + if lean.warmup_active { + if let Some(ref scenario) = lean.warmup_scenario_text { + lines.push(Line::from("")); + for line in scenario.lines() { + lines.push(Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(Color::White), + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Press 'r' to respond", + Style::default().fg(Color::Yellow), + ))); + if lean.warmup_game_ready { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Game ready! Respond or wait for auto-transition.", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ))); + } + } else if lean.warmup_generating { + lines.push(Line::from(Span::styled( + " Preparing warmup scenario...", + Style::default().fg(Color::Yellow), + ))); + } else { lines.push(Line::from(Span::styled( - format!("{prefix}{line}"), - style, + " Starting warmup...", + Style::default().fg(Color::DarkGray), ))); } + } else { + // UI channel gets primary display. + if let Some(content) = lean.channel_contents.get("ui") { + for line in content.text.lines() { + lines.push(Line::from(line.to_string())); + } + } + + // Other channels rendered below with prefixes. + for (key, content) in &lean.channel_contents { + if key == "ui" || content.text.is_empty() { + continue; + } + lines.push(Line::from("")); + for line in content.text.lines() { + let prefix = match key.as_str() { + "network" => "[NET] ", + "audio" => "[AUD] ", + "errors" => "[ERR] ", + "logs" => "[LOG] ", + _ => "", + }; + let style = match key.as_str() { + "errors" => Style::default().fg(Color::Red), + "network" => Style::default().fg(Color::Blue), + "audio" => Style::default().fg(Color::Magenta), + "logs" => Style::default().fg(Color::DarkGray), + _ => Style::default(), + }; + lines.push(Line::from(Span::styled( + format!("{prefix}{line}"), + style, + ))); + } + } } - let title = if lean.processing { + let title = if lean.warmup_active { + " Warmup (game loading...) " + } else if lean.processing { " Output (generating...) " } else if lean.spec_updating { " Output (updating spec...) " @@ -131,7 +168,9 @@ fn render_output(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { " Output " }; - let border_color = if lean.processing { + let border_color = if lean.warmup_active { + Color::Green + } else if lean.processing { Color::Yellow } else if lean.spec_updating { Color::Magenta @@ -158,7 +197,12 @@ fn render_interactions(app: &App, frame: &mut Frame, area: ratatui::layout::Rect let mut lines: Vec = Vec::new(); if lean.interactions.is_empty() { - if lean.processing { + if lean.warmup_active { + lines.push(Line::from(Span::styled( + " Game loading... explore warmup scenarios above", + Style::default().fg(Color::Green), + ))); + } else if lean.processing { lines.push(Line::from(Span::styled( " Generating interactions...", Style::default().fg(Color::Yellow), @@ -237,13 +281,23 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) return; } - let mut items: Vec<(&str, String)> = vec![ - ("↑↓", "select".into()), - ("Enter", "go".into()), - ("Bksp", "back".into()), - ("i", "query".into()), - ("m", "modify".into()), - ]; + let mut items: Vec<(&str, String)> = if lean.warmup_active { + let mut v = vec![("r", "respond".into())]; + if lean.warmup_game_ready { + v.push(("", "game ready!".into())); + } else { + v.push(("", "game loading...".into())); + } + v + } else { + vec![ + ("↑↓", "select".into()), + ("Enter", "go".into()), + ("Bksp", "back".into()), + ("i", "query".into()), + ("m", "modify".into()), + ] + }; if lean.unsent_action_count > 0 { items.push(("s", format!("send({})", lean.unsent_action_count))); @@ -339,7 +393,7 @@ fn render_input_overlay(app: &App, frame: &mut Frame) { .wrap(Wrap { trim: false }); frame.render_widget(paragraph, overlay_area); } else { - // Query or modify overlay. + // Query, modify, or warmup respond overlay. let overlay_height = 5; let overlay_area = ratatui::layout::Rect { x: area.x + 1, @@ -350,7 +404,9 @@ fn render_input_overlay(app: &App, frame: &mut Frame) { frame.render_widget(Clear, overlay_area); - let (title, input) = if lean.query_mode { + let (title, input) = if lean.warmup_mode { + (" Warmup Response (Ctrl+S to submit, Esc to cancel) ", &lean.warmup_input) + } else if lean.query_mode { (" Query (Ctrl+S to submit, Esc to cancel) ", &lean.query_input) } else { (" Modify (Ctrl+S to submit, Esc to cancel) ", &lean.modify_input)