Compare commits

...

4 Commits

16 changed files with 1249 additions and 64 deletions
@@ -0,0 +1,27 @@
---
name: "Status command traffic light dots not coloured in Matrix"
---
# Bug 430: Status command traffic light dots not coloured in Matrix
## Description
The traffic light dots in the status command use plain Unicode characters (○ ● ◑ ✗) which render without colour in Matrix. The HTML formatted_body should use data-mx-color to colour them green/yellow/red.
## How to Reproduce
Send the status command to the bot in Matrix. Observe the dots are monochrome.
## Actual Result
Dots render as plain monochrome Unicode characters.
## Expected Result
Dots render in colour: green (● running), yellow (◑ throttled), red (✗ blocked), grey (○ idle). Use font tag with data-mx-color attribute for Matrix HTML formatted_body.
## Acceptance Criteria
- [ ] HTML formatted_body uses <font data-mx-color="#colour">dot</font> for each traffic light state
- [ ] Green (#00cc00) for running, yellow (#ffaa00) for throttled, red (#cc0000) for blocked, grey (#888888) for idle
- [ ] Plain text fallback remains unchanged (Unicode dots for non-HTML transports)
Generated
+1 -1
View File
@@ -4019,7 +4019,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "storkit" name = "storkit"
version = "0.7.1" version = "0.8.0"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"version": "0.7.1", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"version": "0.7.1", "version": "0.8.0",
"dependencies": { "dependencies": {
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0", "react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"private": true, "private": true,
"version": "0.7.1", "version": "0.8.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+29
View File
@@ -21,6 +21,19 @@ export type WsRequest =
config: ProviderConfig; config: ProviderConfig;
}; };
export interface WizardStepInfo {
step: string;
label: string;
status: string;
content?: string;
}
export interface WizardStateData {
steps: WizardStepInfo[];
current_step_index: number;
completed: boolean;
}
export interface AgentAssignment { export interface AgentAssignment {
agent_name: string; agent_name: string;
model: string | null; model: string | null;
@@ -80,6 +93,13 @@ export type WsResponse =
| { type: "pong" } | { type: "pong" }
/** Sent on connect when the project still needs onboarding (specs are placeholders). */ /** Sent on connect when the project still needs onboarding (specs are placeholders). */
| { type: "onboarding_status"; needs_onboarding: boolean } | { type: "onboarding_status"; needs_onboarding: boolean }
/** Sent on connect when a setup wizard is active. */
| {
type: "wizard_state";
steps: WizardStepInfo[];
current_step_index: number;
completed: boolean;
}
/** Streaming thinking token from an extended-thinking block, separate from regular text. */ /** Streaming thinking token from an extended-thinking block, separate from regular text. */
| { type: "thinking_token"; content: string } | { type: "thinking_token"; content: string }
/** Streaming token from a /btw side question response. */ /** Streaming token from a /btw side question response. */
@@ -438,6 +458,7 @@ export class ChatWebSocket {
private onAgentConfigChanged?: () => void; private onAgentConfigChanged?: () => void;
private onAgentStateChanged?: () => void; private onAgentStateChanged?: () => void;
private onOnboardingStatus?: (needsOnboarding: boolean) => void; private onOnboardingStatus?: (needsOnboarding: boolean) => void;
private onWizardState?: (state: WizardStateData) => void;
private onSideQuestionToken?: (content: string) => void; private onSideQuestionToken?: (content: string) => void;
private onSideQuestionDone?: (response: string) => void; private onSideQuestionDone?: (response: string) => void;
private onLogEntry?: ( private onLogEntry?: (
@@ -528,6 +549,12 @@ export class ChatWebSocket {
if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
if (data.type === "onboarding_status") if (data.type === "onboarding_status")
this.onOnboardingStatus?.(data.needs_onboarding); this.onOnboardingStatus?.(data.needs_onboarding);
if (data.type === "wizard_state")
this.onWizardState?.({
steps: data.steps,
current_step_index: data.current_step_index,
completed: data.completed,
});
if (data.type === "side_question_token") if (data.type === "side_question_token")
this.onSideQuestionToken?.(data.content); this.onSideQuestionToken?.(data.content);
if (data.type === "side_question_done") if (data.type === "side_question_done")
@@ -587,6 +614,7 @@ export class ChatWebSocket {
onAgentConfigChanged?: () => void; onAgentConfigChanged?: () => void;
onAgentStateChanged?: () => void; onAgentStateChanged?: () => void;
onOnboardingStatus?: (needsOnboarding: boolean) => void; onOnboardingStatus?: (needsOnboarding: boolean) => void;
onWizardState?: (state: WizardStateData) => void;
onSideQuestionToken?: (content: string) => void; onSideQuestionToken?: (content: string) => void;
onSideQuestionDone?: (response: string) => void; onSideQuestionDone?: (response: string) => void;
onLogEntry?: (timestamp: string, level: string, message: string) => void; onLogEntry?: (timestamp: string, level: string, message: string) => void;
@@ -606,6 +634,7 @@ export class ChatWebSocket {
this.onAgentConfigChanged = handlers.onAgentConfigChanged; this.onAgentConfigChanged = handlers.onAgentConfigChanged;
this.onAgentStateChanged = handlers.onAgentStateChanged; this.onAgentStateChanged = handlers.onAgentStateChanged;
this.onOnboardingStatus = handlers.onOnboardingStatus; this.onOnboardingStatus = handlers.onOnboardingStatus;
this.onWizardState = handlers.onWizardState;
this.onSideQuestionToken = handlers.onSideQuestionToken; this.onSideQuestionToken = handlers.onSideQuestionToken;
this.onSideQuestionDone = handlers.onSideQuestionDone; this.onSideQuestionDone = handlers.onSideQuestionDone;
this.onLogEntry = handlers.onLogEntry; this.onLogEntry = handlers.onLogEntry;
+27 -5
View File
@@ -4,7 +4,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import type { AgentConfigInfo } from "../api/agents"; import type { AgentConfigInfo } from "../api/agents";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import type { AnthropicModelInfo, PipelineState } from "../api/client"; import type {
AnthropicModelInfo,
PipelineState,
WizardStateData,
} from "../api/client";
import { api, ChatWebSocket } from "../api/client"; import { api, ChatWebSocket } from "../api/client";
import { useChatHistory } from "../hooks/useChatHistory"; import { useChatHistory } from "../hooks/useChatHistory";
import type { Message, ProviderConfig } from "../types"; import type { Message, ProviderConfig } from "../types";
@@ -17,6 +21,7 @@ import { LozengeFlyProvider } from "./LozengeFlyContext";
import { MessageItem } from "./MessageItem"; import { MessageItem } from "./MessageItem";
import type { LogEntry } from "./ServerLogsPanel"; import type { LogEntry } from "./ServerLogsPanel";
import { ServerLogsPanel } from "./ServerLogsPanel"; import { ServerLogsPanel } from "./ServerLogsPanel";
import SetupWizard from "./SetupWizard";
import { SideQuestionOverlay } from "./SideQuestionOverlay"; import { SideQuestionOverlay } from "./SideQuestionOverlay";
import { StagePanel } from "./StagePanel"; import { StagePanel } from "./StagePanel";
import { WorkItemDetailPanel } from "./WorkItemDetailPanel"; import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
@@ -217,6 +222,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
new Map(), new Map(),
); );
const [needsOnboarding, setNeedsOnboarding] = useState(false); const [needsOnboarding, setNeedsOnboarding] = useState(false);
const [wizardState, setWizardState] = useState<WizardStateData | null>(null);
const onboardingTriggeredRef = useRef(false); const onboardingTriggeredRef = useRef(false);
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>( const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
null, null,
@@ -466,6 +472,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
onOnboardingStatus: (onboarding: boolean) => { onOnboardingStatus: (onboarding: boolean) => {
setNeedsOnboarding(onboarding); setNeedsOnboarding(onboarding);
}, },
onWizardState: (state: WizardStateData) => {
setWizardState(state);
},
onSideQuestionToken: (content) => { onSideQuestionToken: (content) => {
setSideQuestion((prev) => setSideQuestion((prev) =>
prev ? { ...prev, response: prev.response + content } : prev, prev ? { ...prev, response: prev.response + content } : prev,
@@ -978,7 +987,20 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
gap: "24px", gap: "24px",
}} }}
> >
{needsOnboarding && messages.length === 0 && !loading && ( {wizardState &&
!wizardState.completed &&
messages.length === 0 &&
!loading && (
<SetupWizard
wizardState={wizardState}
onWizardUpdate={setWizardState}
sendMessage={sendMessage}
/>
)}
{needsOnboarding &&
!wizardState &&
messages.length === 0 &&
!loading && (
<div <div
data-testid="onboarding-welcome" data-testid="onboarding-welcome"
style={{ style={{
@@ -1005,9 +1027,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
lineHeight: 1.5, lineHeight: 1.5,
}} }}
> >
This project needs to be set up before you can start writing This project needs to be set up before you can start
stories. The agent will guide you through configuring your writing stories. The agent will guide you through
project goals and tech stack. configuring your project goals and tech stack.
</p> </p>
<button <button
type="button" type="button"
+354
View File
@@ -0,0 +1,354 @@
import { useCallback, useState } from "react";
import type { WizardStateData, WizardStepInfo } from "../api/client";
const API_BASE = "/api";
interface SetupWizardProps {
wizardState: WizardStateData;
onWizardUpdate: (state: WizardStateData) => void;
sendMessage: (message: string) => void;
}
/** Style constants for the wizard UI. */
const STEP_BG_PENDING = "#1a1f2e";
const STEP_BG_ACTIVE = "#1c2a1c";
const STEP_BG_DONE = "#1a2a1a";
const STEP_BORDER_PENDING = "#2a2f3e";
const STEP_BORDER_ACTIVE = "#2d4a2d";
const STEP_BORDER_DONE = "#2d4a2d";
const COLOR_LABEL = "#ccc";
const COLOR_LABEL_DONE = "#a0d4a0";
const COLOR_ACCENT = "#a0d4a0";
function statusIcon(status: string): string {
switch (status) {
case "confirmed":
return "\u2713";
case "skipped":
return "\u2013";
case "generating":
return "\u2026";
case "awaiting_confirmation":
return "?";
default:
return "\u00B7";
}
}
function stepBackground(status: string, isActive: boolean): string {
if (status === "confirmed" || status === "skipped") return STEP_BG_DONE;
if (isActive) return STEP_BG_ACTIVE;
return STEP_BG_PENDING;
}
function stepBorder(status: string, isActive: boolean): string {
if (status === "confirmed" || status === "skipped") return STEP_BORDER_DONE;
if (isActive) return STEP_BORDER_ACTIVE;
return STEP_BORDER_PENDING;
}
/** Messages sent to the chat to trigger agent generation for each step. */
const STEP_PROMPTS: Record<string, string> = {
context:
"Read the codebase and generate .storkit/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard API to store the content: PUT /api/wizard/step/context/content",
stack:
"Read the tech stack and generate .storkit/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard API to store the content: PUT /api/wizard/step/stack/content",
test_script:
"Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard API: PUT /api/wizard/step/test_script/content",
release_script:
"Read the project's deployment setup and create script/release tailored to the project. Then call the wizard API: PUT /api/wizard/step/release_script/content",
test_coverage:
"If the stack supports coverage reporting, create script/test_coverage. Then call the wizard API: PUT /api/wizard/step/test_coverage/content",
};
async function apiPost(path: string): Promise<WizardStateData | null> {
try {
const resp = await fetch(`${API_BASE}${path}`, { method: "POST" });
if (!resp.ok) return null;
return (await resp.json()) as WizardStateData;
} catch {
return null;
}
}
function StepCard({
step,
isActive,
onGenerate,
onConfirm,
onSkip,
}: {
step: WizardStepInfo;
isActive: boolean;
onGenerate: () => void;
onConfirm: () => void;
onSkip: () => void;
}) {
const isDone = step.status === "confirmed" || step.status === "skipped";
return (
<div
data-testid={`wizard-step-${step.step}`}
style={{
padding: "16px",
borderRadius: "8px",
background: stepBackground(step.status, isActive),
border: `1px solid ${stepBorder(step.status, isActive)}`,
opacity: !isActive && !isDone ? 0.5 : 1,
transition: "all 0.2s ease",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
}}
>
<span
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: 600,
background: isDone ? COLOR_ACCENT : "transparent",
border: isDone ? "none" : `1px solid ${COLOR_LABEL}`,
color: isDone ? "#1a1a1a" : COLOR_LABEL,
}}
>
{statusIcon(step.status)}
</span>
<span
style={{
flex: 1,
color: isDone ? COLOR_LABEL_DONE : COLOR_LABEL,
fontWeight: isActive ? 600 : 400,
}}
>
{step.label}
</span>
{isActive && step.status === "pending" && (
<button
type="button"
data-testid={`wizard-generate-${step.step}`}
onClick={onGenerate}
style={{
padding: "6px 14px",
borderRadius: "6px",
border: "none",
backgroundColor: COLOR_ACCENT,
color: "#1a1a1a",
cursor: "pointer",
fontSize: "0.85rem",
fontWeight: 600,
}}
>
Generate
</button>
)}
{isActive && step.status === "generating" && (
<span style={{ color: "#aaa", fontSize: "0.85rem" }}>
Generating...
</span>
)}
</div>
{step.content && step.status === "awaiting_confirmation" && (
<div style={{ marginTop: "12px" }}>
<pre
data-testid={`wizard-preview-${step.step}`}
style={{
background: "#111",
padding: "12px",
borderRadius: "6px",
fontSize: "0.8rem",
color: "#ddd",
whiteSpace: "pre-wrap",
maxHeight: "200px",
overflow: "auto",
margin: "0 0 12px 0",
}}
>
{step.content}
</pre>
<div style={{ display: "flex", gap: "8px" }}>
<button
type="button"
data-testid={`wizard-confirm-${step.step}`}
onClick={onConfirm}
style={{
padding: "6px 14px",
borderRadius: "6px",
border: "none",
backgroundColor: COLOR_ACCENT,
color: "#1a1a1a",
cursor: "pointer",
fontSize: "0.85rem",
fontWeight: 600,
}}
>
Confirm
</button>
<button
type="button"
data-testid={`wizard-revise-${step.step}`}
onClick={onGenerate}
style={{
padding: "6px 14px",
borderRadius: "6px",
border: "1px solid #555",
backgroundColor: "transparent",
color: "#ccc",
cursor: "pointer",
fontSize: "0.85rem",
}}
>
Revise
</button>
<button
type="button"
data-testid={`wizard-skip-${step.step}`}
onClick={onSkip}
style={{
padding: "6px 14px",
borderRadius: "6px",
border: "1px solid #555",
backgroundColor: "transparent",
color: "#888",
cursor: "pointer",
fontSize: "0.85rem",
}}
>
Skip
</button>
</div>
</div>
)}
{isActive && step.status === "pending" && !step.content && (
<div style={{ marginTop: "8px", display: "flex", gap: "8px" }}>
<button
type="button"
data-testid={`wizard-skip-${step.step}`}
onClick={onSkip}
style={{
padding: "4px 10px",
borderRadius: "6px",
border: "1px solid #444",
backgroundColor: "transparent",
color: "#888",
cursor: "pointer",
fontSize: "0.8rem",
}}
>
Skip this step
</button>
</div>
)}
</div>
);
}
export default function SetupWizard({
wizardState,
onWizardUpdate,
sendMessage,
}: SetupWizardProps) {
const [, setRefreshKey] = useState(0);
const handleGenerate = useCallback(
(step: WizardStepInfo) => {
const prompt = STEP_PROMPTS[step.step];
if (prompt) {
sendMessage(prompt);
}
},
[sendMessage],
);
const handleConfirm = useCallback(
async (step: WizardStepInfo) => {
const result = await apiPost(`/wizard/step/${step.step}/confirm`);
if (result) {
onWizardUpdate(result);
setRefreshKey((k) => k + 1);
}
},
[onWizardUpdate],
);
const handleSkip = useCallback(
async (step: WizardStepInfo) => {
const result = await apiPost(`/wizard/step/${step.step}/skip`);
if (result) {
onWizardUpdate(result);
setRefreshKey((k) => k + 1);
}
},
[onWizardUpdate],
);
if (wizardState.completed) {
return (
<div
data-testid="wizard-complete"
style={{
padding: "24px",
borderRadius: "12px",
background: STEP_BG_DONE,
border: `1px solid ${STEP_BORDER_DONE}`,
textAlign: "center",
}}
>
<h3 style={{ margin: "0 0 8px 0", color: COLOR_ACCENT }}>
Setup Complete
</h3>
<p style={{ margin: 0, color: COLOR_LABEL }}>
Your project is configured. You can start writing stories.
</p>
</div>
);
}
return (
<div
data-testid="setup-wizard"
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<div style={{ marginBottom: "8px" }}>
<h3
style={{
margin: "0 0 4px 0",
color: COLOR_ACCENT,
fontSize: "1.1rem",
}}
>
Project Setup Wizard
</h3>
<p style={{ margin: 0, color: "#999", fontSize: "0.85rem" }}>
Step {wizardState.current_step_index + 1} of{" "}
{wizardState.steps.length}
</p>
</div>
{wizardState.steps.map((step, idx) => (
<StepCard
key={step.step}
step={step}
isActive={idx === wizardState.current_step_index}
onGenerate={() => handleGenerate(step)}
onConfirm={() => handleConfirm(step)}
onSkip={() => handleSkip(step)}
/>
))}
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "storkit" name = "storkit"
version = "0.7.1" version = "0.8.0"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
+5 -1
View File
@@ -14,6 +14,7 @@ pub mod settings;
pub mod workflow; pub mod workflow;
pub mod project; pub mod project;
pub mod wizard;
pub mod ws; pub mod ws;
use agents::AgentsApi; use agents::AgentsApi;
@@ -131,6 +132,7 @@ type ApiTuple = (
SettingsApi, SettingsApi,
HealthApi, HealthApi,
BotCommandApi, BotCommandApi,
wizard::WizardApi,
); );
type ApiService = OpenApiService<ApiTuple, ()>; type ApiService = OpenApiService<ApiTuple, ()>;
@@ -147,6 +149,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
SettingsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() },
HealthApi, HealthApi,
BotCommandApi { ctx: ctx.clone() }, BotCommandApi { ctx: ctx.clone() },
wizard::WizardApi { ctx: ctx.clone() },
); );
let api_service = let api_service =
@@ -161,7 +164,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AgentsApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() },
HealthApi, HealthApi,
BotCommandApi { ctx }, BotCommandApi { ctx: ctx.clone() },
wizard::WizardApi { ctx },
); );
let docs_service = let docs_service =
+303
View File
@@ -0,0 +1,303 @@
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
use crate::io::wizard::{StepStatus, WizardState, WizardStep};
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Tags)]
enum WizardTags {
Wizard,
}
/// Response for a single wizard step.
#[derive(Serialize, Object)]
struct StepResponse {
step: String,
label: String,
status: String,
#[oai(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
/// Full wizard state response.
#[derive(Serialize, Object)]
struct WizardResponse {
steps: Vec<StepResponse>,
current_step_index: usize,
completed: bool,
}
/// Request body for confirming/skipping a step or submitting content.
#[derive(Deserialize, Object)]
struct StepActionPayload {
/// Optional content to store for the step (e.g., generated spec).
#[oai(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
impl From<&WizardState> for WizardResponse {
fn from(state: &WizardState) -> Self {
WizardResponse {
steps: state
.steps
.iter()
.map(|s| StepResponse {
step: serde_json::to_value(s.step)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
label: s.step.label().to_string(),
status: serde_json::to_value(&s.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
content: s.content.clone(),
})
.collect(),
current_step_index: state.current_step_index(),
completed: state.completed,
}
}
}
fn parse_step(step_str: &str) -> Result<WizardStep, poem::Error> {
let quoted = format!("\"{step_str}\"");
serde_json::from_str::<WizardStep>(&quoted)
.map_err(|_| not_found(format!("Unknown wizard step: {step_str}")))
}
pub struct WizardApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "WizardTags::Wizard")]
impl WizardApi {
/// Get the current wizard state.
///
/// Returns the full setup wizard progress including all steps and their
/// statuses. Returns 404 if no wizard is active.
#[oai(path = "/wizard", method = "get")]
async fn get_wizard_state(&self) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
Ok(Json(WizardResponse::from(&state)))
}
/// Set a step's content and mark it as awaiting confirmation.
///
/// Used after the agent generates content for a step. The content is
/// stored for preview and the step is marked as awaiting user confirmation.
#[oai(path = "/wizard/step/:step/content", method = "put")]
async fn set_step_content(
&self,
step: Path<String>,
payload: Json<StepActionPayload>,
) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.set_step_status(
wizard_step,
StepStatus::AwaitingConfirmation,
payload.0.content,
);
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Confirm a step and advance to the next.
///
/// The step must be the current active step. Returns the updated wizard state.
#[oai(path = "/wizard/step/:step/confirm", method = "post")]
async fn confirm_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.confirm_step(wizard_step).map_err(bad_request)?;
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Skip a step and advance to the next.
///
/// The step must be the current active step.
#[oai(path = "/wizard/step/:step/skip", method = "post")]
async fn skip_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.skip_step(wizard_step).map_err(bad_request)?;
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Mark a step as generating (agent is working on it).
#[oai(path = "/wizard/step/:step/generating", method = "post")]
async fn mark_generating(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.set_step_status(wizard_step, StepStatus::Generating, None);
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use poem::http::StatusCode;
use poem::test::TestClient;
use poem_openapi::OpenApiService;
use tempfile::TempDir;
fn setup() -> (TempDir, TestClient<impl poem::Endpoint>) {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join(".storkit")).unwrap();
let ctx = Arc::new(AppContext::new_test(root.clone()));
let api = WizardApi { ctx };
let service = OpenApiService::new(api, "test", "0.1.0");
let client = TestClient::new(service);
(dir, client)
}
#[tokio::test]
async fn get_wizard_returns_404_when_no_wizard() {
let (_dir, client) = setup();
let resp = client.get("/wizard").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_wizard_returns_state_when_active() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.get("/wizard").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["current_step_index"], 1);
assert!(!body["completed"].as_bool().unwrap());
assert_eq!(body["steps"].as_array().unwrap().len(), 6);
assert_eq!(body["steps"][0]["status"], "confirmed");
}
#[tokio::test]
async fn confirm_step_advances_wizard() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.post("/wizard/step/context/confirm").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["current_step_index"], 2);
assert_eq!(body["steps"][1]["status"], "confirmed");
}
#[tokio::test]
async fn confirm_wrong_step_returns_error() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
// Try to confirm step 3 (stack) when current is step 2 (context)
let resp = client.post("/wizard/step/stack/confirm").send().await;
resp.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn skip_step_advances_wizard() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.post("/wizard/step/context/skip").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "skipped");
assert_eq!(body["current_step_index"], 2);
}
#[tokio::test]
async fn set_step_content_marks_awaiting_confirmation() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.put("/wizard/step/context/content")
.body_json(&serde_json::json!({
"content": "# My Project\n\nA great project."
}))
.send()
.await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "awaiting_confirmation");
assert_eq!(
body["steps"][1]["content"],
"# My Project\n\nA great project."
);
}
#[tokio::test]
async fn mark_generating_updates_step() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.post("/wizard/step/context/generating")
.send()
.await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "generating");
}
#[tokio::test]
async fn unknown_step_returns_404() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.post("/wizard/step/nonexistent/confirm")
.send()
.await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn full_wizard_flow_completes() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
// Steps 2-6 (scaffold is already confirmed)
let steps = ["context", "stack", "test_script", "release_script", "test_coverage"];
for step in steps {
let resp = client
.post(format!("/wizard/step/{step}/confirm"))
.send()
.await;
resp.assert_status_is_ok();
}
// Check final state
let resp = client.get("/wizard").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert!(body["completed"].as_bool().unwrap());
}
}
+47
View File
@@ -2,6 +2,7 @@ use crate::http::context::{AppContext, PermissionDecision};
use crate::http::workflow::{PipelineState, load_pipeline_state}; use crate::http::workflow::{PipelineState, load_pipeline_state};
use crate::io::onboarding; use crate::io::onboarding;
use crate::io::watcher::WatcherEvent; use crate::io::watcher::WatcherEvent;
use crate::io::wizard;
use crate::llm::chat; use crate::llm::chat;
use crate::llm::types::Message; use crate::llm::types::Message;
use crate::log_buffer; use crate::log_buffer;
@@ -46,6 +47,16 @@ enum WsRequest {
}, },
} }
/// Serialisable summary of a single wizard step for WebSocket broadcast.
#[derive(Serialize, Clone)]
pub struct WizardStepInfo {
pub step: String,
pub label: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
/// WebSocket response messages sent by the server. /// WebSocket response messages sent by the server.
@@ -125,6 +136,13 @@ enum WsResponse {
OnboardingStatus { OnboardingStatus {
needs_onboarding: bool, needs_onboarding: bool,
}, },
/// Sent on connect when a setup wizard is active. Contains the full
/// wizard state so the frontend can render the step-by-step UI.
WizardState {
steps: Vec<WizardStepInfo>,
current_step_index: usize,
completed: bool,
},
/// Streaming token from a `/btw` side question response. /// Streaming token from a `/btw` side question response.
SideQuestionToken { SideQuestionToken {
content: String, content: String,
@@ -219,6 +237,35 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
}); });
} }
// Push wizard state if an active wizard exists.
{
if let Ok(root) = ctx.state.get_project_root()
&& let Some(ws) = wizard::WizardState::load(&root)
{
let steps: Vec<WizardStepInfo> = ws
.steps
.iter()
.map(|s| WizardStepInfo {
step: serde_json::to_value(s.step)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
label: s.step.label().to_string(),
status: serde_json::to_value(&s.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
content: s.content.clone(),
})
.collect();
let _ = tx.send(WsResponse::WizardState {
steps,
current_step_index: ws.current_step_index(),
completed: ws.completed,
});
}
}
// Push recent server log entries so the client has history on connect. // Push recent server log entries so the client has history on connect.
{ {
let entries = log_buffer::global().get_recent_entries(100, None, None); let entries = log_buffer::global().get_recent_entries(100, None, None);
+1
View File
@@ -289,6 +289,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
"work/4_merge/", "work/4_merge/",
"logs/", "logs/",
"token_usage.jsonl", "token_usage.jsonl",
"wizard_state.json",
]; ];
let gitignore_path = root.join(".storkit").join(".gitignore"); let gitignore_path = root.join(".storkit").join(".gitignore");
+1
View File
@@ -4,3 +4,4 @@ pub mod search;
pub mod shell; pub mod shell;
pub mod story_metadata; pub mod story_metadata;
pub mod watcher; pub mod watcher;
pub mod wizard;
+351
View File
@@ -0,0 +1,351 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// Ordered wizard steps for project setup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WizardStep {
/// Step 1: scaffold .storkit/ directory structure and project.toml
Scaffold,
/// Step 2: generate specs/00_CONTEXT.md
Context,
/// Step 3: generate specs/tech/STACK.md
Stack,
/// Step 4: create script/test
TestScript,
/// Step 5: create script/release
ReleaseScript,
/// Step 6: create script/test_coverage
TestCoverage,
}
impl WizardStep {
/// All steps in order.
pub const ALL: &[WizardStep] = &[
WizardStep::Scaffold,
WizardStep::Context,
WizardStep::Stack,
WizardStep::TestScript,
WizardStep::ReleaseScript,
WizardStep::TestCoverage,
];
/// Human-readable label for this step.
pub fn label(&self) -> &'static str {
match self {
WizardStep::Scaffold => "Scaffold directory structure",
WizardStep::Context => "Generate project context (00_CONTEXT.md)",
WizardStep::Stack => "Generate tech stack spec (STACK.md)",
WizardStep::TestScript => "Create test script (script/test)",
WizardStep::ReleaseScript => "Create release script (script/release)",
WizardStep::TestCoverage => "Create test coverage script (script/test_coverage)",
}
}
/// Zero-based index of this step.
pub fn index(&self) -> usize {
Self::ALL.iter().position(|s| s == self).unwrap_or(0)
}
}
/// Status of an individual wizard step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
/// Not yet started.
Pending,
/// Agent is generating content for this step.
Generating,
/// Content generated, awaiting user confirmation.
AwaitingConfirmation,
/// User confirmed this step.
Confirmed,
/// User skipped this step.
Skipped,
}
/// State of a single wizard step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepState {
pub step: WizardStep,
pub status: StepStatus,
/// The generated content (if any) for preview.
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
/// Persistent wizard state, stored in `.storkit/wizard_state.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WizardState {
pub steps: Vec<StepState>,
/// True when all steps are confirmed or skipped.
pub completed: bool,
}
impl Default for WizardState {
fn default() -> Self {
Self {
steps: WizardStep::ALL
.iter()
.map(|&step| StepState {
step,
status: StepStatus::Pending,
content: None,
})
.collect(),
completed: false,
}
}
}
impl WizardState {
/// Path to the wizard state file relative to the project root.
fn state_path(project_root: &Path) -> std::path::PathBuf {
project_root.join(".storkit").join("wizard_state.json")
}
/// Load wizard state from disk, or return None if it doesn't exist.
pub fn load(project_root: &Path) -> Option<Self> {
let path = Self::state_path(project_root);
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
/// Save wizard state to disk.
pub fn save(&self, project_root: &Path) -> Result<(), String> {
let path = Self::state_path(project_root);
let content =
serde_json::to_string_pretty(self).map_err(|e| format!("Serialize error: {e}"))?;
fs::write(&path, content).map_err(|e| format!("Failed to write wizard state: {e}"))
}
/// Create wizard state file if it doesn't already exist.
/// Step 1 (Scaffold) is automatically confirmed since `storkit init`
/// has already run the scaffold.
pub fn init_if_missing(project_root: &Path) {
if Self::load(project_root).is_some() {
return;
}
let mut state = Self::default();
// Scaffold step is done by the time the server starts.
state.steps[0].status = StepStatus::Confirmed;
let _ = state.save(project_root);
}
/// Get the current step index (0-based).
pub fn current_step_index(&self) -> usize {
self.steps
.iter()
.position(|s| !matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped))
.unwrap_or(self.steps.len())
}
/// Mark a step's status and update completion state.
pub fn set_step_status(
&mut self,
step: WizardStep,
status: StepStatus,
content: Option<String>,
) {
if let Some(s) = self.steps.iter_mut().find(|s| s.step == step) {
s.status = status;
if content.is_some() {
s.content = content;
}
}
self.completed = self
.steps
.iter()
.all(|s| matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped));
}
/// Confirm a step. Returns error if the step is not the current one
/// (enforces sequential progression).
pub fn confirm_step(&mut self, step: WizardStep) -> Result<(), String> {
let current_idx = self.current_step_index();
let target_idx = step.index();
if target_idx != current_idx {
return Err(format!(
"Cannot confirm step {:?}: current step is {}",
step, current_idx
));
}
self.set_step_status(step, StepStatus::Confirmed, None);
Ok(())
}
/// Skip a step. Only the current step can be skipped.
pub fn skip_step(&mut self, step: WizardStep) -> Result<(), String> {
let current_idx = self.current_step_index();
let target_idx = step.index();
if target_idx != current_idx {
return Err(format!(
"Cannot skip step {:?}: current step is {}",
step, current_idx
));
}
self.set_step_status(step, StepStatus::Skipped, None);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_project(dir: &TempDir) -> std::path::PathBuf {
let root = dir.path().to_path_buf();
let sk = root.join(".storkit");
std::fs::create_dir_all(&sk).unwrap();
root
}
#[test]
fn default_state_has_all_steps_pending() {
let state = WizardState::default();
assert_eq!(state.steps.len(), 6);
for step in &state.steps {
assert_eq!(step.status, StepStatus::Pending);
}
assert!(!state.completed);
}
#[test]
fn init_if_missing_creates_state_with_scaffold_confirmed() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
WizardState::init_if_missing(&root);
let state = WizardState::load(&root).unwrap();
assert_eq!(state.steps[0].status, StepStatus::Confirmed);
assert_eq!(state.steps[0].step, WizardStep::Scaffold);
// Rest should be pending
for step in &state.steps[1..] {
assert_eq!(step.status, StepStatus::Pending);
}
}
#[test]
fn init_if_missing_does_not_overwrite_existing() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
// Create a custom state
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
state.steps[1].status = StepStatus::Confirmed;
state.save(&root).unwrap();
// init_if_missing should not overwrite
WizardState::init_if_missing(&root);
let loaded = WizardState::load(&root).unwrap();
assert_eq!(loaded.steps[1].status, StepStatus::Confirmed);
}
#[test]
fn save_and_load_round_trip() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
state.steps[1].status = StepStatus::AwaitingConfirmation;
state.steps[1].content = Some("# My Project\n\nA cool project.".to_string());
state.save(&root).unwrap();
let loaded = WizardState::load(&root).unwrap();
assert_eq!(loaded.steps[0].status, StepStatus::Confirmed);
assert_eq!(loaded.steps[1].status, StepStatus::AwaitingConfirmation);
assert_eq!(
loaded.steps[1].content.as_deref(),
Some("# My Project\n\nA cool project.")
);
}
#[test]
fn current_step_index_correct() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
assert_eq!(state.current_step_index(), 1);
state.steps[1].status = StepStatus::Skipped;
assert_eq!(state.current_step_index(), 2);
}
#[test]
fn confirm_step_enforces_order() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
// Can confirm the current step (Context, index 1)
assert!(state.confirm_step(WizardStep::Context).is_ok());
// Cannot confirm a step that's not current
assert!(state.confirm_step(WizardStep::TestScript).is_err());
}
#[test]
fn skip_step_works() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
assert!(state.skip_step(WizardStep::Context).is_ok());
assert_eq!(state.steps[1].status, StepStatus::Skipped);
assert_eq!(state.current_step_index(), 2);
}
#[test]
fn completed_when_all_confirmed_or_skipped() {
let mut state = WizardState::default();
for step in WizardStep::ALL {
state.set_step_status(*step, StepStatus::Confirmed, None);
}
assert!(state.completed);
}
#[test]
fn not_completed_when_some_pending() {
let mut state = WizardState::default();
state.set_step_status(WizardStep::Scaffold, StepStatus::Confirmed, None);
assert!(!state.completed);
}
#[test]
fn set_step_status_with_content() {
let mut state = WizardState::default();
state.set_step_status(
WizardStep::Context,
StepStatus::AwaitingConfirmation,
Some("generated content".to_string()),
);
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
assert_eq!(
state.steps[1].content.as_deref(),
Some("generated content")
);
}
#[test]
fn load_returns_none_when_no_file() {
let dir = TempDir::new().unwrap();
assert!(WizardState::load(dir.path()).is_none());
}
#[test]
fn step_labels_are_non_empty() {
for step in WizardStep::ALL {
assert!(!step.label().is_empty());
}
}
#[test]
fn step_indices_are_sequential() {
for (i, step) in WizardStep::ALL.iter().enumerate() {
assert_eq!(step.index(), i);
}
}
}
+49 -3
View File
@@ -39,6 +39,8 @@ enum CliDirective {
Help, Help,
/// `--version` / `-V` /// `--version` / `-V`
Version, Version,
/// `init [PATH]` — scaffold and start the setup wizard.
Init,
/// An unrecognised flag (starts with `-`). /// An unrecognised flag (starts with `-`).
UnknownFlag(String), UnknownFlag(String),
/// A positional path argument. /// A positional path argument.
@@ -53,6 +55,7 @@ fn classify_cli_args(args: &[String]) -> CliDirective {
None => CliDirective::None, None => CliDirective::None,
Some("--help" | "-h") => CliDirective::Help, Some("--help" | "-h") => CliDirective::Help,
Some("--version" | "-V") => CliDirective::Version, Some("--version" | "-V") => CliDirective::Version,
Some("init") => CliDirective::Init,
Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()), Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()),
Some(_) => CliDirective::Path, Some(_) => CliDirective::Path,
} }
@@ -79,14 +82,22 @@ async fn main() -> Result<(), std::io::Error> {
let cli_args: Vec<String> = std::env::args().skip(1).collect(); let cli_args: Vec<String> = std::env::args().skip(1).collect();
// Handle CLI flags before treating anything as a project path. // Handle CLI flags before treating anything as a project path.
let is_init = matches!(classify_cli_args(&cli_args), CliDirective::Init);
match classify_cli_args(&cli_args) { match classify_cli_args(&cli_args) {
CliDirective::Help => { CliDirective::Help => {
println!("storkit [PATH]"); println!("storkit [PATH]");
println!("storkit init [PATH]");
println!(); println!();
println!("Serve a storkit project."); println!("Serve a storkit project.");
println!(); println!();
println!("USAGE:"); println!("USAGE:");
println!(" storkit [PATH]"); println!(" storkit [PATH]");
println!(" storkit init [PATH]");
println!();
println!("COMMANDS:");
println!(
" init Scaffold a new .storkit/ project and start the interactive setup wizard."
);
println!(); println!();
println!("ARGS:"); println!("ARGS:");
println!( println!(
@@ -108,10 +119,15 @@ async fn main() -> Result<(), std::io::Error> {
eprintln!("Run 'storkit --help' for usage."); eprintln!("Run 'storkit --help' for usage.");
std::process::exit(1); std::process::exit(1);
} }
CliDirective::Path | CliDirective::None => {} CliDirective::Init | CliDirective::Path | CliDirective::None => {}
} }
let explicit_path = parse_project_path_arg(&cli_args, &cwd); // For `storkit init [PATH]`, the path argument follows "init".
let explicit_path = if is_init {
parse_project_path_arg(&cli_args[1..], &cwd)
} else {
parse_project_path_arg(&cli_args, &cwd)
};
// When a path is given explicitly on the CLI, it must already exist as a // When a path is given explicitly on the CLI, it must already exist as a
// directory. We do not create directories from the command line. // directory. We do not create directories from the command line.
@@ -126,7 +142,37 @@ async fn main() -> Result<(), std::io::Error> {
} }
} }
if let Some(explicit_root) = explicit_path { if is_init {
// `storkit init [PATH]` — always scaffold, never search parents.
let init_root = explicit_path.unwrap_or_else(|| cwd.clone());
if !init_root.exists() {
std::fs::create_dir_all(&init_root).unwrap_or_else(|e| {
eprintln!("error: cannot create directory {}: {e}", init_root.display());
std::process::exit(1);
});
}
match io::fs::open_project(
init_root.to_string_lossy().to_string(),
&app_state,
store.as_ref(),
port,
)
.await
{
Ok(_) => {
if let Some(root) = app_state.project_root.lock().unwrap().as_ref() {
config::ProjectConfig::load(root)
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
// Initialize wizard state for the setup flow.
io::wizard::WizardState::init_if_missing(root);
}
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else if let Some(explicit_root) = explicit_path {
// An explicit path was given on the command line. // An explicit path was given on the command line.
// Open it directly — scaffold .storkit/ if it is missing — and // Open it directly — scaffold .storkit/ if it is missing — and
// exit with a clear error message if the path is invalid. // exit with a clear error message if the path is invalid.