story-kit: merge 148_story_interactive_onboarding_guides_user_through_project_setup_after_init
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use crate::http::context::AppContext;
|
||||
use crate::http::workflow::{PipelineState, load_pipeline_state};
|
||||
use crate::io::onboarding;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::llm::chat;
|
||||
use crate::llm::types::Message;
|
||||
@@ -97,6 +98,11 @@ enum WsResponse {
|
||||
/// Heartbeat response to a client `Ping`. Lets the client confirm the
|
||||
/// connection is alive and cancel any stale-connection timeout.
|
||||
Pong,
|
||||
/// Sent on connect when the project's spec files still contain scaffold
|
||||
/// placeholder content and the user needs to go through onboarding.
|
||||
OnboardingStatus {
|
||||
needs_onboarding: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<WatcherEvent> for Option<WsResponse> {
|
||||
@@ -155,6 +161,19 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod fs;
|
||||
pub mod onboarding;
|
||||
pub mod search;
|
||||
pub mod shell;
|
||||
pub mod story_metadata;
|
||||
|
||||
260
server/src/io/onboarding.rs
Normal file
260
server/src/io/onboarding.rs
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
"#;
|
||||
|
||||
Reference in New Issue
Block a user