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:
Dave
2026-02-24 15:34:31 +00:00
parent 81ac2f309a
commit 5567cdf480
7 changed files with 476 additions and 4 deletions

View File

@@ -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)
}

View File

@@ -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
View 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());
}
}

View File

@@ -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,
},

View File

@@ -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
"#;