From 5567cdf480b9354bb025be186c08762303382061 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 24 Feb 2026 15:34:31 +0000 Subject: [PATCH] story-kit: merge 148_story_interactive_onboarding_guides_user_through_project_setup_after_init Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/client.ts | 9 +- frontend/src/components/Chat.tsx | 62 ++++++++ server/src/http/ws.rs | 58 ++++++- server/src/io/mod.rs | 1 + server/src/io/onboarding.rs | 260 +++++++++++++++++++++++++++++++ server/src/llm/chat.rs | 19 ++- server/src/llm/prompts.rs | 71 +++++++++ 7 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 server/src/io/onboarding.rs diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 65805d2..be14d2a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -63,7 +63,9 @@ export type WsResponse = | { type: "agent_config_changed" } | { type: "tool_activity"; tool_name: string } /** Heartbeat response confirming the connection is alive. */ - | { type: "pong" }; + | { type: "pong" } + /** Sent on connect when the project still needs onboarding (specs are placeholders). */ + | { type: "onboarding_status"; needs_onboarding: boolean }; export interface ProviderConfig { provider: string; @@ -280,6 +282,7 @@ export class ChatWebSocket { message: string, ) => void; private onAgentConfigChanged?: () => void; + private onOnboardingStatus?: (needsOnboarding: boolean) => void; private connected = false; private closeTimer?: number; private wsPath = DEFAULT_WS_PATH; @@ -355,6 +358,8 @@ export class ChatWebSocket { data.message, ); if (data.type === "agent_config_changed") this.onAgentConfigChanged?.(); + if (data.type === "onboarding_status") + this.onOnboardingStatus?.(data.needs_onboarding); if (data.type === "pong") { window.clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = undefined; @@ -405,6 +410,7 @@ export class ChatWebSocket { message: string, ) => void; onAgentConfigChanged?: () => void; + onOnboardingStatus?: (needsOnboarding: boolean) => void; }, wsPath = DEFAULT_WS_PATH, ) { @@ -417,6 +423,7 @@ export class ChatWebSocket { this.onActivity = handlers.onActivity; this.onReconciliationProgress = handlers.onReconciliationProgress; this.onAgentConfigChanged = handlers.onAgentConfigChanged; + this.onOnboardingStatus = handlers.onOnboardingStatus; this.wsPath = wsPath; this.shouldReconnect = true; diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index d6eb79e..fb51bd7 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -88,6 +88,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { >([]); const reconciliationEventIdRef = useRef(0); const [agentConfigVersion, setAgentConfigVersion] = useState(0); + const [needsOnboarding, setNeedsOnboarding] = useState(false); + const onboardingTriggeredRef = useRef(false); const wsRef = useRef(null); const messagesEndRef = useRef(null); @@ -237,6 +239,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { onAgentConfigChanged: () => { setAgentConfigVersion((v) => v + 1); }, + onOnboardingStatus: (onboarding: boolean) => { + setNeedsOnboarding(onboarding); + }, }); return () => { @@ -495,6 +500,63 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { gap: "24px", }} > + {needsOnboarding && messages.length === 0 && !loading && ( +
+

+ Welcome to Story Kit +

+

+ This project needs to be set up before you can start writing + stories. The agent will guide you through configuring your + project goals and tech stack. +

+ +
+ )} {messages.map((msg: Message, idx: number) => (
for Option { @@ -155,6 +161,19 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> impl poem let _ = tx.send(state.into()); } + // Push onboarding status so the frontend knows whether to show + // the onboarding welcome flow. + { + let needs = ctx + .state + .get_project_root() + .map(|root| onboarding::check_onboarding_status(&root).needs_onboarding()) + .unwrap_or(false); + let _ = tx.send(WsResponse::OnboardingStatus { + needs_onboarding: needs, + }); + } + // Subscribe to filesystem watcher events and forward them to the client. // After each work-item event, also push the updated pipeline state. // Config-changed events are forwarded as-is without a pipeline refresh. @@ -564,6 +583,26 @@ mod tests { assert_eq!(json["type"], "pong"); } + #[test] + fn serialize_onboarding_status_true() { + let resp = WsResponse::OnboardingStatus { + needs_onboarding: true, + }; + let json = serde_json::to_value(&resp).unwrap(); + assert_eq!(json["type"], "onboarding_status"); + assert_eq!(json["needs_onboarding"], true); + } + + #[test] + fn serialize_onboarding_status_false() { + let resp = WsResponse::OnboardingStatus { + needs_onboarding: false, + }; + let json = serde_json::to_value(&resp).unwrap(); + assert_eq!(json["type"], "onboarding_status"); + assert_eq!(json["needs_onboarding"], false); + } + #[test] fn serialize_permission_request_response() { let resp = WsResponse::PermissionRequest { @@ -878,7 +917,8 @@ mod tests { >; /// Helper: connect and return (sink, stream) plus read the initial - /// pipeline_state message that is always sent on connect. + /// pipeline_state and onboarding_status messages that are always sent + /// on connect. async fn connect_ws( url: &str, ) -> ( @@ -905,6 +945,22 @@ mod tests { other => panic!("expected text message, got: {other:?}"), }; + // The second message is the onboarding_status — consume it so + // callers only see application-level messages. + let second = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next()) + .await + .expect("timeout waiting for onboarding_status") + .expect("stream ended") + .expect("ws error"); + let onboarding: serde_json::Value = match second { + tungstenite::Message::Text(t) => serde_json::from_str(t.as_ref()).unwrap(), + other => panic!("expected text message, got: {other:?}"), + }; + assert_eq!( + onboarding["type"], "onboarding_status", + "expected onboarding_status, got: {onboarding}" + ); + (sink, stream, initial) } diff --git a/server/src/io/mod.rs b/server/src/io/mod.rs index 16688a5..dfd80b9 100644 --- a/server/src/io/mod.rs +++ b/server/src/io/mod.rs @@ -1,4 +1,5 @@ pub mod fs; +pub mod onboarding; pub mod search; pub mod shell; pub mod story_metadata; diff --git a/server/src/io/onboarding.rs b/server/src/io/onboarding.rs new file mode 100644 index 0000000..8e4dc1e --- /dev/null +++ b/server/src/io/onboarding.rs @@ -0,0 +1,260 @@ +use std::path::Path; + +/// Unique marker found in the scaffold template for `00_CONTEXT.md`. +/// If the project's context file contains this phrase, it is still the +/// default scaffold content and needs to be replaced. +const TEMPLATE_MARKER_CONTEXT: &str = "Agentic AI Code Assistant"; + +/// Unique marker found in the scaffold template for `STACK.md`. +const TEMPLATE_MARKER_STACK: &str = "Agentic Code Assistant"; + +/// Marker found in the default `script/test` scaffold output. +const TEMPLATE_MARKER_SCRIPT: &str = "No tests configured"; + +/// Summary of what parts of a project still need onboarding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OnboardingStatus { + /// True when the project context spec needs to be populated. + pub needs_context: bool, + /// True when the tech stack spec needs to be populated. + pub needs_stack: bool, + /// True when `script/test` still contains the scaffold placeholder. + pub needs_test_script: bool, + /// True when `.story_kit/project.toml` is missing or has no + /// `[[component]]` entries. + pub needs_project_toml: bool, +} + +impl OnboardingStatus { + /// Returns `true` when any onboarding step is still needed. + pub fn needs_onboarding(&self) -> bool { + self.needs_context || self.needs_stack + } +} + +/// Inspect the project at `project_root` and determine which onboarding +/// steps are still required. +pub fn check_onboarding_status(project_root: &Path) -> OnboardingStatus { + let story_kit = project_root.join(".story_kit"); + + OnboardingStatus { + needs_context: is_template_or_missing( + &story_kit.join("specs").join("00_CONTEXT.md"), + TEMPLATE_MARKER_CONTEXT, + ), + needs_stack: is_template_or_missing( + &story_kit.join("specs").join("tech").join("STACK.md"), + TEMPLATE_MARKER_STACK, + ), + needs_test_script: is_template_or_missing( + &project_root.join("script").join("test"), + TEMPLATE_MARKER_SCRIPT, + ), + needs_project_toml: needs_project_toml(&story_kit), + } +} + +/// Returns `true` when the file is missing, empty, or contains the +/// given scaffold marker string. +fn is_template_or_missing(path: &Path, marker: &str) -> bool { + match std::fs::read_to_string(path) { + Ok(content) => content.trim().is_empty() || content.contains(marker), + Err(_) => true, + } +} + +/// Returns `true` when `project.toml` is missing or has no +/// `[[component]]` entries. +fn needs_project_toml(story_kit: &Path) -> bool { + let toml_path = story_kit.join("project.toml"); + match std::fs::read_to_string(toml_path) { + Ok(content) => !content.contains("[[component]]"), + Err(_) => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_project(dir: &TempDir) -> std::path::PathBuf { + let root = dir.path().to_path_buf(); + let sk = root.join(".story_kit"); + fs::create_dir_all(sk.join("specs").join("tech")).unwrap(); + fs::create_dir_all(root.join("script")).unwrap(); + root + } + + // ── needs_onboarding ────────────────────────────────────────── + + #[test] + fn needs_onboarding_true_when_no_files_exist() { + let dir = TempDir::new().unwrap(); + let root = dir.path().to_path_buf(); + let status = check_onboarding_status(&root); + assert!(status.needs_onboarding()); + assert!(status.needs_context); + assert!(status.needs_stack); + assert!(status.needs_test_script); + assert!(status.needs_project_toml); + } + + #[test] + fn needs_onboarding_true_when_specs_contain_scaffold_markers() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + // Write scaffold template content + fs::write( + root.join(".story_kit/specs/00_CONTEXT.md"), + "# Project Context\nTo build a standalone Agentic AI Code Assistant...", + ) + .unwrap(); + fs::write( + root.join(".story_kit/specs/tech/STACK.md"), + "# Tech Stack\nThis is an Agentic Code Assistant binary...", + ) + .unwrap(); + + let status = check_onboarding_status(&root); + assert!(status.needs_context); + assert!(status.needs_stack); + assert!(status.needs_onboarding()); + } + + #[test] + fn needs_onboarding_false_when_specs_have_custom_content() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + fs::write( + root.join(".story_kit/specs/00_CONTEXT.md"), + "# My Project\n\nThis is an e-commerce platform for selling widgets.", + ) + .unwrap(); + fs::write( + root.join(".story_kit/specs/tech/STACK.md"), + "# Tech Stack\n\n## Backend: Python + FastAPI\n## Frontend: React + TypeScript", + ) + .unwrap(); + + let status = check_onboarding_status(&root); + assert!(!status.needs_context); + assert!(!status.needs_stack); + assert!(!status.needs_onboarding()); + } + + #[test] + fn needs_onboarding_true_when_specs_are_empty() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + fs::write(root.join(".story_kit/specs/00_CONTEXT.md"), " \n").unwrap(); + fs::write(root.join(".story_kit/specs/tech/STACK.md"), "").unwrap(); + + let status = check_onboarding_status(&root); + assert!(status.needs_context); + assert!(status.needs_stack); + } + + // ── needs_test_script ───────────────────────────────────────── + + #[test] + fn needs_test_script_true_when_placeholder() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + fs::write( + root.join("script/test"), + "#!/usr/bin/env bash\nset -euo pipefail\necho \"No tests configured\"\n", + ) + .unwrap(); + + let status = check_onboarding_status(&root); + assert!(status.needs_test_script); + } + + #[test] + fn needs_test_script_false_when_customised() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + fs::write( + root.join("script/test"), + "#!/usr/bin/env bash\nset -euo pipefail\ncargo test\n", + ) + .unwrap(); + + let status = check_onboarding_status(&root); + assert!(!status.needs_test_script); + } + + // ── needs_project_toml ──────────────────────────────────────── + + #[test] + fn needs_project_toml_true_when_missing() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + let status = check_onboarding_status(&root); + assert!(status.needs_project_toml); + } + + #[test] + fn needs_project_toml_true_when_no_components() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + fs::write( + root.join(".story_kit/project.toml"), + "# empty config\n", + ) + .unwrap(); + + let status = check_onboarding_status(&root); + assert!(status.needs_project_toml); + } + + #[test] + fn needs_project_toml_false_when_has_components() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + fs::write( + root.join(".story_kit/project.toml"), + "[[component]]\nname = \"app\"\npath = \".\"\nsetup = [\"cargo check\"]\n", + ) + .unwrap(); + + let status = check_onboarding_status(&root); + assert!(!status.needs_project_toml); + } + + // ── partial onboarding ──────────────────────────────────────── + + #[test] + fn needs_onboarding_true_when_only_context_is_template() { + let dir = TempDir::new().unwrap(); + let root = setup_project(&dir); + + // Context is still template + fs::write( + root.join(".story_kit/specs/00_CONTEXT.md"), + "Agentic AI Code Assistant placeholder", + ) + .unwrap(); + // Stack is customised + fs::write( + root.join(".story_kit/specs/tech/STACK.md"), + "# My Stack\nRuby on Rails + PostgreSQL", + ) + .unwrap(); + + let status = check_onboarding_status(&root); + assert!(status.needs_context); + assert!(!status.needs_stack); + assert!(status.needs_onboarding()); + } +} diff --git a/server/src/llm/chat.rs b/server/src/llm/chat.rs index 6989318..dc1215e 100644 --- a/server/src/llm/chat.rs +++ b/server/src/llm/chat.rs @@ -1,5 +1,6 @@ use crate::slog; -use crate::llm::prompts::SYSTEM_PROMPT; +use crate::io::onboarding; +use crate::llm::prompts::{ONBOARDING_PROMPT, SYSTEM_PROMPT}; use crate::llm::providers::claude_code::ClaudeCodeResult; use crate::llm::types::{Message, Role, ToolCall, ToolDefinition, ToolFunctionDefinition}; use crate::state::SessionState; @@ -278,11 +279,25 @@ where let mut current_history = messages.clone(); + // Build the system prompt — append onboarding instructions when the + // project's spec files still contain scaffold placeholders. + let system_content = { + let mut content = SYSTEM_PROMPT.to_string(); + if let Ok(root) = state.get_project_root() { + let status = onboarding::check_onboarding_status(&root); + if status.needs_onboarding() { + content.push_str("\n\n"); + content.push_str(ONBOARDING_PROMPT); + } + } + content + }; + current_history.insert( 0, Message { role: Role::System, - content: SYSTEM_PROMPT.to_string(), + content: system_content, tool_calls: None, tool_call_id: None, }, diff --git a/server/src/llm/prompts.rs b/server/src/llm/prompts.rs index 0eca931..51cebcd 100644 --- a/server/src/llm/prompts.rs +++ b/server/src/llm/prompts.rs @@ -89,3 +89,74 @@ REMEMBER: Remember: You are an autonomous agent that can both explain concepts and take action. Choose appropriately based on the user's request. "#; + +pub const ONBOARDING_PROMPT: &str = r#"ONBOARDING MODE ACTIVE — This is a newly scaffolded project. The spec files still contain placeholder content and must be replaced with real project information before any stories can be written. + +Guide the user through each step below. Ask ONE category of questions at a time — do not overwhelm the user with everything at once. + +## Step 1: Project Context +Ask the user: +- What is this project? What does it do? +- Who are the target users? +- What are the core features or goals? + +Then use `write_file` to write `.story_kit/specs/00_CONTEXT.md` with: +- **High-Level Goal** — a clear, concise summary of what the project does +- **Core Features** — 3-5 bullet points +- **Domain Definition** — key terms and roles +- **Glossary** — project-specific terminology + +## Step 2: Tech Stack +Ask the user: +- What programming language(s)? +- What framework(s) or libraries? +- What build tool(s)? +- What test runner(s)? (e.g. cargo test, pytest, jest, pnpm test) +- What linter(s)? (e.g. clippy, eslint, biome, ruff) + +Then use `write_file` to write `.story_kit/specs/tech/STACK.md` with: +- **Overview** of the architecture +- **Core Stack** — languages, frameworks, build tools +- **Coding Standards** — formatting, linting, quality gates +- **Libraries (Approved)** — key dependencies + +## Step 3: Test Script +Based on the tech stack answers, use `write_file` to write `script/test` — a bash script that invokes the project's actual test runner. Examples: +- Rust: `cargo test` +- Python: `pytest` +- Node/TypeScript: `pnpm test` +- Go: `go test ./...` +- Multi-component: run each component's tests sequentially + +The script must start with `#!/usr/bin/env bash` and `set -euo pipefail`. + +## Step 4: Project Configuration +Use `write_file` to write `.story_kit/project.toml` with `[[component]]` entries that match the chosen stack. Each component needs: +- `name` — component identifier (e.g. "backend", "frontend", "app") +- `path` — relative path from project root (use "." for root) +- `setup` — list of setup commands (e.g. ["pnpm install"], ["cargo check"]) +- `teardown` — list of cleanup commands (usually empty) + +Also include at least one `[[agent]]` entry for a coder agent: +```toml +[[agent]] +name = "coder-1" +stage = "coder" +role = "Implements features across all components." +model = "sonnet" +max_turns = 50 +max_budget_usd = 5.00 +``` + +## Step 5: Commit & Finish +After writing all files: +1. Use `exec_shell` to run: `git`, `["add", "-A"]` +2. Use `exec_shell` to run: `git`, `["commit", "-m", "docs: populate project specs and configure tooling"]` +3. Tell the user: "Your project is set up! You're ready to write Story #1. Just tell me what you'd like to build." + +## Rules +- Be conversational and helpful +- After each file write, briefly confirm what you wrote +- Make specs specific to the user's project — never leave scaffold placeholders +- Do NOT skip steps or combine multiple steps into one question +"#;