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:
@@ -63,7 +63,9 @@ export type WsResponse =
|
|||||||
| { type: "agent_config_changed" }
|
| { type: "agent_config_changed" }
|
||||||
| { type: "tool_activity"; tool_name: string }
|
| { type: "tool_activity"; tool_name: string }
|
||||||
/** Heartbeat response confirming the connection is alive. */
|
/** 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 {
|
export interface ProviderConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -280,6 +282,7 @@ export class ChatWebSocket {
|
|||||||
message: string,
|
message: string,
|
||||||
) => void;
|
) => void;
|
||||||
private onAgentConfigChanged?: () => void;
|
private onAgentConfigChanged?: () => void;
|
||||||
|
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private closeTimer?: number;
|
private closeTimer?: number;
|
||||||
private wsPath = DEFAULT_WS_PATH;
|
private wsPath = DEFAULT_WS_PATH;
|
||||||
@@ -355,6 +358,8 @@ export class ChatWebSocket {
|
|||||||
data.message,
|
data.message,
|
||||||
);
|
);
|
||||||
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
|
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
|
||||||
|
if (data.type === "onboarding_status")
|
||||||
|
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||||
if (data.type === "pong") {
|
if (data.type === "pong") {
|
||||||
window.clearTimeout(this.heartbeatTimeout);
|
window.clearTimeout(this.heartbeatTimeout);
|
||||||
this.heartbeatTimeout = undefined;
|
this.heartbeatTimeout = undefined;
|
||||||
@@ -405,6 +410,7 @@ export class ChatWebSocket {
|
|||||||
message: string,
|
message: string,
|
||||||
) => void;
|
) => void;
|
||||||
onAgentConfigChanged?: () => void;
|
onAgentConfigChanged?: () => void;
|
||||||
|
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
},
|
},
|
||||||
wsPath = DEFAULT_WS_PATH,
|
wsPath = DEFAULT_WS_PATH,
|
||||||
) {
|
) {
|
||||||
@@ -417,6 +423,7 @@ export class ChatWebSocket {
|
|||||||
this.onActivity = handlers.onActivity;
|
this.onActivity = handlers.onActivity;
|
||||||
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
||||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||||
|
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||||
this.wsPath = wsPath;
|
this.wsPath = wsPath;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
>([]);
|
>([]);
|
||||||
const reconciliationEventIdRef = useRef(0);
|
const reconciliationEventIdRef = useRef(0);
|
||||||
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
||||||
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
|
const onboardingTriggeredRef = useRef(false);
|
||||||
|
|
||||||
const wsRef = useRef<ChatWebSocket | null>(null);
|
const wsRef = useRef<ChatWebSocket | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -237,6 +239,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onAgentConfigChanged: () => {
|
onAgentConfigChanged: () => {
|
||||||
setAgentConfigVersion((v) => v + 1);
|
setAgentConfigVersion((v) => v + 1);
|
||||||
},
|
},
|
||||||
|
onOnboardingStatus: (onboarding: boolean) => {
|
||||||
|
setNeedsOnboarding(onboarding);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -495,6 +500,63 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
gap: "24px",
|
gap: "24px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{needsOnboarding && messages.length === 0 && !loading && (
|
||||||
|
<div
|
||||||
|
data-testid="onboarding-welcome"
|
||||||
|
style={{
|
||||||
|
padding: "24px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "#1c2a1c",
|
||||||
|
border: "1px solid #2d4a2d",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
color: "#a0d4a0",
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Welcome to Story Kit
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 16px 0",
|
||||||
|
color: "#ccc",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="onboarding-start-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (onboardingTriggeredRef.current) return;
|
||||||
|
onboardingTriggeredRef.current = true;
|
||||||
|
setNeedsOnboarding(false);
|
||||||
|
sendMessage(
|
||||||
|
"I just created a new project. Help me set it up.",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: "#a0d4a0",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start Project Setup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{messages.map((msg: Message, idx: number) => (
|
{messages.map((msg: Message, idx: number) => (
|
||||||
<div
|
<div
|
||||||
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::{PipelineState, load_pipeline_state};
|
use crate::http::workflow::{PipelineState, load_pipeline_state};
|
||||||
|
use crate::io::onboarding;
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use crate::llm::chat;
|
use crate::llm::chat;
|
||||||
use crate::llm::types::Message;
|
use crate::llm::types::Message;
|
||||||
@@ -97,6 +98,11 @@ enum WsResponse {
|
|||||||
/// Heartbeat response to a client `Ping`. Lets the client confirm the
|
/// Heartbeat response to a client `Ping`. Lets the client confirm the
|
||||||
/// connection is alive and cancel any stale-connection timeout.
|
/// connection is alive and cancel any stale-connection timeout.
|
||||||
Pong,
|
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> {
|
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());
|
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.
|
// Subscribe to filesystem watcher events and forward them to the client.
|
||||||
// After each work-item event, also push the updated pipeline state.
|
// After each work-item event, also push the updated pipeline state.
|
||||||
// Config-changed events are forwarded as-is without a pipeline refresh.
|
// Config-changed events are forwarded as-is without a pipeline refresh.
|
||||||
@@ -564,6 +583,26 @@ mod tests {
|
|||||||
assert_eq!(json["type"], "pong");
|
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]
|
#[test]
|
||||||
fn serialize_permission_request_response() {
|
fn serialize_permission_request_response() {
|
||||||
let resp = WsResponse::PermissionRequest {
|
let resp = WsResponse::PermissionRequest {
|
||||||
@@ -878,7 +917,8 @@ mod tests {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
/// Helper: connect and return (sink, stream) plus read the initial
|
/// 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(
|
async fn connect_ws(
|
||||||
url: &str,
|
url: &str,
|
||||||
) -> (
|
) -> (
|
||||||
@@ -905,6 +945,22 @@ mod tests {
|
|||||||
other => panic!("expected text message, got: {other:?}"),
|
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)
|
(sink, stream, initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod fs;
|
pub mod fs;
|
||||||
|
pub mod onboarding;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod story_metadata;
|
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::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::providers::claude_code::ClaudeCodeResult;
|
||||||
use crate::llm::types::{Message, Role, ToolCall, ToolDefinition, ToolFunctionDefinition};
|
use crate::llm::types::{Message, Role, ToolCall, ToolDefinition, ToolFunctionDefinition};
|
||||||
use crate::state::SessionState;
|
use crate::state::SessionState;
|
||||||
@@ -278,11 +279,25 @@ where
|
|||||||
|
|
||||||
let mut current_history = messages.clone();
|
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(
|
current_history.insert(
|
||||||
0,
|
0,
|
||||||
Message {
|
Message {
|
||||||
role: Role::System,
|
role: Role::System,
|
||||||
content: SYSTEM_PROMPT.to_string(),
|
content: system_content,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: 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.
|
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