diff --git a/.claude/settings.json b/.claude/settings.json index 15d9bb8..36aa5f7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,12 +1,10 @@ { - "enabledMcpjsonServers": [ - "storkit" - ], + "enabledMcpjsonServers": ["storkit"], "permissions": { "allow": [ - "Bash(./server/target/debug/story-kit:*)", - "Bash(./target/debug/story-kit:*)", - "Bash(STORYKIT_PORT=*)", + "Bash(./server/target/debug/storkit:*)", + "Bash(./target/debug/storkit:*)", + "Bash(STORKIT_PORT=*)", "Bash(cargo build:*)", "Bash(cargo check:*)", "Bash(cargo clippy:*)", @@ -56,7 +54,7 @@ "WebFetch(domain:portkey.ai)", "WebFetch(domain:www.shuttle.dev)", "WebSearch", - "mcp__story-kit__*", + "mcp__storkit__*", "Edit", "Write", "Bash(find *)", @@ -72,4 +70,4 @@ "Bash(npm run dev:*)" ] } -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index 76b33e7..f9662e4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ # Local environment (secrets) .env -# App specific (root-level; story-kit subdirectory patterns live in .storkit/.gitignore) +# App specific (root-level; storkit subdirectory patterns live in .storkit/.gitignore) store.json .storkit_port diff --git a/.storkit/README.md b/.storkit/README.md index a71f540..ccb3bb2 100644 --- a/.storkit/README.md +++ b/.storkit/README.md @@ -11,7 +11,7 @@ When you start a new session with this project: 1. **Check for MCP Tools:** Read `.mcp.json` to discover the MCP server endpoint. Then list available tools by calling: ```bash - curl -s "$(jq -r '.mcpServers["story-kit"].url' .mcp.json)" \ + curl -s "$(jq -r '.mcpServers["storkit"].url' .mcp.json)" \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` diff --git a/.storkit/project.toml b/.storkit/project.toml index a8c7418..cccfe09 100644 --- a/.storkit/project.toml +++ b/.storkit/project.toml @@ -88,7 +88,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. - URL to visit in the browser - Things to check in the UI - curl commands to exercise relevant API endpoints -- Kill the test server when done: `pkill -f 'target.*story-kit' || true` (NEVER use `pkill -f story-kit` — it kills the vite dev server) +- Kill the test server when done: `pkill -f 'target.*storkit' || true` (NEVER use `pkill -f storkit` — it kills the vite dev server) ### 4. Produce Structured Report Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format: @@ -165,7 +165,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. - URL to visit in the browser - Things to check in the UI - curl commands to exercise relevant API endpoints -- Kill the test server when done: `pkill -f 'target.*story-kit' || true` (NEVER use `pkill -f story-kit` — it kills the vite dev server) +- Kill the test server when done: `pkill -f 'target.*storkit' || true` (NEVER use `pkill -f storkit` — it kills the vite dev server) ### 4. Produce Structured Report Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format: diff --git a/.storkit/specs/tech/STACK.md b/.storkit/specs/tech/STACK.md index 466994b..5ec7ede 100644 --- a/.storkit/specs/tech/STACK.md +++ b/.storkit/specs/tech/STACK.md @@ -118,8 +118,8 @@ To support both Remote and Local models, the system implements a `ModelProvider` Multiple instances can run simultaneously in different worktrees. To avoid port conflicts: -- **Backend:** Set `STORYKIT_PORT` to a unique port (default is 3001). Example: `STORYKIT_PORT=3002 cargo run` -- **Frontend:** Run `npm run dev` from `frontend/`. It auto-selects the next unused port. It reads `STORYKIT_PORT` to know which backend to talk to, so export it before running: `export STORYKIT_PORT=3002 && cd frontend && npm run dev` +- **Backend:** Set `STORKIT_PORT` to a unique port (default is 3001). Example: `STORKIT_PORT=3002 cargo run` +- **Frontend:** Run `npm run dev` from `frontend/`. It auto-selects the next unused port. It reads `STORKIT_PORT` to know which backend to talk to, so export it before running: `export STORKIT_PORT=3002 && cd frontend && npm run dev` When running in a worktree, use a port that won't conflict with the main instance (3001). Ports 3002+ are good choices. @@ -127,4 +127,4 @@ When running in a worktree, use a port that won't conflict with the main instanc 1. **Project Scope:** The application must strictly enforce that it does not read/write outside the `project_root` selected by the user. 2. **Human in the Loop:** * Shell commands that modify state (non-readonly) should ideally require a UI confirmation (configurable). - * File writes must be confirmed or revertible. \ No newline at end of file + * File writes must be confirmed or revertible. diff --git a/README.md b/README.md index 398df29..bd65587 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ cargo run cargo build --release # Run the server (serves embedded frontend/dist/) -./target/release/story-kit +./target/release/storkit ``` ## Cross-Platform Distribution @@ -37,10 +37,10 @@ Story Kit ships as a **single self-contained binary** with the React frontend em ```bash # Native build – no extra tools required beyond Rust + npm make build-macos -# Output: target/release/story-kit +# Output: target/release/storkit # Verify only system frameworks are linked (Security.framework, libSystem.B.dylib, etc.) -otool -L target/release/story-kit +otool -L target/release/storkit ``` ### Linux (static x86_64, zero dynamic deps) @@ -60,13 +60,13 @@ cargo install cross ```bash make build-linux -# Output: target/x86_64-unknown-linux-musl/release/story-kit +# Output: target/x86_64-unknown-linux-musl/release/storkit # Verify the binary is statically linked -file target/x86_64-unknown-linux-musl/release/story-kit +file target/x86_64-unknown-linux-musl/release/storkit # Expected: ELF 64-bit LSB executable, x86-64, statically linked -ldd target/x86_64-unknown-linux-musl/release/story-kit +ldd target/x86_64-unknown-linux-musl/release/storkit # Expected: not a dynamic executable ``` @@ -74,7 +74,7 @@ ldd target/x86_64-unknown-linux-musl/release/story-kit ```bash # No Rust, Node, glibc, or any other library needed – just copy and run -./story-kit +./storkit ``` ## Releasing diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6a3997d..1275c20 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,694 +1,694 @@ export type WsRequest = - | { - type: "chat"; - messages: Message[]; - config: ProviderConfig; - } - | { - type: "cancel"; - } - | { - type: "permission_response"; - request_id: string; - approved: boolean; - always_allow: boolean; - } - | { type: "ping" } - | { - type: "side_question"; - question: string; - context_messages: Message[]; - config: ProviderConfig; - }; + | { + type: "chat"; + messages: Message[]; + config: ProviderConfig; + } + | { + type: "cancel"; + } + | { + type: "permission_response"; + request_id: string; + approved: boolean; + always_allow: boolean; + } + | { type: "ping" } + | { + type: "side_question"; + question: string; + context_messages: Message[]; + config: ProviderConfig; + }; export interface AgentAssignment { - agent_name: string; - model: string | null; - status: string; + agent_name: string; + model: string | null; + status: string; } export interface PipelineStageItem { - story_id: string; - name: string | null; - error: string | null; - merge_failure: string | null; - agent: AgentAssignment | null; - review_hold: boolean | null; - qa: string | null; + story_id: string; + name: string | null; + error: string | null; + merge_failure: string | null; + agent: AgentAssignment | null; + review_hold: boolean | null; + qa: string | null; } export interface PipelineState { - backlog: PipelineStageItem[]; - current: PipelineStageItem[]; - qa: PipelineStageItem[]; - merge: PipelineStageItem[]; - done: PipelineStageItem[]; + backlog: PipelineStageItem[]; + current: PipelineStageItem[]; + qa: PipelineStageItem[]; + merge: PipelineStageItem[]; + done: PipelineStageItem[]; } export type WsResponse = - | { type: "token"; content: string } - | { type: "update"; messages: Message[] } - | { type: "session_id"; session_id: string } - | { type: "error"; message: string } - | { - type: "pipeline_state"; - backlog: PipelineStageItem[]; - current: PipelineStageItem[]; - qa: PipelineStageItem[]; - merge: PipelineStageItem[]; - done: PipelineStageItem[]; - } - | { - type: "permission_request"; - request_id: string; - tool_name: string; - tool_input: Record; - } - | { type: "tool_activity"; tool_name: string } - | { - type: "reconciliation_progress"; - story_id: string; - status: string; - message: string; - } - /** `.story_kit/project.toml` was modified; re-fetch the agent roster. */ - | { type: "agent_config_changed" } - /** An agent started, stopped, or changed state; re-fetch agent list. */ - | { type: "agent_state_changed" } - | { type: "tool_activity"; tool_name: string } - /** Heartbeat response confirming the connection is alive. */ - | { type: "pong" } - /** Sent on connect when the project still needs onboarding (specs are placeholders). */ - | { type: "onboarding_status"; needs_onboarding: boolean } - /** Streaming thinking token from an extended-thinking block, separate from regular text. */ - | { type: "thinking_token"; content: string } - /** Streaming token from a /btw side question response. */ - | { type: "side_question_token"; content: string } - /** Final signal that the /btw side question has been fully answered. */ - | { type: "side_question_done"; response: string } - /** A single server log entry (bulk on connect, then live). */ - | { type: "log_entry"; timestamp: string; level: string; message: string }; + | { type: "token"; content: string } + | { type: "update"; messages: Message[] } + | { type: "session_id"; session_id: string } + | { type: "error"; message: string } + | { + type: "pipeline_state"; + backlog: PipelineStageItem[]; + current: PipelineStageItem[]; + qa: PipelineStageItem[]; + merge: PipelineStageItem[]; + done: PipelineStageItem[]; + } + | { + type: "permission_request"; + request_id: string; + tool_name: string; + tool_input: Record; + } + | { type: "tool_activity"; tool_name: string } + | { + type: "reconciliation_progress"; + story_id: string; + status: string; + message: string; + } + /** `.story_kit/project.toml` was modified; re-fetch the agent roster. */ + | { type: "agent_config_changed" } + /** An agent started, stopped, or changed state; re-fetch agent list. */ + | { type: "agent_state_changed" } + | { type: "tool_activity"; tool_name: string } + /** Heartbeat response confirming the connection is alive. */ + | { type: "pong" } + /** Sent on connect when the project still needs onboarding (specs are placeholders). */ + | { type: "onboarding_status"; needs_onboarding: boolean } + /** Streaming thinking token from an extended-thinking block, separate from regular text. */ + | { type: "thinking_token"; content: string } + /** Streaming token from a /btw side question response. */ + | { type: "side_question_token"; content: string } + /** Final signal that the /btw side question has been fully answered. */ + | { type: "side_question_done"; response: string } + /** A single server log entry (bulk on connect, then live). */ + | { type: "log_entry"; timestamp: string; level: string; message: string }; export interface ProviderConfig { - provider: string; - model: string; - base_url?: string; - enable_tools?: boolean; - session_id?: string; + provider: string; + model: string; + base_url?: string; + enable_tools?: boolean; + session_id?: string; } export type Role = "system" | "user" | "assistant" | "tool"; export interface ToolCall { - id?: string; - type: string; - function: { - name: string; - arguments: string; - }; + id?: string; + type: string; + function: { + name: string; + arguments: string; + }; } export interface Message { - role: Role; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; + role: Role; + content: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; } export interface WorkItemContent { - content: string; - stage: string; - name: string | null; - agent: string | null; + content: string; + stage: string; + name: string | null; + agent: string | null; } export interface TestCaseResult { - name: string; - status: "pass" | "fail"; - details: string | null; + name: string; + status: "pass" | "fail"; + details: string | null; } export interface TestResultsResponse { - unit: TestCaseResult[]; - integration: TestCaseResult[]; + unit: TestCaseResult[]; + integration: TestCaseResult[]; } export interface FileEntry { - name: string; - kind: "file" | "dir"; + name: string; + kind: "file" | "dir"; } export interface SearchResult { - path: string; - matches: number; + path: string; + matches: number; } export interface AgentCostEntry { - agent_name: string; - model: string | null; - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens: number; - cache_read_input_tokens: number; - total_cost_usd: number; + agent_name: string; + model: string | null; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + total_cost_usd: number; } export interface TokenCostResponse { - total_cost_usd: number; - agents: AgentCostEntry[]; + total_cost_usd: number; + agents: AgentCostEntry[]; } export interface TokenUsageRecord { - story_id: string; - agent_name: string; - model: string | null; - timestamp: string; - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens: number; - cache_read_input_tokens: number; - total_cost_usd: number; + story_id: string; + agent_name: string; + model: string | null; + timestamp: string; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + total_cost_usd: number; } export interface AllTokenUsageResponse { - records: TokenUsageRecord[]; + records: TokenUsageRecord[]; } export interface CommandOutput { - stdout: string; - stderr: string; - exit_code: number; + stdout: string; + stderr: string; + exit_code: number; } -declare const __STORYKIT_PORT__: string; +declare const __STORKIT_PORT__: string; const DEFAULT_API_BASE = "/api"; const DEFAULT_WS_PATH = "/ws"; export function resolveWsHost( - isDev: boolean, - envPort: string | undefined, - locationHost: string, + isDev: boolean, + envPort: string | undefined, + locationHost: string, ): string { - return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; + return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; } function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { - return `${baseUrl}${path}`; + return `${baseUrl}${path}`; } async function requestJson( - path: string, - options: RequestInit = {}, - baseUrl = DEFAULT_API_BASE, + path: string, + options: RequestInit = {}, + baseUrl = DEFAULT_API_BASE, ): Promise { - const res = await fetch(buildApiUrl(path, baseUrl), { - headers: { - "Content-Type": "application/json", - ...(options.headers ?? {}), - }, - ...options, - }); + const res = await fetch(buildApiUrl(path, baseUrl), { + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, + }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Request failed (${res.status})`); - } + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } - return res.json() as Promise; + return res.json() as Promise; } export const api = { - getCurrentProject(baseUrl?: string) { - return requestJson("/project", {}, baseUrl); - }, - getKnownProjects(baseUrl?: string) { - return requestJson("/projects", {}, baseUrl); - }, - forgetKnownProject(path: string, baseUrl?: string) { - return requestJson( - "/projects/forget", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - openProject(path: string, baseUrl?: string) { - return requestJson( - "/project", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - closeProject(baseUrl?: string) { - return requestJson("/project", { method: "DELETE" }, baseUrl); - }, - getModelPreference(baseUrl?: string) { - return requestJson("/model", {}, baseUrl); - }, - setModelPreference(model: string, baseUrl?: string) { - return requestJson( - "/model", - { method: "POST", body: JSON.stringify({ model }) }, - baseUrl, - ); - }, - getOllamaModels(baseUrlParam?: string, baseUrl?: string) { - const url = new URL( - buildApiUrl("/ollama/models", baseUrl), - window.location.origin, - ); - if (baseUrlParam) { - url.searchParams.set("base_url", baseUrlParam); - } - return requestJson(url.pathname + url.search, {}, ""); - }, - getAnthropicApiKeyExists(baseUrl?: string) { - return requestJson("/anthropic/key/exists", {}, baseUrl); - }, - getAnthropicModels(baseUrl?: string) { - return requestJson("/anthropic/models", {}, baseUrl); - }, - setAnthropicApiKey(api_key: string, baseUrl?: string) { - return requestJson( - "/anthropic/key", - { method: "POST", body: JSON.stringify({ api_key }) }, - baseUrl, - ); - }, - readFile(path: string, baseUrl?: string) { - return requestJson( - "/fs/read", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - writeFile(path: string, content: string, baseUrl?: string) { - return requestJson( - "/fs/write", - { method: "POST", body: JSON.stringify({ path, content }) }, - baseUrl, - ); - }, - listDirectory(path: string, baseUrl?: string) { - return requestJson( - "/fs/list", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - listDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/list/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - createDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/create/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - getHomeDirectory(baseUrl?: string) { - return requestJson("/io/fs/home", {}, baseUrl); - }, - listProjectFiles(baseUrl?: string) { - return requestJson("/io/fs/files", {}, baseUrl); - }, - searchFiles(query: string, baseUrl?: string) { - return requestJson( - "/fs/search", - { method: "POST", body: JSON.stringify({ query }) }, - baseUrl, - ); - }, - execShell(command: string, args: string[], baseUrl?: string) { - return requestJson( - "/shell/exec", - { method: "POST", body: JSON.stringify({ command, args }) }, - baseUrl, - ); - }, - cancelChat(baseUrl?: string) { - return requestJson("/chat/cancel", { method: "POST" }, baseUrl); - }, - getWorkItemContent(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}`, - {}, - baseUrl, - ); - }, - getTestResults(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}/test-results`, - {}, - baseUrl, - ); - }, - getTokenCost(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}/token-cost`, - {}, - baseUrl, - ); - }, - getAllTokenUsage(baseUrl?: string) { - return requestJson("/token-usage", {}, baseUrl); - }, - /** Trigger a server rebuild and restart. */ - rebuildAndRestart() { - return callMcpTool("rebuild_and_restart", {}); - }, - /** Approve a story in QA, moving it to merge. */ - approveQa(storyId: string) { - return callMcpTool("approve_qa", { story_id: storyId }); - }, - /** Reject a story in QA, moving it back to current with notes. */ - rejectQa(storyId: string, notes: string) { - return callMcpTool("reject_qa", { story_id: storyId, notes }); - }, - /** Launch the QA app for a story's worktree. */ - launchQaApp(storyId: string) { - return callMcpTool("launch_qa_app", { story_id: storyId }); - }, - /** Delete a story from the pipeline, stopping any running agent and removing the worktree. */ - deleteStory(storyId: string) { - return callMcpTool("delete_story", { story_id: storyId }); - }, + getCurrentProject(baseUrl?: string) { + return requestJson("/project", {}, baseUrl); + }, + getKnownProjects(baseUrl?: string) { + return requestJson("/projects", {}, baseUrl); + }, + forgetKnownProject(path: string, baseUrl?: string) { + return requestJson( + "/projects/forget", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + openProject(path: string, baseUrl?: string) { + return requestJson( + "/project", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + closeProject(baseUrl?: string) { + return requestJson("/project", { method: "DELETE" }, baseUrl); + }, + getModelPreference(baseUrl?: string) { + return requestJson("/model", {}, baseUrl); + }, + setModelPreference(model: string, baseUrl?: string) { + return requestJson( + "/model", + { method: "POST", body: JSON.stringify({ model }) }, + baseUrl, + ); + }, + getOllamaModels(baseUrlParam?: string, baseUrl?: string) { + const url = new URL( + buildApiUrl("/ollama/models", baseUrl), + window.location.origin, + ); + if (baseUrlParam) { + url.searchParams.set("base_url", baseUrlParam); + } + return requestJson(url.pathname + url.search, {}, ""); + }, + getAnthropicApiKeyExists(baseUrl?: string) { + return requestJson("/anthropic/key/exists", {}, baseUrl); + }, + getAnthropicModels(baseUrl?: string) { + return requestJson("/anthropic/models", {}, baseUrl); + }, + setAnthropicApiKey(api_key: string, baseUrl?: string) { + return requestJson( + "/anthropic/key", + { method: "POST", body: JSON.stringify({ api_key }) }, + baseUrl, + ); + }, + readFile(path: string, baseUrl?: string) { + return requestJson( + "/fs/read", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + writeFile(path: string, content: string, baseUrl?: string) { + return requestJson( + "/fs/write", + { method: "POST", body: JSON.stringify({ path, content }) }, + baseUrl, + ); + }, + listDirectory(path: string, baseUrl?: string) { + return requestJson( + "/fs/list", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + listDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/list/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + createDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/create/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + getHomeDirectory(baseUrl?: string) { + return requestJson("/io/fs/home", {}, baseUrl); + }, + listProjectFiles(baseUrl?: string) { + return requestJson("/io/fs/files", {}, baseUrl); + }, + searchFiles(query: string, baseUrl?: string) { + return requestJson( + "/fs/search", + { method: "POST", body: JSON.stringify({ query }) }, + baseUrl, + ); + }, + execShell(command: string, args: string[], baseUrl?: string) { + return requestJson( + "/shell/exec", + { method: "POST", body: JSON.stringify({ command, args }) }, + baseUrl, + ); + }, + cancelChat(baseUrl?: string) { + return requestJson("/chat/cancel", { method: "POST" }, baseUrl); + }, + getWorkItemContent(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}`, + {}, + baseUrl, + ); + }, + getTestResults(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}/test-results`, + {}, + baseUrl, + ); + }, + getTokenCost(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}/token-cost`, + {}, + baseUrl, + ); + }, + getAllTokenUsage(baseUrl?: string) { + return requestJson("/token-usage", {}, baseUrl); + }, + /** Trigger a server rebuild and restart. */ + rebuildAndRestart() { + return callMcpTool("rebuild_and_restart", {}); + }, + /** Approve a story in QA, moving it to merge. */ + approveQa(storyId: string) { + return callMcpTool("approve_qa", { story_id: storyId }); + }, + /** Reject a story in QA, moving it back to current with notes. */ + rejectQa(storyId: string, notes: string) { + return callMcpTool("reject_qa", { story_id: storyId, notes }); + }, + /** Launch the QA app for a story's worktree. */ + launchQaApp(storyId: string) { + return callMcpTool("launch_qa_app", { story_id: storyId }); + }, + /** Delete a story from the pipeline, stopping any running agent and removing the worktree. */ + deleteStory(storyId: string) { + return callMcpTool("delete_story", { story_id: storyId }); + }, }; async function callMcpTool( - toolName: string, - args: Record, + toolName: string, + args: Record, ): Promise { - const res = await fetch("/mcp", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: toolName, arguments: args }, - }), - }); - const json = await res.json(); - if (json.error) { - throw new Error(json.error.message); - } - const text = json.result?.content?.[0]?.text ?? ""; - return text; + const res = await fetch("/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: toolName, arguments: args }, + }), + }); + const json = await res.json(); + if (json.error) { + throw new Error(json.error.message); + } + const text = json.result?.content?.[0]?.text ?? ""; + return text; } export class ChatWebSocket { - private static sharedSocket: WebSocket | null = null; - private static refCount = 0; - private socket?: WebSocket; - private onToken?: (content: string) => void; - private onThinkingToken?: (content: string) => void; - private onUpdate?: (messages: Message[]) => void; - private onSessionId?: (sessionId: string) => void; - private onError?: (message: string) => void; - private onPipelineState?: (state: PipelineState) => void; - private onPermissionRequest?: ( - requestId: string, - toolName: string, - toolInput: Record, - ) => void; - private onActivity?: (toolName: string) => void; - private onReconciliationProgress?: ( - storyId: string, - status: string, - message: string, - ) => void; - private onAgentConfigChanged?: () => void; - private onAgentStateChanged?: () => void; - private onOnboardingStatus?: (needsOnboarding: boolean) => void; - private onSideQuestionToken?: (content: string) => void; - private onSideQuestionDone?: (response: string) => void; - private onLogEntry?: ( - timestamp: string, - level: string, - message: string, - ) => void; - private onConnected?: () => void; - private connected = false; - private closeTimer?: number; - private wsPath = DEFAULT_WS_PATH; - private reconnectTimer?: number; - private reconnectDelay = 1000; - private shouldReconnect = false; - private heartbeatInterval?: number; - private heartbeatTimeout?: number; - private static readonly HEARTBEAT_INTERVAL = 30_000; - private static readonly HEARTBEAT_TIMEOUT = 5_000; + private static sharedSocket: WebSocket | null = null; + private static refCount = 0; + private socket?: WebSocket; + private onToken?: (content: string) => void; + private onThinkingToken?: (content: string) => void; + private onUpdate?: (messages: Message[]) => void; + private onSessionId?: (sessionId: string) => void; + private onError?: (message: string) => void; + private onPipelineState?: (state: PipelineState) => void; + private onPermissionRequest?: ( + requestId: string, + toolName: string, + toolInput: Record, + ) => void; + private onActivity?: (toolName: string) => void; + private onReconciliationProgress?: ( + storyId: string, + status: string, + message: string, + ) => void; + private onAgentConfigChanged?: () => void; + private onAgentStateChanged?: () => void; + private onOnboardingStatus?: (needsOnboarding: boolean) => void; + private onSideQuestionToken?: (content: string) => void; + private onSideQuestionDone?: (response: string) => void; + private onLogEntry?: ( + timestamp: string, + level: string, + message: string, + ) => void; + private onConnected?: () => void; + private connected = false; + private closeTimer?: number; + private wsPath = DEFAULT_WS_PATH; + private reconnectTimer?: number; + private reconnectDelay = 1000; + private shouldReconnect = false; + private heartbeatInterval?: number; + private heartbeatTimeout?: number; + private static readonly HEARTBEAT_INTERVAL = 30_000; + private static readonly HEARTBEAT_TIMEOUT = 5_000; - private _startHeartbeat(): void { - this._stopHeartbeat(); - this.heartbeatInterval = window.setInterval(() => { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; - const ping: WsRequest = { type: "ping" }; - this.socket.send(JSON.stringify(ping)); - this.heartbeatTimeout = window.setTimeout(() => { - // No pong received within timeout; close socket to trigger reconnect. - this.socket?.close(); - }, ChatWebSocket.HEARTBEAT_TIMEOUT); - }, ChatWebSocket.HEARTBEAT_INTERVAL); - } + private _startHeartbeat(): void { + this._stopHeartbeat(); + this.heartbeatInterval = window.setInterval(() => { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; + const ping: WsRequest = { type: "ping" }; + this.socket.send(JSON.stringify(ping)); + this.heartbeatTimeout = window.setTimeout(() => { + // No pong received within timeout; close socket to trigger reconnect. + this.socket?.close(); + }, ChatWebSocket.HEARTBEAT_TIMEOUT); + }, ChatWebSocket.HEARTBEAT_INTERVAL); + } - private _stopHeartbeat(): void { - window.clearInterval(this.heartbeatInterval); - window.clearTimeout(this.heartbeatTimeout); - this.heartbeatInterval = undefined; - this.heartbeatTimeout = undefined; - } + private _stopHeartbeat(): void { + window.clearInterval(this.heartbeatInterval); + window.clearTimeout(this.heartbeatTimeout); + this.heartbeatInterval = undefined; + this.heartbeatTimeout = undefined; + } - private _buildWsUrl(): string { - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const wsHost = resolveWsHost( - import.meta.env.DEV, - typeof __STORYKIT_PORT__ !== "undefined" ? __STORYKIT_PORT__ : undefined, - window.location.host, - ); - return `${protocol}://${wsHost}${this.wsPath}`; - } + private _buildWsUrl(): string { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const wsHost = resolveWsHost( + import.meta.env.DEV, + typeof __STORKIT_PORT__ !== "undefined" ? __STORKIT_PORT__ : undefined, + window.location.host, + ); + return `${protocol}://${wsHost}${this.wsPath}`; + } - private _attachHandlers(): void { - if (!this.socket) return; - this.socket.onopen = () => { - this.reconnectDelay = 1000; - this._startHeartbeat(); - this.onConnected?.(); - }; - this.socket.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as WsResponse; - if (data.type === "token") this.onToken?.(data.content); - if (data.type === "thinking_token") - this.onThinkingToken?.(data.content); - if (data.type === "update") this.onUpdate?.(data.messages); - if (data.type === "session_id") this.onSessionId?.(data.session_id); - if (data.type === "error") this.onError?.(data.message); - if (data.type === "pipeline_state") - this.onPipelineState?.({ - backlog: data.backlog, - current: data.current, - qa: data.qa, - merge: data.merge, - done: data.done, - }); - if (data.type === "permission_request") - this.onPermissionRequest?.( - data.request_id, - data.tool_name, - data.tool_input, - ); - if (data.type === "tool_activity") this.onActivity?.(data.tool_name); - if (data.type === "reconciliation_progress") - this.onReconciliationProgress?.( - data.story_id, - data.status, - data.message, - ); - if (data.type === "agent_config_changed") this.onAgentConfigChanged?.(); - if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); - if (data.type === "onboarding_status") - this.onOnboardingStatus?.(data.needs_onboarding); - if (data.type === "side_question_token") - this.onSideQuestionToken?.(data.content); - if (data.type === "side_question_done") - this.onSideQuestionDone?.(data.response); - if (data.type === "log_entry") - this.onLogEntry?.(data.timestamp, data.level, data.message); - if (data.type === "pong") { - window.clearTimeout(this.heartbeatTimeout); - this.heartbeatTimeout = undefined; - } - } catch (err) { - this.onError?.(String(err)); - } - }; - this.socket.onerror = () => { - this.onError?.("WebSocket error"); - }; - this.socket.onclose = () => { - if (this.shouldReconnect && this.connected) { - this._scheduleReconnect(); - } - }; - } + private _attachHandlers(): void { + if (!this.socket) return; + this.socket.onopen = () => { + this.reconnectDelay = 1000; + this._startHeartbeat(); + this.onConnected?.(); + }; + this.socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as WsResponse; + if (data.type === "token") this.onToken?.(data.content); + if (data.type === "thinking_token") + this.onThinkingToken?.(data.content); + if (data.type === "update") this.onUpdate?.(data.messages); + if (data.type === "session_id") this.onSessionId?.(data.session_id); + if (data.type === "error") this.onError?.(data.message); + if (data.type === "pipeline_state") + this.onPipelineState?.({ + backlog: data.backlog, + current: data.current, + qa: data.qa, + merge: data.merge, + done: data.done, + }); + if (data.type === "permission_request") + this.onPermissionRequest?.( + data.request_id, + data.tool_name, + data.tool_input, + ); + if (data.type === "tool_activity") this.onActivity?.(data.tool_name); + if (data.type === "reconciliation_progress") + this.onReconciliationProgress?.( + data.story_id, + data.status, + data.message, + ); + if (data.type === "agent_config_changed") this.onAgentConfigChanged?.(); + if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); + if (data.type === "onboarding_status") + this.onOnboardingStatus?.(data.needs_onboarding); + if (data.type === "side_question_token") + this.onSideQuestionToken?.(data.content); + if (data.type === "side_question_done") + this.onSideQuestionDone?.(data.response); + if (data.type === "log_entry") + this.onLogEntry?.(data.timestamp, data.level, data.message); + if (data.type === "pong") { + window.clearTimeout(this.heartbeatTimeout); + this.heartbeatTimeout = undefined; + } + } catch (err) { + this.onError?.(String(err)); + } + }; + this.socket.onerror = () => { + this.onError?.("WebSocket error"); + }; + this.socket.onclose = () => { + if (this.shouldReconnect && this.connected) { + this._scheduleReconnect(); + } + }; + } - private _scheduleReconnect(): void { - window.clearTimeout(this.reconnectTimer); - const delay = this.reconnectDelay; - this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); - this.reconnectTimer = window.setTimeout(() => { - this.reconnectTimer = undefined; - const wsUrl = this._buildWsUrl(); - ChatWebSocket.sharedSocket = new WebSocket(wsUrl); - this.socket = ChatWebSocket.sharedSocket; - this._attachHandlers(); - }, delay); - } + private _scheduleReconnect(): void { + window.clearTimeout(this.reconnectTimer); + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = undefined; + const wsUrl = this._buildWsUrl(); + ChatWebSocket.sharedSocket = new WebSocket(wsUrl); + this.socket = ChatWebSocket.sharedSocket; + this._attachHandlers(); + }, delay); + } - connect( - handlers: { - onToken?: (content: string) => void; - onThinkingToken?: (content: string) => void; - onUpdate?: (messages: Message[]) => void; - onSessionId?: (sessionId: string) => void; - onError?: (message: string) => void; - onPipelineState?: (state: PipelineState) => void; - onPermissionRequest?: ( - requestId: string, - toolName: string, - toolInput: Record, - ) => void; - onActivity?: (toolName: string) => void; - onReconciliationProgress?: ( - storyId: string, - status: string, - message: string, - ) => void; - onAgentConfigChanged?: () => void; - onAgentStateChanged?: () => void; - onOnboardingStatus?: (needsOnboarding: boolean) => void; - onSideQuestionToken?: (content: string) => void; - onSideQuestionDone?: (response: string) => void; - onLogEntry?: (timestamp: string, level: string, message: string) => void; - onConnected?: () => void; - }, - wsPath = DEFAULT_WS_PATH, - ) { - this.onToken = handlers.onToken; - this.onThinkingToken = handlers.onThinkingToken; - this.onUpdate = handlers.onUpdate; - this.onSessionId = handlers.onSessionId; - this.onError = handlers.onError; - this.onPipelineState = handlers.onPipelineState; - this.onPermissionRequest = handlers.onPermissionRequest; - this.onActivity = handlers.onActivity; - this.onReconciliationProgress = handlers.onReconciliationProgress; - this.onAgentConfigChanged = handlers.onAgentConfigChanged; - this.onAgentStateChanged = handlers.onAgentStateChanged; - this.onOnboardingStatus = handlers.onOnboardingStatus; - this.onSideQuestionToken = handlers.onSideQuestionToken; - this.onSideQuestionDone = handlers.onSideQuestionDone; - this.onLogEntry = handlers.onLogEntry; - this.onConnected = handlers.onConnected; - this.wsPath = wsPath; - this.shouldReconnect = true; + connect( + handlers: { + onToken?: (content: string) => void; + onThinkingToken?: (content: string) => void; + onUpdate?: (messages: Message[]) => void; + onSessionId?: (sessionId: string) => void; + onError?: (message: string) => void; + onPipelineState?: (state: PipelineState) => void; + onPermissionRequest?: ( + requestId: string, + toolName: string, + toolInput: Record, + ) => void; + onActivity?: (toolName: string) => void; + onReconciliationProgress?: ( + storyId: string, + status: string, + message: string, + ) => void; + onAgentConfigChanged?: () => void; + onAgentStateChanged?: () => void; + onOnboardingStatus?: (needsOnboarding: boolean) => void; + onSideQuestionToken?: (content: string) => void; + onSideQuestionDone?: (response: string) => void; + onLogEntry?: (timestamp: string, level: string, message: string) => void; + onConnected?: () => void; + }, + wsPath = DEFAULT_WS_PATH, + ) { + this.onToken = handlers.onToken; + this.onThinkingToken = handlers.onThinkingToken; + this.onUpdate = handlers.onUpdate; + this.onSessionId = handlers.onSessionId; + this.onError = handlers.onError; + this.onPipelineState = handlers.onPipelineState; + this.onPermissionRequest = handlers.onPermissionRequest; + this.onActivity = handlers.onActivity; + this.onReconciliationProgress = handlers.onReconciliationProgress; + this.onAgentConfigChanged = handlers.onAgentConfigChanged; + this.onAgentStateChanged = handlers.onAgentStateChanged; + this.onOnboardingStatus = handlers.onOnboardingStatus; + this.onSideQuestionToken = handlers.onSideQuestionToken; + this.onSideQuestionDone = handlers.onSideQuestionDone; + this.onLogEntry = handlers.onLogEntry; + this.onConnected = handlers.onConnected; + this.wsPath = wsPath; + this.shouldReconnect = true; - if (this.connected) { - return; - } - this.connected = true; - ChatWebSocket.refCount += 1; + if (this.connected) { + return; + } + this.connected = true; + ChatWebSocket.refCount += 1; - if ( - !ChatWebSocket.sharedSocket || - ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || - ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING - ) { - const wsUrl = this._buildWsUrl(); - ChatWebSocket.sharedSocket = new WebSocket(wsUrl); - } - this.socket = ChatWebSocket.sharedSocket; - this._attachHandlers(); - } + if ( + !ChatWebSocket.sharedSocket || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING + ) { + const wsUrl = this._buildWsUrl(); + ChatWebSocket.sharedSocket = new WebSocket(wsUrl); + } + this.socket = ChatWebSocket.sharedSocket; + this._attachHandlers(); + } - sendChat(messages: Message[], config: ProviderConfig) { - this.send({ type: "chat", messages, config }); - } + sendChat(messages: Message[], config: ProviderConfig) { + this.send({ type: "chat", messages, config }); + } - sendSideQuestion( - question: string, - contextMessages: Message[], - config: ProviderConfig, - ) { - this.send({ - type: "side_question", - question, - context_messages: contextMessages, - config, - }); - } + sendSideQuestion( + question: string, + contextMessages: Message[], + config: ProviderConfig, + ) { + this.send({ + type: "side_question", + question, + context_messages: contextMessages, + config, + }); + } - cancel() { - this.send({ type: "cancel" }); - } + cancel() { + this.send({ type: "cancel" }); + } - sendPermissionResponse( - requestId: string, - approved: boolean, - alwaysAllow = false, - ) { - this.send({ - type: "permission_response", - request_id: requestId, - approved, - always_allow: alwaysAllow, - }); - } + sendPermissionResponse( + requestId: string, + approved: boolean, + alwaysAllow = false, + ) { + this.send({ + type: "permission_response", + request_id: requestId, + approved, + always_allow: alwaysAllow, + }); + } - close() { - this.shouldReconnect = false; - this._stopHeartbeat(); - window.clearTimeout(this.reconnectTimer); - this.reconnectTimer = undefined; + close() { + this.shouldReconnect = false; + this._stopHeartbeat(); + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; - if (!this.connected) return; - this.connected = false; - ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); + if (!this.connected) return; + this.connected = false; + ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); - if (import.meta.env.DEV) { - if (this.closeTimer) { - window.clearTimeout(this.closeTimer); - } - this.closeTimer = window.setTimeout(() => { - if (ChatWebSocket.refCount === 0) { - ChatWebSocket.sharedSocket?.close(); - ChatWebSocket.sharedSocket = null; - } - this.socket = ChatWebSocket.sharedSocket ?? undefined; - this.closeTimer = undefined; - }, 250); - return; - } + if (import.meta.env.DEV) { + if (this.closeTimer) { + window.clearTimeout(this.closeTimer); + } + this.closeTimer = window.setTimeout(() => { + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + this.closeTimer = undefined; + }, 250); + return; + } - if (ChatWebSocket.refCount === 0) { - ChatWebSocket.sharedSocket?.close(); - ChatWebSocket.sharedSocket = null; - } - this.socket = ChatWebSocket.sharedSocket ?? undefined; - } + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + } - private send(payload: WsRequest) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - this.onError?.("WebSocket is not connected"); - return; - } - this.socket.send(JSON.stringify(payload)); - } + private send(payload: WsRequest) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + this.onError?.("WebSocket is not connected"); + return; + } + this.socket.send(JSON.stringify(payload)); + } } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 931dea2..83d210c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,11 +3,11 @@ import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig(() => { - const backendPort = Number(process.env.STORYKIT_PORT || "3001"); + const backendPort = Number(process.env.STORKIT_PORT || "3001"); return { plugins: [react()], define: { - __STORYKIT_PORT__: JSON.stringify(String(backendPort)), + __STORKIT_PORT__: JSON.stringify(String(backendPort)), __BUILD_TIME__: JSON.stringify(new Date().toISOString()), }, server: { diff --git a/package-lock.json b/package-lock.json index b062c03..3547a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "story-kit", + "name": "storkit", "lockfileVersion": 3, "requires": true, "packages": {} diff --git a/server/src/agents/merge.rs b/server/src/agents/merge.rs index b3e1df0..f496bc1 100644 --- a/server/src/agents/merge.rs +++ b/server/src/agents/merge.rs @@ -88,9 +88,7 @@ pub(crate) fn run_squash_merge( let mut all_output = String::new(); let merge_branch = format!("merge-queue/{story_id}"); - let merge_wt_path = project_root - .join(".storkit") - .join("merge_workspace"); + let merge_wt_path = project_root.join(".storkit").join("merge_workspace"); // Ensure we start clean: remove any leftover merge workspace. cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); @@ -153,21 +151,15 @@ pub(crate) fn run_squash_merge( all_output.push_str(&resolution_log); if resolved { conflicts_resolved = true; - all_output - .push_str("=== All conflicts resolved automatically ===\n"); + all_output.push_str("=== All conflicts resolved automatically ===\n"); } else { // Could not resolve — abort, clean up, and report. let details = format!( "Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}\n{resolution_log}" ); conflict_details = Some(details); - all_output - .push_str("=== Unresolvable conflicts, aborting merge ===\n"); - cleanup_merge_workspace( - project_root, - &merge_wt_path, - &merge_branch, - ); + all_output.push_str("=== Unresolvable conflicts, aborting merge ===\n"); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts: true, @@ -180,11 +172,7 @@ pub(crate) fn run_squash_merge( } Err(e) => { all_output.push_str(&format!("Auto-resolution error: {e}\n")); - cleanup_merge_workspace( - project_root, - &merge_wt_path, - &merge_branch, - ); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts: true, @@ -201,7 +189,7 @@ pub(crate) fn run_squash_merge( // ── Commit in the temporary worktree ────────────────────────── all_output.push_str("=== git commit ===\n"); - let commit_msg = format!("story-kit: merge {story_id}"); + let commit_msg = format!("storkit: merge {story_id}"); let commit = Command::new("git") .args(["commit", "-m", &commit_msg]) .current_dir(&merge_wt_path) @@ -259,9 +247,7 @@ pub(crate) fn run_squash_merge( .output() .map_err(|e| format!("Failed to check merge diff: {e}"))?; let changed_files = String::from_utf8_lossy(&diff_check.stdout); - let has_code_changes = changed_files - .lines() - .any(|f| !f.starts_with(".storkit/")); + let has_code_changes = changed_files.lines().any(|f| !f.starts_with(".storkit/")); if !has_code_changes { all_output.push_str( "=== Merge commit contains only .storkit/ file moves, no code changes ===\n", @@ -330,8 +316,9 @@ pub(crate) fn run_squash_merge( Ok((false, gate_out)) => { all_output.push_str(&gate_out); all_output.push('\n'); - all_output - .push_str("=== Quality gates FAILED — aborting fast-forward, master unchanged ===\n"); + all_output.push_str( + "=== Quality gates FAILED — aborting fast-forward, master unchanged ===\n", + ); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, @@ -451,18 +438,14 @@ fn try_resolve_conflicts(worktree: &Path) -> Result<(bool, String), String> { .map_err(|e| format!("Failed to list conflicted files: {e}"))?; let file_list = String::from_utf8_lossy(&ls.stdout); - let conflicted_files: Vec<&str> = - file_list.lines().filter(|l| !l.is_empty()).collect(); + let conflicted_files: Vec<&str> = file_list.lines().filter(|l| !l.is_empty()).collect(); if conflicted_files.is_empty() { log.push_str("No conflicted files found (conflict may be index-only).\n"); return Ok((false, log)); } - log.push_str(&format!( - "Conflicted files ({}):\n", - conflicted_files.len() - )); + log.push_str(&format!("Conflicted files ({}):\n", conflicted_files.len())); for f in &conflicted_files { log.push_str(&format!(" - {f}\n")); } @@ -480,9 +463,7 @@ fn try_resolve_conflicts(worktree: &Path) -> Result<(bool, String), String> { resolutions.push((file, resolved)); } None => { - log.push_str(&format!( - " [COMPLEX — cannot auto-resolve] {file}\n" - )); + log.push_str(&format!(" [COMPLEX — cannot auto-resolve] {file}\n")); return Ok((false, log)); } } @@ -716,10 +697,7 @@ after // Ours comes before theirs let ours_pos = result.find("ours line 1").unwrap(); let theirs_pos = result.find("theirs line 1").unwrap(); - assert!( - ours_pos < theirs_pos, - "ours should come before theirs" - ); + assert!(ours_pos < theirs_pos, "ours should come before theirs"); } #[test] @@ -758,7 +736,10 @@ ours >>>>>>> feature "; let result = resolve_simple_conflicts(input); - assert!(result.is_none(), "malformed conflict (no separator) should return None"); + assert!( + result.is_none(), + "malformed conflict (no separator) should return None" + ); } #[test] @@ -770,14 +751,20 @@ ours theirs "; let result = resolve_simple_conflicts(input); - assert!(result.is_none(), "malformed conflict (no end marker) should return None"); + assert!( + result.is_none(), + "malformed conflict (no end marker) should return None" + ); } #[test] fn resolve_simple_conflicts_preserves_no_trailing_newline() { let input = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nafter"; let result = resolve_simple_conflicts(input).unwrap(); - assert!(!result.ends_with('\n'), "should not add trailing newline if original lacks one"); + assert!( + !result.ends_with('\n'), + "should not add trailing newline if original lacks one" + ); assert!(result.ends_with("after")); } @@ -801,10 +788,22 @@ fn feature_fn() { println!(\"from feature\"); }\n\ assert!(!result.contains("<<<<<<<"), "no conflict markers in output"); assert!(!result.contains(">>>>>>>"), "no conflict markers in output"); assert!(!result.contains("======="), "no separator in output"); - assert!(result.contains("fn master_fn()"), "master (ours) side must be preserved"); - assert!(result.contains("fn feature_fn()"), "feature (theirs) side must be preserved"); - assert!(result.contains("// shared code"), "context before conflict preserved"); - assert!(result.contains("// end"), "context after conflict preserved"); + assert!( + result.contains("fn master_fn()"), + "master (ours) side must be preserved" + ); + assert!( + result.contains("fn feature_fn()"), + "feature (theirs) side must be preserved" + ); + assert!( + result.contains("// shared code"), + "context before conflict preserved" + ); + assert!( + result.contains("// end"), + "context after conflict preserved" + ); // ours (master) must appear before theirs (feature) assert!( result.find("master_fn").unwrap() < result.find("feature_fn").unwrap(), @@ -830,12 +829,27 @@ export function featureImpl() {}\n\ >>>>>>> feature/story-43\n"; let result = resolve_simple_conflicts(input).unwrap(); assert!(!result.contains("<<<<<<<"), "no conflict markers in output"); - assert!(result.contains("import { A }"), "first block ours preserved"); - assert!(result.contains("import { B }"), "first block theirs preserved"); + assert!( + result.contains("import { A }"), + "first block ours preserved" + ); + assert!( + result.contains("import { B }"), + "first block theirs preserved" + ); assert!(result.contains("masterImpl"), "second block ours preserved"); - assert!(result.contains("featureImpl"), "second block theirs preserved"); - assert!(result.contains("// imports"), "surrounding context preserved"); - assert!(result.contains("// implementation"), "surrounding context preserved"); + assert!( + result.contains("featureImpl"), + "second block theirs preserved" + ); + assert!( + result.contains("// imports"), + "surrounding context preserved" + ); + assert!( + result.contains("// implementation"), + "surrounding context preserved" + ); } #[test] @@ -850,7 +864,10 @@ feature_addition\n\ after\n"; let result = resolve_simple_conflicts(input).unwrap(); assert!(!result.contains("<<<<<<<"), "no conflict markers"); - assert!(result.contains("feature_addition"), "non-empty side preserved"); + assert!( + result.contains("feature_addition"), + "non-empty side preserved" + ); assert!(result.contains("before"), "context preserved"); assert!(result.contains("after"), "context preserved"); } @@ -885,7 +902,11 @@ after\n"; .current_dir(repo) .output() .unwrap(); - fs::write(repo.join("shared.txt"), "line 1\nline 2\nfeature addition\n").unwrap(); + fs::write( + repo.join("shared.txt"), + "line 1\nline 2\nfeature addition\n", + ) + .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) @@ -916,8 +937,8 @@ after\n"; .unwrap(); // Run the squash merge. - let result = run_squash_merge(repo, "feature/story-conflict_test", "conflict_test") - .unwrap(); + let result = + run_squash_merge(repo, "feature/story-conflict_test", "conflict_test").unwrap(); // Master should NEVER contain conflict markers, regardless of outcome. let master_content = fs::read_to_string(repo.join("shared.txt")).unwrap(); @@ -999,12 +1020,17 @@ after\n"; .output() .unwrap(); - let result = run_squash_merge(repo, "feature/story-clean_test", "clean_test") - .unwrap(); + let result = run_squash_merge(repo, "feature/story-clean_test", "clean_test").unwrap(); assert!(result.success, "clean merge should succeed"); - assert!(!result.had_conflicts, "clean merge should have no conflicts"); - assert!(!result.conflicts_resolved, "no conflicts means nothing to resolve"); + assert!( + !result.had_conflicts, + "clean merge should have no conflicts" + ); + assert!( + !result.conflicts_resolved, + "no conflicts means nothing to resolve" + ); assert!( repo.join("new_file.txt").exists(), "merged file should exist on master" @@ -1019,8 +1045,7 @@ after\n"; let repo = tmp.path(); init_git_repo(repo); - let result = run_squash_merge(repo, "feature/story-nope", "nope") - .unwrap(); + let result = run_squash_merge(repo, "feature/story-nope", "nope").unwrap(); assert!(!result.success, "merge of nonexistent branch should fail"); } @@ -1078,36 +1103,28 @@ after\n"; .unwrap(); let sk_dir = repo.join(".storkit/work/4_merge"); fs::create_dir_all(&sk_dir).unwrap(); - fs::write( - sk_dir.join("diverge_test.md"), - "---\nname: test\n---\n", - ) - .unwrap(); + fs::write(sk_dir.join("diverge_test.md"), "---\nname: test\n---\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") - .args(["commit", "-m", "story-kit: queue diverge_test for merge"]) + .args(["commit", "-m", "storkit: queue diverge_test for merge"]) .current_dir(repo) .output() .unwrap(); // Run the squash merge. With the old fast-forward approach, this // would fail because master diverged. With cherry-pick, it succeeds. - let result = - run_squash_merge(repo, "feature/story-diverge_test", "diverge_test").unwrap(); + let result = run_squash_merge(repo, "feature/story-diverge_test", "diverge_test").unwrap(); assert!( result.success, "squash merge should succeed despite diverged master: {}", result.output ); - assert!( - !result.had_conflicts, - "no conflicts expected" - ); + assert!(!result.had_conflicts, "no conflicts expected"); // Verify the feature file landed on master. assert!( @@ -1176,8 +1193,7 @@ after\n"; .output() .unwrap(); - let result = - run_squash_merge(repo, "feature/story-empty_test", "empty_test").unwrap(); + let result = run_squash_merge(repo, "feature/story-empty_test", "empty_test").unwrap(); // Bug 226: empty diff must NOT be treated as success. assert!( @@ -1212,11 +1228,7 @@ after\n"; .unwrap(); let sk_dir = repo.join(".storkit/work/2_current"); fs::create_dir_all(&sk_dir).unwrap(); - fs::write( - sk_dir.join("md_only_test.md"), - "---\nname: Test\n---\n", - ) - .unwrap(); + fs::write(sk_dir.join("md_only_test.md"), "---\nname: Test\n---\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) @@ -1233,8 +1245,7 @@ after\n"; .output() .unwrap(); - let result = - run_squash_merge(repo, "feature/story-md_only_test", "md_only_test").unwrap(); + let result = run_squash_merge(repo, "feature/story-md_only_test", "md_only_test").unwrap(); // The squash merge will commit the .storkit/ file, but should fail because // there are no code changes outside .storkit/. @@ -1323,8 +1334,7 @@ after\n"; .unwrap(); // Squash-merge the feature branch — conflicts because both appended to the same location. - let result = - run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap(); + let result = run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap(); // Conflict must be detected and auto-resolved. assert!(result.had_conflicts, "additive conflict should be detected"); @@ -1336,8 +1346,14 @@ after\n"; // Master must contain both additions without conflict markers. let content = fs::read_to_string(repo.join("module.rs")).unwrap(); - assert!(!content.contains("<<<<<<<"), "master must not contain conflict markers"); - assert!(!content.contains(">>>>>>>"), "master must not contain conflict markers"); + assert!( + !content.contains("<<<<<<<"), + "master must not contain conflict markers" + ); + assert!( + !content.contains(">>>>>>>"), + "master must not contain conflict markers" + ); assert!( content.contains("feature_fn"), "feature branch addition must be preserved on master" @@ -1346,7 +1362,10 @@ after\n"; content.contains("master_fn"), "master branch addition must be preserved on master" ); - assert!(content.contains("existing"), "original function must be preserved"); + assert!( + content.contains("existing"), + "original function must be preserved" + ); // Cleanup: no leftover merge-queue branch or workspace. let branches = Command::new("git") @@ -1444,9 +1463,18 @@ after\n"; run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap(); assert!(result.had_conflicts, "conflict must be detected"); - assert!(result.conflicts_resolved, "additive conflict must be auto-resolved"); - assert!(!result.gates_passed, "quality gates must fail (script/test exits 1)"); - assert!(!result.success, "merge must be reported as failed when gates fail"); + assert!( + result.conflicts_resolved, + "additive conflict must be auto-resolved" + ); + assert!( + !result.gates_passed, + "quality gates must fail (script/test exits 1)" + ); + assert!( + !result.success, + "merge must be reported as failed when gates fail" + ); assert!( !result.output.is_empty(), "output must contain gate failure details" @@ -1454,7 +1482,10 @@ after\n"; // Master must NOT have been updated (cherry-pick was blocked by gate failure). let content = fs::read_to_string(repo.join("code.txt")).unwrap(); - assert!(!content.contains("<<<<<<<"), "master must not contain conflict markers"); + assert!( + !content.contains("<<<<<<<"), + "master must not contain conflict markers" + ); // master_addition was the last commit on master; feature_addition must NOT be there. assert!( !content.contains("feature_addition"), @@ -1508,8 +1539,7 @@ after\n"; fs::write(stale_ws.join("leftover.txt"), "stale").unwrap(); // Run the merge — it should clean up the stale workspace first. - let result = - run_squash_merge(repo, "feature/story-stale_test", "stale_test").unwrap(); + let result = run_squash_merge(repo, "feature/story-stale_test", "stale_test").unwrap(); assert!( result.success, @@ -1649,8 +1679,7 @@ after\n"; .unwrap(); let result = - run_squash_merge(repo, "feature/story-216_no_components", "216_no_components") - .unwrap(); + run_squash_merge(repo, "feature/story-216_no_components", "216_no_components").unwrap(); // No pnpm or frontend references should appear in the output. assert!( diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs index 3c80a51..96c9d0a 100644 --- a/server/src/http/agents.rs +++ b/server/src/http/agents.rs @@ -160,8 +160,7 @@ struct AllTokenUsageResponse { pub fn story_is_archived(project_root: &path::Path, story_id: &str) -> bool { let work = project_root.join(".storkit").join("work"); let filename = format!("{story_id}.md"); - work.join("5_done").join(&filename).exists() - || work.join("6_archived").join(&filename).exists() + work.join("5_done").join(&filename).exists() || work.join("6_archived").join(&filename).exists() } pub struct AgentsApi { @@ -215,11 +214,7 @@ impl AgentsApi { self.ctx .agents - .stop_agent( - &project_root, - &payload.0.story_id, - &payload.0.agent_name, - ) + .stop_agent(&project_root, &payload.0.story_id, &payload.0.agent_name) .await .map_err(bad_request)?; @@ -258,9 +253,7 @@ impl AgentsApi { /// Get the configured agent roster from project.toml. #[oai(path = "/agents/config", method = "get")] - async fn get_agent_config( - &self, - ) -> OpenApiResult>> { + async fn get_agent_config(&self) -> OpenApiResult>> { let project_root = self .ctx .agents @@ -288,9 +281,7 @@ impl AgentsApi { /// Reload project config and return the updated agent roster. #[oai(path = "/agents/config/reload", method = "post")] - async fn reload_config( - &self, - ) -> OpenApiResult>> { + async fn reload_config(&self) -> OpenApiResult>> { let project_root = self .ctx .agents @@ -440,10 +431,8 @@ impl AgentsApi { .get_project_root(&self.ctx.state) .map_err(bad_request)?; - let file_results = crate::http::workflow::read_test_results_from_story_file( - &project_root, - &story_id.0, - ); + let file_results = + crate::http::workflow::read_test_results_from_story_file(&project_root, &story_id.0); Ok(Json( file_results.map(|r| TestResultsResponse::from_story_results(&r)), @@ -467,8 +456,7 @@ impl AgentsApi { .get_project_root(&self.ctx.state) .map_err(bad_request)?; - let log_path = - crate::agent_log::find_latest_log(&project_root, &story_id.0, &agent_name.0); + let log_path = crate::agent_log::find_latest_log(&project_root, &story_id.0, &agent_name.0); let Some(path) = log_path else { return Ok(Json(AgentOutputResponse { @@ -480,10 +468,13 @@ impl AgentsApi { let output: String = entries .iter() - .filter(|e| { - e.event.get("type").and_then(|t| t.as_str()) == Some("output") + .filter(|e| e.event.get("type").and_then(|t| t.as_str()) == Some("output")) + .filter_map(|e| { + e.event + .get("text") + .and_then(|t| t.as_str()) + .map(str::to_owned) }) - .filter_map(|e| e.event.get("text").and_then(|t| t.as_str()).map(str::to_owned)) .collect(); Ok(Json(AgentOutputResponse { output })) @@ -562,9 +553,7 @@ impl AgentsApi { /// /// Returns the full history from the persistent token_usage.jsonl log. #[oai(path = "/token-usage", method = "get")] - async fn get_all_token_usage( - &self, - ) -> OpenApiResult> { + async fn get_all_token_usage(&self) -> OpenApiResult> { let project_root = self .ctx .agents @@ -659,9 +648,7 @@ mod tests { ctx.agents .inject_test_agent("80_story_active", "coder-1", AgentStatus::Running); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.list_agents().await.unwrap().0; // Archived story's agent should not appear @@ -686,9 +673,7 @@ mod tests { ctx.agents .inject_test_agent("42_story_whatever", "coder-1", AgentStatus::Completed); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.list_agents().await.unwrap().0; assert!(result.iter().any(|a| a.story_id == "42_story_whatever")); } @@ -705,9 +690,7 @@ mod tests { async fn get_agent_config_returns_default_when_no_toml() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.get_agent_config().await.unwrap().0; // Default config has one agent named "default" assert_eq!(result.len(), 1); @@ -734,9 +717,7 @@ model = "haiku" "#, ); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.get_agent_config().await.unwrap().0; assert_eq!(result.len(), 2); assert_eq!(result[0].name, "coder-1"); @@ -753,9 +734,7 @@ model = "haiku" let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.get_agent_config().await; assert!(result.is_err()); } @@ -766,9 +745,7 @@ model = "haiku" async fn reload_config_returns_default_when_no_toml() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.reload_config().await.unwrap().0; assert_eq!(result.len(), 1); assert_eq!(result[0].name, "default"); @@ -788,9 +765,7 @@ allowed_tools = ["Read", "Bash"] "#, ); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.reload_config().await.unwrap().0; assert_eq!(result.len(), 1); assert_eq!(result[0].name, "supervisor"); @@ -807,9 +782,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.reload_config().await; assert!(result.is_err()); } @@ -820,9 +793,7 @@ allowed_tools = ["Read", "Bash"] async fn list_worktrees_returns_empty_when_no_worktree_dir() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.list_worktrees().await.unwrap().0; assert!(result.is_empty()); } @@ -835,9 +806,7 @@ allowed_tools = ["Read", "Bash"] std::fs::create_dir_all(worktrees_dir.join("43_story_bar")).unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let mut result = api.list_worktrees().await.unwrap().0; result.sort_by(|a, b| a.story_id.cmp(&b.story_id)); @@ -851,9 +820,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api.list_worktrees().await; assert!(result.is_err()); } @@ -865,9 +832,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .stop_agent(Json(StopAgentPayload { story_id: "42_story_foo".to_string(), @@ -881,9 +846,7 @@ allowed_tools = ["Read", "Bash"] async fn stop_agent_returns_error_when_agent_not_found() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .stop_agent(Json(StopAgentPayload { story_id: "nonexistent_story".to_string(), @@ -899,9 +862,7 @@ allowed_tools = ["Read", "Bash"] let ctx = AppContext::new_test(tmp.path().to_path_buf()); ctx.agents .inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .stop_agent(Json(StopAgentPayload { story_id: "42_story_foo".to_string(), @@ -920,9 +881,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .start_agent(Json(StartAgentPayload { story_id: "42_story_foo".to_string(), @@ -949,9 +908,7 @@ allowed_tools = ["Read", "Bash"] ) .unwrap(); let ctx = AppContext::new_test(root.to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_work_item_content(Path("42_story_foo".to_string())) .await @@ -973,9 +930,7 @@ allowed_tools = ["Read", "Bash"] ) .unwrap(); let ctx = AppContext::new_test(root.to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_work_item_content(Path("43_story_bar".to_string())) .await @@ -989,9 +944,7 @@ allowed_tools = ["Read", "Bash"] async fn get_work_item_content_returns_not_found_when_absent() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_work_item_content(Path("99_story_nonexistent".to_string())) .await; @@ -1003,9 +956,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_work_item_content(Path("42_story_foo".to_string())) .await; @@ -1018,9 +969,7 @@ allowed_tools = ["Read", "Bash"] async fn get_agent_output_returns_empty_when_no_log_exists() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_agent_output( Path("42_story_foo".to_string()), @@ -1040,8 +989,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let root = tmp.path(); - let mut writer = - AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-test").unwrap(); + let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-test").unwrap(); writer .write_event(&AgentEvent::Status { @@ -1073,9 +1021,7 @@ allowed_tools = ["Read", "Bash"] .unwrap(); let ctx = AppContext::new_test(root.to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_agent_output( Path("42_story_foo".to_string()), @@ -1094,9 +1040,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_agent_output( Path("42_story_foo".to_string()), @@ -1113,9 +1057,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .create_worktree(Json(CreateWorktreePayload { story_id: "42_story_foo".to_string(), @@ -1129,9 +1071,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); // project_root is set but has no git repo — git worktree add will fail let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .create_worktree(Json(CreateWorktreePayload { story_id: "42_story_foo".to_string(), @@ -1147,12 +1087,8 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { - ctx: Arc::new(ctx), - }; - let result = api - .remove_worktree(Path("42_story_foo".to_string())) - .await; + let api = AgentsApi { ctx: Arc::new(ctx) }; + let result = api.remove_worktree(Path("42_story_foo".to_string())).await; assert!(result.is_err()); } @@ -1161,9 +1097,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); // project_root is set but no worktree exists for this story_id let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .remove_worktree(Path("nonexistent_story".to_string())) .await; @@ -1177,9 +1111,7 @@ allowed_tools = ["Read", "Bash"] let tmp = TempDir::new().unwrap(); let root = make_work_dirs(&tmp); let ctx = AppContext::new_test(root); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_test_results(Path("42_story_foo".to_string())) .await @@ -1214,9 +1146,7 @@ allowed_tools = ["Read", "Bash"] .unwrap(); } - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_test_results(Path("42_story_foo".to_string())) .await @@ -1255,7 +1185,7 @@ name: "Test story" ## Test Results - + "#; std::fs::write( root.join(".storkit/work/2_current/42_story_foo.md"), @@ -1264,9 +1194,7 @@ name: "Test story" .unwrap(); let ctx = AppContext::new_test(root); - let api = AgentsApi { - ctx: Arc::new(ctx), - }; + let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_test_results(Path("42_story_foo".to_string())) .await diff --git a/server/src/http/mcp/diagnostics.rs b/server/src/http/mcp/diagnostics.rs index 768f71f..4cddb41 100644 --- a/server/src/http/mcp/diagnostics.rs +++ b/server/src/http/mcp/diagnostics.rs @@ -3,7 +3,7 @@ use crate::http::context::AppContext; use crate::log_buffer; use crate::slog; use crate::slog_warn; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::fs; pub(super) fn tool_get_server_logs(args: &Value) -> Result { @@ -29,7 +29,7 @@ pub(super) fn tool_get_server_logs(args: &Value) -> Result { /// Rebuild the server binary and re-exec. /// /// 1. Gracefully stops all running agents (kills PTY children). -/// 2. Runs `cargo build [-p story-kit]` from the workspace root, matching +/// 2. Runs `cargo build [-p storkit]` from the workspace root, matching /// the current build profile (debug or release). /// 3. If the build fails, returns the build error (server stays up). /// 4. If the build succeeds, re-execs the process with the new binary via @@ -92,8 +92,8 @@ pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result = std::env::args().collect(); // Remove the port file before re-exec so the new process can write its own. @@ -124,7 +124,7 @@ pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result String { if tool_name == "Bash" { // Extract command from tool_input.command and use first word as prefix @@ -142,7 +142,10 @@ fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String { /// Add a permission rule to `.claude/settings.json` in the project root. /// Does nothing if the rule already exists. Creates the file if missing. -pub(super) fn add_permission_rule(project_root: &std::path::Path, rule: &str) -> Result<(), String> { +pub(super) fn add_permission_rule( + project_root: &std::path::Path, + rule: &str, +) -> Result<(), String> { let claude_dir = project_root.join(".claude"); fs::create_dir_all(&claude_dir) .map_err(|e| format!("Failed to create .claude/ directory: {e}"))?; @@ -151,8 +154,7 @@ pub(super) fn add_permission_rule(project_root: &std::path::Path, rule: &str) -> let mut settings: Value = if settings_path.exists() { let content = fs::read_to_string(&settings_path) .map_err(|e| format!("Failed to read settings.json: {e}"))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse settings.json: {e}"))? + serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings.json: {e}"))? } else { json!({ "permissions": { "allow": [] } }) }; @@ -184,8 +186,8 @@ pub(super) fn add_permission_rule(project_root: &std::path::Path, rule: &str) -> return Ok(()); } - // Also check for wildcard coverage: if "mcp__story-kit__*" exists, don't add - // a more specific "mcp__story-kit__create_story". + // Also check for wildcard coverage: if "mcp__storkit__*" exists, don't add + // a more specific "mcp__storkit__create_story". let dominated = allow.iter().any(|existing| { if let Some(pat) = existing.as_str() && let Some(prefix) = pat.strip_suffix('*') @@ -202,8 +204,7 @@ pub(super) fn add_permission_rule(project_root: &std::path::Path, rule: &str) -> let pretty = serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?; - fs::write(&settings_path, pretty) - .map_err(|e| format!("Failed to write settings.json: {e}"))?; + fs::write(&settings_path, pretty).map_err(|e| format!("Failed to write settings.json: {e}"))?; Ok(()) } @@ -212,16 +213,16 @@ pub(super) fn add_permission_rule(project_root: &std::path::Path, rule: &str) -> /// Forwards the permission request through the shared channel to the active /// WebSocket session, which presents a dialog to the user. Blocks until the /// user approves or denies (with a 5-minute timeout). -pub(super) async fn tool_prompt_permission(args: &Value, ctx: &AppContext) -> Result { +pub(super) async fn tool_prompt_permission( + args: &Value, + ctx: &AppContext, +) -> Result { let tool_name = args .get("tool_name") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - let tool_input = args - .get("input") - .cloned() - .unwrap_or(json!({})); + let tool_input = args.get("input").cloned().unwrap_or(json!({})); let request_id = uuid::Uuid::new_v4().to_string(); let (response_tx, response_rx) = tokio::sync::oneshot::channel(); @@ -237,17 +238,14 @@ pub(super) async fn tool_prompt_permission(args: &Value, ctx: &AppContext) -> Re use crate::http::context::PermissionDecision; - let decision = tokio::time::timeout( - std::time::Duration::from_secs(300), - response_rx, - ) - .await - .map_err(|_| { - let msg = format!("Permission request for '{tool_name}' timed out after 5 minutes"); - slog_warn!("[permission] {msg}"); - msg - })? - .map_err(|_| "Permission response channel closed unexpectedly".to_string())?; + let decision = tokio::time::timeout(std::time::Duration::from_secs(300), response_rx) + .await + .map_err(|_| { + let msg = format!("Permission request for '{tool_name}' timed out after 5 minutes"); + slog_warn!("[permission] {msg}"); + msg + })? + .map_err(|_| "Permission response channel closed unexpectedly".to_string())?; if decision == PermissionDecision::AlwaysAllow { // Persist the rule so Claude Code won't prompt again for this tool. @@ -362,9 +360,11 @@ mod tests { #[test] fn tool_get_server_logs_with_filter_returns_matching_lines() { - let result = - tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap(); - assert_eq!(result, "", "filter with no matches should return empty string"); + let result = tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap(); + assert_eq!( + result, "", + "filter with no matches should return empty string" + ); } #[test] @@ -431,13 +431,13 @@ mod tests { cache_read_input_tokens: 0, total_cost_usd: 0.5, }; - let r1 = crate::agents::token_usage::build_record("10_story_a", "coder-1", None, usage.clone()); + let r1 = + crate::agents::token_usage::build_record("10_story_a", "coder-1", None, usage.clone()); let r2 = crate::agents::token_usage::build_record("20_story_b", "coder-2", None, usage); crate::agents::token_usage::append_record(root, &r1).unwrap(); crate::agents::token_usage::append_record(root, &r2).unwrap(); - let result = - tool_get_token_usage(&json!({"story_id": "10_story_a"}), &ctx).unwrap(); + let result = tool_get_token_usage(&json!({"story_id": "10_story_a"}), &ctx).unwrap(); let parsed: Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["records"].as_array().unwrap().len(), 1); assert_eq!(parsed["records"][0]["story_id"], "10_story_a"); @@ -454,7 +454,9 @@ mod tests { tokio::spawn(async move { let mut rx = perm_rx.lock().await; if let Some(forward) = rx.recv().await { - let _ = forward.response_tx.send(crate::http::context::PermissionDecision::Approve); + let _ = forward + .response_tx + .send(crate::http::context::PermissionDecision::Approve); } }); @@ -486,19 +488,21 @@ mod tests { tokio::spawn(async move { let mut rx = perm_rx.lock().await; if let Some(forward) = rx.recv().await { - let _ = forward.response_tx.send(crate::http::context::PermissionDecision::Deny); + let _ = forward + .response_tx + .send(crate::http::context::PermissionDecision::Deny); } }); - let result = tool_prompt_permission( - &json!({"tool_name": "Write", "input": {}}), - &ctx, - ) - .await - .expect("denial must return Ok, not Err"); + let result = tool_prompt_permission(&json!({"tool_name": "Write", "input": {}}), &ctx) + .await + .expect("denial must return Ok, not Err"); let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON"); - assert_eq!(parsed["behavior"], "deny", "denied must return behavior:deny"); + assert_eq!( + parsed["behavior"], "deny", + "denied must return behavior:deny" + ); assert!(parsed["message"].is_string(), "deny must include a message"); } @@ -518,15 +522,13 @@ mod tests { #[test] fn generate_rule_for_bash_git() { - let rule = - generate_permission_rule("Bash", &json!({"command": "git status"})); + let rule = generate_permission_rule("Bash", &json!({"command": "git status"})); assert_eq!(rule, "Bash(git *)"); } #[test] fn generate_rule_for_bash_cargo() { - let rule = - generate_permission_rule("Bash", &json!({"command": "cargo test --all"})); + let rule = generate_permission_rule("Bash", &json!({"command": "cargo test --all"})); assert_eq!(rule, "Bash(cargo *)"); } @@ -538,11 +540,8 @@ mod tests { #[test] fn generate_rule_for_mcp_tool() { - let rule = generate_permission_rule( - "mcp__story-kit__create_story", - &json!({"name": "foo"}), - ); - assert_eq!(rule, "mcp__story-kit__create_story"); + let rule = generate_permission_rule("mcp__storkit__create_story", &json!({"name": "foo"})); + assert_eq!(rule, "mcp__storkit__create_story"); } // ── Settings.json writing tests ────────────────────────────── @@ -578,17 +577,17 @@ mod tests { fs::create_dir_all(&claude_dir).unwrap(); fs::write( claude_dir.join("settings.json"), - r#"{"permissions":{"allow":["mcp__story-kit__*"]}}"#, + r#"{"permissions":{"allow":["mcp__storkit__*"]}}"#, ) .unwrap(); - add_permission_rule(tmp.path(), "mcp__story-kit__create_story").unwrap(); + add_permission_rule(tmp.path(), "mcp__storkit__create_story").unwrap(); let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap(); let settings: Value = serde_json::from_str(&content).unwrap(); let allow = settings["permissions"]["allow"].as_array().unwrap(); assert_eq!(allow.len(), 1); - assert_eq!(allow[0], "mcp__story-kit__*"); + assert_eq!(allow[0], "mcp__storkit__*"); } #[test] @@ -634,7 +633,7 @@ mod tests { #[test] fn rebuild_and_restart_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "rebuild_and_restart"); @@ -687,7 +686,7 @@ mod tests { #[test] fn move_story_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "move_story"); @@ -814,6 +813,10 @@ mod tests { &ctx, ); assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found in any pipeline stage")); + assert!( + result + .unwrap_err() + .contains("not found in any pipeline stage") + ); } } diff --git a/server/src/http/mcp/qa_tools.rs b/server/src/http/mcp/qa_tools.rs index 0a9c940..a2ed7ac 100644 --- a/server/src/http/mcp/qa_tools.rs +++ b/server/src/http/mcp/qa_tools.rs @@ -2,7 +2,7 @@ use crate::agents::{move_story_to_merge, move_story_to_qa, reject_story_from_qa} use crate::http::context::AppContext; use crate::slog; use crate::slog_warn; -use serde_json::{json, Value}; +use serde_json::{Value, json}; pub(super) async fn tool_request_qa(args: &Value, ctx: &AppContext) -> Result { let story_id = args @@ -160,7 +160,7 @@ pub(super) async fn tool_launch_qa_app(args: &Value, ctx: &AppContext) -> Result // Launch the server from the worktree let child = std::process::Command::new("cargo") .args(["run"]) - .env("STORYKIT_PORT", port.to_string()) + .env("STORKIT_PORT", port.to_string()) .current_dir(&wt_path) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -202,7 +202,7 @@ mod tests { #[test] fn request_qa_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "request_qa"); @@ -217,7 +217,7 @@ mod tests { #[test] fn approve_qa_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "approve_qa"); @@ -230,7 +230,7 @@ mod tests { #[test] fn reject_qa_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "reject_qa"); @@ -244,7 +244,7 @@ mod tests { #[test] fn launch_qa_app_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "launch_qa_app"); diff --git a/server/src/http/mcp/story_tools.rs b/server/src/http/mcp/story_tools.rs index 2b041a6..4dbbbba 100644 --- a/server/src/http/mcp/story_tools.rs +++ b/server/src/http/mcp/story_tools.rs @@ -1,14 +1,16 @@ +use crate::agents::{ + close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived, +}; use crate::http::context::AppContext; use crate::http::workflow::{ add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file, - create_spike_file, create_story_file, list_bug_files, list_refactor_files, - load_pipeline_state, load_upcoming_stories, update_story_in_file, validate_story_dirs, + create_spike_file, create_story_file, list_bug_files, list_refactor_files, load_pipeline_state, + load_upcoming_stories, update_story_in_file, validate_story_dirs, }; -use crate::agents::{close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived}; -use crate::slog_warn; use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos}; -use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus}; -use serde_json::{json, Value}; +use crate::slog_warn; +use crate::workflow::{TestCaseResult, TestStatus, evaluate_acceptance_with_coverage}; +use serde_json::{Value, json}; use std::collections::HashMap; use std::fs; @@ -40,27 +42,31 @@ pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result Result { let root = ctx.state.get_project_root()?; let results = validate_story_dirs(&root)?; - serde_json::to_string_pretty(&json!(results - .iter() - .map(|r| json!({ - "story_id": r.story_id, - "valid": r.valid, - "error": r.error, - })) - .collect::>())) + serde_json::to_string_pretty(&json!( + results + .iter() + .map(|r| json!({ + "story_id": r.story_id, + "valid": r.valid, + "error": r.error, + })) + .collect::>() + )) .map_err(|e| format!("Serialization error: {e}")) } pub(super) fn tool_list_upcoming(ctx: &AppContext) -> Result { let stories = load_upcoming_stories(ctx)?; - serde_json::to_string_pretty(&json!(stories - .iter() - .map(|s| json!({ - "story_id": s.story_id, - "name": s.name, - "error": s.error, - })) - .collect::>())) + serde_json::to_string_pretty(&json!( + stories + .iter() + .map(|s| json!({ + "story_id": s.story_id, + "name": s.name, + "error": s.error, + })) + .collect::>() + )) .map_err(|e| format!("Serialization error: {e}")) } @@ -131,12 +137,10 @@ pub(super) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result Result Result Result Result { let root = ctx.state.get_project_root()?; let bugs = list_bug_files(&root)?; - serde_json::to_string_pretty(&json!(bugs - .iter() - .map(|(id, name)| json!({ "bug_id": id, "name": name })) - .collect::>())) + serde_json::to_string_pretty(&json!( + bugs.iter() + .map(|(id, name)| json!({ "bug_id": id, "name": name })) + .collect::>() + )) .map_err(|e| format!("Serialization error: {e}")) } @@ -401,7 +413,10 @@ pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result< // 1. Stop any running agents for this story (best-effort) if let Ok(agents) = ctx.agents.list_agents() { for agent in agents.iter().filter(|a| a.story_id == story_id) { - let _ = ctx.agents.stop_agent(&project_root, story_id, &agent.agent_name).await; + let _ = ctx + .agents + .stop_agent(&project_root, story_id, &agent.agent_name) + .await; } } @@ -410,18 +425,25 @@ pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result< // 3. Remove worktree (best-effort) if let Ok(config) = crate::config::ProjectConfig::load(&project_root) { - let _ = crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await; + let _ = + crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await; } // 4. Find and delete the story file from any pipeline stage let sk = project_root.join(".storkit").join("work"); - let stage_dirs = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"]; + let stage_dirs = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; let mut deleted = false; for stage in &stage_dirs { let path = sk.join(stage).join(format!("{story_id}.md")); if path.exists() { - fs::remove_file(&path) - .map_err(|e| format!("Failed to delete story file: {e}"))?; + fs::remove_file(&path).map_err(|e| format!("Failed to delete story file: {e}"))?; slog_warn!("[delete_story] Deleted '{story_id}' from work/{stage}/"); deleted = true; break; @@ -448,12 +470,8 @@ pub(super) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result Result Result { let root = ctx.state.get_project_root()?; let refactors = list_refactor_files(&root)?; - serde_json::to_string_pretty(&json!(refactors - .iter() - .map(|(id, name)| json!({ "refactor_id": id, "name": name })) - .collect::>())) + serde_json::to_string_pretty(&json!( + refactors + .iter() + .map(|(id, name)| json!({ "refactor_id": id, "name": name })) + .collect::>() + )) .map_err(|e| format!("Serialization error: {e}")) } @@ -489,9 +509,16 @@ pub(super) fn parse_test_cases(value: Option<&Value>) -> Result TestStatus::Pass, "fail" => TestStatus::Fail, - other => return Err(format!("Invalid test status '{other}'. Use 'pass' or 'fail'.")), + other => { + return Err(format!( + "Invalid test status '{other}'. Use 'pass' or 'fail'." + )); + } }; - let details = item.get("details").and_then(|v| v.as_str()).map(String::from); + let details = item + .get("details") + .and_then(|v| v.as_str()) + .map(String::from); Ok(TestCaseResult { name, status, @@ -643,7 +670,10 @@ mod tests { let active = parsed["active"].as_array().unwrap(); assert_eq!(active.len(), 4); - let stages: Vec<&str> = active.iter().map(|i| i["stage"].as_str().unwrap()).collect(); + let stages: Vec<&str> = active + .iter() + .map(|i| i["stage"].as_str().unwrap()) + .collect(); assert!(stages.contains(&"current")); assert!(stages.contains(&"qa")); assert!(stages.contains(&"merge")); @@ -783,7 +813,7 @@ mod tests { #[test] fn create_bug_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "create_bug"); @@ -809,7 +839,7 @@ mod tests { #[test] fn list_bugs_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "list_bugs"); @@ -828,7 +858,7 @@ mod tests { #[test] fn close_bug_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "close_bug"); @@ -921,11 +951,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let backlog_dir = tmp.path().join(".storkit/work/1_backlog"); std::fs::create_dir_all(&backlog_dir).unwrap(); - std::fs::write( - backlog_dir.join("1_bug_crash.md"), - "# Bug 1: App Crash\n", - ) - .unwrap(); + std::fs::write(backlog_dir.join("1_bug_crash.md"), "# Bug 1: App Crash\n").unwrap(); std::fs::write( backlog_dir.join("2_bug_typo.md"), "# Bug 2: Typo in Header\n", @@ -975,12 +1001,16 @@ mod tests { let result = tool_close_bug(&json!({"bug_id": "1_bug_crash"}), &ctx).unwrap(); assert!(result.contains("1_bug_crash")); assert!(!bug_file.exists()); - assert!(tmp.path().join(".storkit/work/5_done/1_bug_crash.md").exists()); + assert!( + tmp.path() + .join(".storkit/work/5_done/1_bug_crash.md") + .exists() + ); } #[test] fn create_spike_in_tools_list() { - use super::super::{handle_tools_list}; + use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "create_spike"); @@ -1041,7 +1071,9 @@ mod tests { let result = tool_create_spike(&json!({"name": "My Spike"}), &ctx).unwrap(); assert!(result.contains("1_spike_my_spike")); - let spike_file = tmp.path().join(".storkit/work/1_backlog/1_spike_my_spike.md"); + let spike_file = tmp + .path() + .join(".storkit/work/1_backlog/1_spike_my_spike.md"); assert!(spike_file.exists()); let contents = std::fs::read_to_string(&spike_file).unwrap(); assert!(contents.starts_with("---\nname: \"My Spike\"\n---")); @@ -1052,10 +1084,7 @@ mod tests { fn tool_record_tests_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); - let result = tool_record_tests( - &json!({"unit": [], "integration": []}), - &ctx, - ); + let result = tool_record_tests(&json!({"unit": [], "integration": []}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } @@ -1106,11 +1135,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let current_dir = tmp.path().join(".storkit").join("work").join("2_current"); fs::create_dir_all(¤t_dir).unwrap(); - fs::write( - current_dir.join("1_test.md"), - "## No front matter at all\n", - ) - .unwrap(); + fs::write(current_dir.join("1_test.md"), "## No front matter at all\n").unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_validate_stories(&ctx).unwrap(); let parsed: Vec = serde_json::from_str(&result).unwrap(); @@ -1123,7 +1148,11 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); - fs::write(current.join("1_story_persist.md"), "---\nname: Persist\n---\n# Story\n").unwrap(); + fs::write( + current.join("1_story_persist.md"), + "---\nname: Persist\n---\n# Story\n", + ) + .unwrap(); let ctx = test_ctx(tmp.path()); tool_record_tests( @@ -1137,8 +1166,14 @@ mod tests { .unwrap(); let contents = fs::read_to_string(current.join("1_story_persist.md")).unwrap(); - assert!(contents.contains("## Test Results"), "file should have Test Results section"); - assert!(contents.contains("story-kit-test-results:"), "file should have JSON marker"); + assert!( + contents.contains("## Test Results"), + "file should have Test Results section" + ); + assert!( + contents.contains("storkit-test-results:"), + "file should have JSON marker" + ); assert!(contents.contains("u1"), "file should contain test name"); } @@ -1149,7 +1184,7 @@ mod tests { fs::create_dir_all(¤t).unwrap(); // Write a story file with a pre-populated Test Results section (simulating a restart) - let story_content = "---\nname: Persist\n---\n# Story\n\n## Test Results\n\n\n"; + let story_content = "---\nname: Persist\n---\n# Story\n\n## Test Results\n\n\n"; fs::write(current.join("2_story_file_only.md"), story_content).unwrap(); // Use a fresh context (empty in-memory state, simulating a restart) @@ -1157,7 +1192,11 @@ mod tests { // ensure_acceptance should read from file and succeed let result = tool_ensure_acceptance(&json!({"story_id": "2_story_file_only"}), &ctx); - assert!(result.is_ok(), "should accept based on file data, got: {:?}", result); + assert!( + result.is_ok(), + "should accept based on file data, got: {:?}", + result + ); assert!(result.unwrap().contains("All gates pass")); } @@ -1167,7 +1206,7 @@ mod tests { let current = tmp.path().join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); - let story_content = "---\nname: Fail\n---\n# Story\n\n## Test Results\n\n\n"; + let story_content = "---\nname: Fail\n---\n# Story\n\n## Test Results\n\n\n"; fs::write(current.join("3_story_fail.md"), story_content).unwrap(); let ctx = test_ctx(tmp.path()); @@ -1191,7 +1230,11 @@ mod tests { let ctx = test_ctx(tmp.path()); let result = tool_delete_story(&json!({"story_id": "99_nonexistent"}), &ctx).await; assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found in any pipeline stage")); + assert!( + result + .unwrap_err() + .contains("not found in any pipeline stage") + ); } #[tokio::test] @@ -1280,9 +1323,11 @@ mod tests { .unwrap(); let ctx = test_ctx(tmp.path()); - let result = - tool_accept_story(&json!({"story_id": "50_story_test"}), &ctx); - assert!(result.is_err(), "should refuse when feature branch has unmerged code"); + let result = tool_accept_story(&json!({"story_id": "50_story_test"}), &ctx); + assert!( + result.is_err(), + "should refuse when feature branch has unmerged code" + ); let err = result.unwrap_err(); assert!( err.contains("unmerged"), @@ -1306,9 +1351,11 @@ mod tests { .unwrap(); let ctx = test_ctx(tmp.path()); - let result = - tool_accept_story(&json!({"story_id": "51_story_no_branch"}), &ctx); - assert!(result.is_ok(), "should succeed when no feature branch: {result:?}"); + let result = tool_accept_story(&json!({"story_id": "51_story_no_branch"}), &ctx); + assert!( + result.is_ok(), + "should succeed when no feature branch: {result:?}" + ); } #[test] @@ -1352,10 +1399,8 @@ mod tests { .unwrap(); let ctx = test_ctx(tmp.path()); - let result = tool_check_criterion( - &json!({"story_id": "1_test", "criterion_index": 0}), - &ctx, - ); + let result = + tool_check_criterion(&json!({"story_id": "1_test", "criterion_index": 0}), &ctx); assert!(result.is_ok(), "Expected ok: {result:?}"); assert!(result.unwrap().contains("Criterion 0 checked")); } diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 0f4bdac..49dba41 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -41,7 +41,7 @@ pub fn parse_port(value: Option) -> u16 { } pub fn resolve_port() -> u16 { - parse_port(std::env::var("STORYKIT_PORT").ok()) + parse_port(std::env::var("STORKIT_PORT").ok()) } pub fn write_port_file(dir: &Path, port: u16) -> Option { @@ -194,7 +194,7 @@ mod tests { #[test] fn resolve_port_returns_a_valid_port() { - // Exercises the resolve_port code path (reads STORYKIT_PORT env var or defaults). + // Exercises the resolve_port code path (reads STORKIT_PORT env var or defaults). let port = resolve_port(); assert!(port > 0); } diff --git a/server/src/http/workflow/test_results.rs b/server/src/http/workflow/test_results.rs index 61b77f5..f3fcc9e 100644 --- a/server/src/http/workflow/test_results.rs +++ b/server/src/http/workflow/test_results.rs @@ -5,7 +5,7 @@ use std::path::Path; use super::{find_story_file, replace_or_append_section}; -const TEST_RESULTS_MARKER: &str = "\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n", + "---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n", ) .unwrap(); @@ -208,7 +235,11 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); - fs::write(current.join("4_story_empty.md"), "---\nname: Empty\n---\n# Story\n").unwrap(); + fs::write( + current.join("4_story_empty.md"), + "---\nname: Empty\n---\n# Story\n", + ) + .unwrap(); let result = read_test_results_from_story_file(tmp.path(), "4_story_empty"); assert!(result.is_none()); @@ -226,10 +257,18 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let qa_dir = tmp.path().join(".storkit/work/3_qa"); fs::create_dir_all(&qa_dir).unwrap(); - fs::write(qa_dir.join("5_story_qa.md"), "---\nname: QA Story\n---\n# Story\n").unwrap(); + fs::write( + qa_dir.join("5_story_qa.md"), + "---\nname: QA Story\n---\n# Story\n", + ) + .unwrap(); let results = StoryTestResults { - unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None }], + unit: vec![TestCaseResult { + name: "u1".to_string(), + status: TestStatus::Pass, + details: None, + }], integration: vec![], }; write_test_results_to_story_file(tmp.path(), "5_story_qa", &results).unwrap(); @@ -243,12 +282,19 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); - fs::write(current.join("6_story_cov.md"), "---\nname: Cov Story\n---\n# Story\n").unwrap(); + fs::write( + current.join("6_story_cov.md"), + "---\nname: Cov Story\n---\n# Story\n", + ) + .unwrap(); write_coverage_baseline_to_story_file(tmp.path(), "6_story_cov", 75.4).unwrap(); let contents = fs::read_to_string(current.join("6_story_cov.md")).unwrap(); - assert!(contents.contains("coverage_baseline: 75.4%"), "got: {contents}"); + assert!( + contents.contains("coverage_baseline: 75.4%"), + "got: {contents}" + ); } #[test] diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index 32794f4..606a1b3 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -555,7 +555,10 @@ mod tests { match req { WsRequest::Chat { messages, config } => { assert!(messages.is_empty()); - assert_eq!(config.base_url.as_deref(), Some("https://api.anthropic.com")); + assert_eq!( + config.base_url.as_deref(), + Some("https://api.anthropic.com") + ); assert_eq!(config.enable_tools, Some(true)); assert_eq!(config.session_id.as_deref(), Some("sess-123")); } @@ -719,14 +722,14 @@ mod tests { stage: "2_current".to_string(), item_id: "42_story_foo".to_string(), action: "start".to_string(), - commit_msg: "story-kit: start 42_story_foo".to_string(), + commit_msg: "storkit: start 42_story_foo".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "work_item_changed"); assert_eq!(json["stage"], "2_current"); assert_eq!(json["item_id"], "42_story_foo"); assert_eq!(json["action"], "start"); - assert_eq!(json["commit_msg"], "story-kit: start 42_story_foo"); + assert_eq!(json["commit_msg"], "storkit: start 42_story_foo"); } #[test] @@ -847,7 +850,7 @@ mod tests { stage: "2_current".to_string(), item_id: "42_story_foo".to_string(), action: "start".to_string(), - commit_msg: "story-kit: start 42_story_foo".to_string(), + commit_msg: "storkit: start 42_story_foo".to_string(), }; let ws_msg: Option = evt.into(); let ws_msg = ws_msg.expect("WorkItem should produce Some"); @@ -1126,9 +1129,7 @@ mod tests { tokio::spawn(async move { let acceptor = poem::listener::TcpAcceptor::from_tokio(listener).unwrap(); - let _ = poem::Server::new_with_acceptor(acceptor) - .run(app) - .await; + let _ = poem::Server::new_with_acceptor(acceptor).run(app).await; }); // Small delay to let the server start. @@ -1256,17 +1257,12 @@ mod tests { let (mut sink, mut stream, _initial) = connect_ws(&url).await; // Send invalid JSON. - sink.send(ws_text("not valid json")) - .await - .unwrap(); + sink.send(ws_text("not valid json")).await.unwrap(); let msg = next_msg(&mut stream).await; assert_eq!(msg["type"], "error"); assert!( - msg["message"] - .as_str() - .unwrap() - .contains("Invalid request"), + msg["message"].as_str().unwrap().contains("Invalid request"), "error message should indicate invalid request, got: {}", msg["message"] ); @@ -1278,9 +1274,7 @@ mod tests { let (mut sink, mut stream, _initial) = connect_ws(&url).await; // Send a message with an unknown type. - sink.send(ws_text(r#"{"type": "bogus"}"#)) - .await - .unwrap(); + sink.send(ws_text(r#"{"type": "bogus"}"#)).await.unwrap(); let msg = next_msg(&mut stream).await; assert_eq!(msg["type"], "error"); @@ -1293,14 +1287,10 @@ mod tests { let (mut sink, mut stream, _initial) = connect_ws(&url).await; // Send cancel when no chat is active — should not produce an error. - sink.send(ws_text(r#"{"type": "cancel"}"#)) - .await - .unwrap(); + sink.send(ws_text(r#"{"type": "cancel"}"#)).await.unwrap(); // Send another invalid message to check the connection is still alive. - sink.send(ws_text("{}")) - .await - .unwrap(); + sink.send(ws_text("{}")).await.unwrap(); let msg = next_msg(&mut stream).await; // The invalid JSON message should produce an error, confirming @@ -1321,9 +1311,7 @@ mod tests { .unwrap(); // Send a probe message to check the connection is still alive. - sink.send(ws_text("bad")) - .await - .unwrap(); + sink.send(ws_text("bad")).await.unwrap(); let msg = next_msg(&mut stream).await; assert_eq!(msg["type"], "error"); @@ -1341,7 +1329,7 @@ mod tests { stage: "2_current".to_string(), item_id: "99_story_test".to_string(), action: "start".to_string(), - commit_msg: "story-kit: start 99_story_test".to_string(), + commit_msg: "storkit: start 99_story_test".to_string(), }) .unwrap(); diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index a2e64c9..fef42c7 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -11,7 +11,7 @@ const KEY_KNOWN_PROJECTS: &str = "known_projects"; const STORY_KIT_README: &str = include_str!("../../../.storkit/README.md"); -const STORY_KIT_CONTEXT: &str = "\n\ +const STORY_KIT_CONTEXT: &str = "\n\ # Project Context\n\ \n\ ## High-Level Goal\n\ @@ -30,7 +30,7 @@ TODO: Define the key domain concepts and entities.\n\ \n\ TODO: Define abbreviations and technical terms.\n"; -const STORY_KIT_STACK: &str = "\n\ +const STORY_KIT_STACK: &str = "\n\ # Tech Stack & Constraints\n\ \n\ ## Core Stack\n\ @@ -51,7 +51,7 @@ TODO: List approved libraries and their purpose.\n"; const STORY_KIT_SCRIPT_TEST: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's test commands here.\n# Story Kit agents invoke this script as the canonical test runner.\n# Exit 0 on success, non-zero on failure.\necho \"No tests configured\"\n"; -const STORY_KIT_CLAUDE_MD: &str = "\n\ +const STORY_KIT_CLAUDE_MD: &str = "\n\ Never chain shell commands with `&&`, `||`, or `;` in a single Bash call. \ The permission system validates the entire command string, and chained commands \ won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \ @@ -90,7 +90,7 @@ const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{ "Bash(./script/test:*)", "Edit", "Write", - "mcp__story-kit__*" + "mcp__storkit__*" ] }, "enabledMcpjsonServers": [ @@ -422,8 +422,7 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> { ]; for stage in &work_stages { let dir = story_kit_root.join("work").join(stage); - fs::create_dir_all(&dir) - .map_err(|e| format!("Failed to create work/{}: {}", stage, e))?; + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create work/{}: {}", stage, e))?; write_file_if_missing(&dir.join(".gitkeep"), "")?; } @@ -464,7 +463,14 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> { } let add_output = std::process::Command::new("git") - .args(["add", ".storkit", "script", ".gitignore", "CLAUDE.md", ".claude"]) + .args([ + "add", + ".storkit", + "script", + ".gitignore", + "CLAUDE.md", + ".claude", + ]) .current_dir(root) .output() .map_err(|e| format!("Failed to run git add: {}", e))?; @@ -478,7 +484,7 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> { let commit_output = std::process::Command::new("git") .args([ "-c", - "user.email=story-kit@localhost", + "user.email=storkit@localhost", "-c", "user.name=Story Kit", "commit", @@ -526,7 +532,10 @@ pub async fn open_project( { // TRACE:MERGE-DEBUG — remove once root cause is found - crate::slog!("[MERGE-DEBUG] open_project: setting project_root to {:?}", p); + crate::slog!( + "[MERGE-DEBUG] open_project: setting project_root to {:?}", + p + ); let mut root = state.project_root.lock().map_err(|e| e.to_string())?; *root = Some(p); } @@ -807,12 +816,7 @@ mod tests { let store = make_store(&dir); let state = SessionState::default(); - let result = open_project( - project_dir.to_string_lossy().to_string(), - &state, - &store, - ) - .await; + let result = open_project(project_dir.to_string_lossy().to_string(), &state, &store).await; assert!(result.is_ok()); let root = state.get_project_root().unwrap(); @@ -831,13 +835,9 @@ mod tests { let store = make_store(&dir); let state = SessionState::default(); - open_project( - project_dir.to_string_lossy().to_string(), - &state, - &store, - ) - .await - .unwrap(); + open_project(project_dir.to_string_lossy().to_string(), &state, &store) + .await + .unwrap(); let mcp_path = project_dir.join(".mcp.json"); assert!( @@ -898,13 +898,9 @@ mod tests { let store = make_store(&dir); let state = SessionState::default(); - open_project( - project_dir.to_string_lossy().to_string(), - &state, - &store, - ) - .await - .unwrap(); + open_project(project_dir.to_string_lossy().to_string(), &state, &store) + .await + .unwrap(); let projects = get_known_projects(&store).unwrap(); assert_eq!(projects.len(), 1); @@ -978,7 +974,9 @@ mod tests { let dir = tempdir().unwrap(); let file = dir.path().join("sub").join("output.txt"); - write_file_impl(file.clone(), "content".to_string()).await.unwrap(); + write_file_impl(file.clone(), "content".to_string()) + .await + .unwrap(); assert_eq!(fs::read_to_string(&file).unwrap(), "content"); } @@ -1089,7 +1087,14 @@ mod tests { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let stages = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"]; + let stages = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; for stage in &stages { let path = dir.path().join(".storkit/work").join(stage); assert!(path.is_dir(), "work/{} should be a directory", stage); @@ -1106,8 +1111,7 @@ mod tests { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let content = - fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap(); + let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap(); assert!(content.contains("[[agent]]")); assert!(content.contains("stage = \"coder\"")); assert!(content.contains("stage = \"qa\"")); @@ -1120,9 +1124,8 @@ mod tests { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let content = - fs::read_to_string(dir.path().join(".storkit/specs/00_CONTEXT.md")).unwrap(); - assert!(content.contains("")); + let content = fs::read_to_string(dir.path().join(".storkit/specs/00_CONTEXT.md")).unwrap(); + assert!(content.contains("")); assert!(content.contains("## High-Level Goal")); assert!(content.contains("## Core Features")); assert!(content.contains("## Domain Definition")); @@ -1137,9 +1140,8 @@ mod tests { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let content = - fs::read_to_string(dir.path().join(".storkit/specs/tech/STACK.md")).unwrap(); - assert!(content.contains("")); + let content = fs::read_to_string(dir.path().join(".storkit/specs/tech/STACK.md")).unwrap(); + assert!(content.contains("")); assert!(content.contains("## Core Stack")); assert!(content.contains("## Coding Standards")); assert!(content.contains("## Quality Gates")); @@ -1183,10 +1185,8 @@ mod tests { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let readme_content = - fs::read_to_string(dir.path().join(".storkit/README.md")).unwrap(); - let toml_content = - fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap(); + let readme_content = fs::read_to_string(dir.path().join(".storkit/README.md")).unwrap(); + let toml_content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap(); // Run again — must not change content or add duplicate .gitignore entries scaffold_story_kit(dir.path()).unwrap(); @@ -1207,8 +1207,7 @@ mod tests { .filter(|l| l.trim() == "worktrees/") .count(); assert_eq!( - count, - 1, + count, 1, ".storkit/.gitignore should not have duplicate entries" ); } @@ -1249,8 +1248,7 @@ mod tests { let log = String::from_utf8_lossy(&log_output.stdout); let commit_count = log.lines().count(); assert_eq!( - commit_count, - 1, + commit_count, 1, "scaffold should not create a commit in an existing git repo" ); } @@ -1261,15 +1259,14 @@ mod tests { scaffold_story_kit(dir.path()).unwrap(); // .storkit/.gitignore must contain relative patterns for files under .storkit/ - let sk_content = - fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap(); + let sk_content = fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap(); assert!(sk_content.contains("worktrees/")); assert!(sk_content.contains("merge_workspace/")); assert!(sk_content.contains("coverage/")); // Must NOT contain absolute .storkit/ prefixed paths assert!(!sk_content.contains(".storkit/")); - // Root .gitignore must contain root-level story-kit entries + // Root .gitignore must contain root-level storkit entries let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); assert!(root_content.contains(".storkit_port")); assert!(root_content.contains("store.json")); @@ -1292,17 +1289,10 @@ mod tests { scaffold_story_kit(dir.path()).unwrap(); - let content = - fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap(); - let worktrees_count = content - .lines() - .filter(|l| l.trim() == "worktrees/") - .count(); + let content = fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap(); + let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count(); assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated"); - let coverage_count = content - .lines() - .filter(|l| l.trim() == "coverage/") - .count(); + let coverage_count = content.lines().filter(|l| l.trim() == "coverage/").count(); assert_eq!(coverage_count, 1, "coverage/ should not be duplicated"); // The missing entry must have been added assert!(content.contains("merge_workspace/")); @@ -1316,11 +1306,14 @@ mod tests { scaffold_story_kit(dir.path()).unwrap(); let claude_md = dir.path().join("CLAUDE.md"); - assert!(claude_md.exists(), "CLAUDE.md should be created at project root"); + assert!( + claude_md.exists(), + "CLAUDE.md should be created at project root" + ); let content = fs::read_to_string(&claude_md).unwrap(); assert!( - content.contains(""), + content.contains(""), "CLAUDE.md should contain the scaffold sentinel" ); assert!( @@ -1358,13 +1351,9 @@ mod tests { let store = make_store(&dir); let state = SessionState::default(); - open_project( - project_dir.to_string_lossy().to_string(), - &state, - &store, - ) - .await - .unwrap(); + open_project(project_dir.to_string_lossy().to_string(), &state, &store) + .await + .unwrap(); // .storkit/ should have been created automatically assert!(project_dir.join(".storkit").is_dir()); @@ -1381,13 +1370,9 @@ mod tests { let store = make_store(&dir); let state = SessionState::default(); - open_project( - project_dir.to_string_lossy().to_string(), - &state, - &store, - ) - .await - .unwrap(); + open_project(project_dir.to_string_lossy().to_string(), &state, &store) + .await + .unwrap(); // Existing .storkit/ content should not be overwritten assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); @@ -1451,7 +1436,11 @@ mod tests { #[test] fn detect_cargo_toml_generates_rust_component() { let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\n").unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"test\"\n", + ) + .unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"server\"")); @@ -1482,7 +1471,11 @@ mod tests { #[test] fn detect_pyproject_toml_generates_python_component() { let dir = tempdir().unwrap(); - fs::write(dir.path().join("pyproject.toml"), "[project]\nname = \"test\"\n").unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"test\"\n", + ) + .unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"python\"")); @@ -1512,7 +1505,11 @@ mod tests { #[test] fn detect_gemfile_generates_ruby_component() { let dir = tempdir().unwrap(); - fs::write(dir.path().join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap(); + fs::write( + dir.path().join("Gemfile"), + "source \"https://rubygems.org\"\n", + ) + .unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"ruby\"")); @@ -1522,7 +1519,11 @@ mod tests { #[test] fn detect_multiple_markers_generates_multiple_components() { let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"server\"\n").unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); fs::write(dir.path().join("package.json"), "{}").unwrap(); let toml = detect_components_toml(dir.path()); @@ -1565,12 +1566,15 @@ mod tests { fn scaffold_project_toml_contains_detected_components() { let dir = tempdir().unwrap(); // Place a Cargo.toml in the project root before scaffolding - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"myapp\"\n").unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"myapp\"\n", + ) + .unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let content = - fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap(); + let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap(); assert!( content.contains("[[component]]"), "project.toml should contain a component entry" @@ -1590,8 +1594,7 @@ mod tests { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let content = - fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap(); + let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap(); assert!( content.contains("[[component]]"), "project.toml should always have at least one component" diff --git a/server/src/io/onboarding.rs b/server/src/io/onboarding.rs index bf30df9..fd50099 100644 --- a/server/src/io/onboarding.rs +++ b/server/src/io/onboarding.rs @@ -4,7 +4,7 @@ use std::path::Path; /// Only untouched templates contain this marker — real project content /// will never include it, so it avoids false positives when the project /// itself is an "Agentic AI Code Assistant". -const TEMPLATE_SENTINEL: &str = ""; +const TEMPLATE_SENTINEL: &str = ""; /// Marker found in the default `script/test` scaffold output. const TEMPLATE_MARKER_SCRIPT: &str = "No tests configured"; @@ -107,12 +107,12 @@ mod tests { // Write content that includes the scaffold sentinel fs::write( root.join(".storkit/specs/00_CONTEXT.md"), - "\n# Project Context\nPlaceholder...", + "\n# Project Context\nPlaceholder...", ) .unwrap(); fs::write( root.join(".storkit/specs/tech/STACK.md"), - "\n# Tech Stack\nPlaceholder...", + "\n# Tech Stack\nPlaceholder...", ) .unwrap(); @@ -229,11 +229,7 @@ mod tests { let dir = TempDir::new().unwrap(); let root = setup_project(&dir); - fs::write( - root.join(".storkit/project.toml"), - "# empty config\n", - ) - .unwrap(); + fs::write(root.join(".storkit/project.toml"), "# empty config\n").unwrap(); let status = check_onboarding_status(&root); assert!(status.needs_project_toml); @@ -301,7 +297,7 @@ mod tests { // Context still has sentinel fs::write( root.join(".storkit/specs/00_CONTEXT.md"), - "\n# Project Context\nPlaceholder...", + "\n# Project Context\nPlaceholder...", ) .unwrap(); // Stack is customised (no sentinel) diff --git a/server/src/io/watcher.rs b/server/src/io/watcher.rs index 4977cee..fb4ee17 100644 --- a/server/src/io/watcher.rs +++ b/server/src/io/watcher.rs @@ -78,12 +78,12 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool { /// Map a pipeline directory name to a (action, commit-message-prefix) pair. fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> { let (action, prefix) = match stage { - "1_backlog" => ("create", format!("story-kit: create {item_id}")), - "2_current" => ("start", format!("story-kit: start {item_id}")), - "3_qa" => ("qa", format!("story-kit: queue {item_id} for QA")), - "4_merge" => ("merge", format!("story-kit: queue {item_id} for merge")), - "5_done" => ("done", format!("story-kit: done {item_id}")), - "6_archived" => ("accept", format!("story-kit: accept {item_id}")), + "1_backlog" => ("create", format!("storkit: create {item_id}")), + "2_current" => ("start", format!("storkit: start {item_id}")), + "3_qa" => ("qa", format!("storkit: queue {item_id} for QA")), + "4_merge" => ("merge", format!("storkit: queue {item_id} for merge")), + "5_done" => ("done", format!("storkit: done {item_id}")), + "6_archived" => ("accept", format!("storkit: accept {item_id}")), _ => return None, }; Some((action, prefix)) @@ -97,10 +97,7 @@ fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> /// auto-committed to master by the watcher. fn stage_for_path(path: &Path) -> Option { // Reject any path that passes through the worktrees directory. - if path - .components() - .any(|c| c.as_os_str() == "worktrees") - { + if path.components().any(|c| c.as_os_str() == "worktrees") { return None; } @@ -111,8 +108,11 @@ fn stage_for_path(path: &Path) -> Option { .parent() .and_then(|p| p.file_name()) .and_then(|n| n.to_str())?; - matches!(stage, "1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived") - .then(|| stage.to_string()) + matches!( + stage, + "1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived" + ) + .then(|| stage.to_string()) } /// Stage all changes in the work directory and commit with the given message. @@ -190,7 +190,10 @@ fn flush_pending( // Pick the commit message from the first addition (the meaningful side of a move). // If there are only deletions, use a generic message. let (action, item_id, commit_msg) = if let Some((path, stage)) = additions.first() { - let item = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"); + let item = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); if let Some((act, msg)) = stage_metadata(stage, item) { (act, item.to_string(), msg) } else { @@ -201,8 +204,15 @@ fn flush_pending( let Some((path, _)) = pending.iter().next() else { return; }; - let item = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"); - ("remove", item.to_string(), format!("story-kit: remove {item}")) + let item = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + ( + "remove", + item.to_string(), + format!("storkit: remove {item}"), + ) }; // Strip stale merge_failure front matter from any story that has left 4_merge/. @@ -210,7 +220,10 @@ fn flush_pending( if *stage != "4_merge" && let Err(e) = clear_front_matter_field(path, "merge_failure") { - slog!("[watcher] Warning: could not clear merge_failure from {}: {e}", path.display()); + slog!( + "[watcher] Warning: could not clear merge_failure from {}: {e}", + path.display() + ); } } @@ -261,7 +274,6 @@ fn flush_pending( /// Called periodically from the watcher thread. File moves will trigger normal /// watcher events, which `flush_pending` will commit and broadcast. fn sweep_done_to_archived(work_dir: &Path, git_root: &Path, done_retention: Duration) { - // ── Part 1: promote old items from 5_done/ → 6_archived/ ─────────────── let done_dir = work_dir.join("5_done"); if done_dir.exists() { @@ -281,9 +293,7 @@ fn sweep_done_to_archived(work_dir: &Path, git_root: &Path, done_retention: Dura Err(_) => continue, }; - let age = SystemTime::now() - .duration_since(mtime) - .unwrap_or_default(); + let age = SystemTime::now().duration_since(mtime).unwrap_or_default(); if age >= done_retention { if let Err(e) = std::fs::create_dir_all(&archived_dir) { @@ -372,7 +382,10 @@ pub fn start_watcher( if config_file.exists() && let Err(e) = watcher.watch(&config_file, RecursiveMode::NonRecursive) { - slog!("[watcher] failed to watch config file {}: {e}", config_file.display()); + slog!( + "[watcher] failed to watch config file {}: {e}", + config_file.display() + ); } slog!("[watcher] watching {}", work_dir.display()); @@ -453,13 +466,10 @@ pub fn start_watcher( // Hot-reload sweep config from project.toml. match ProjectConfig::load(&git_root) { Ok(cfg) => { - let new_sweep = - Duration::from_secs(cfg.watcher.sweep_interval_secs); + let new_sweep = Duration::from_secs(cfg.watcher.sweep_interval_secs); let new_retention = Duration::from_secs(cfg.watcher.done_retention_secs); - if new_sweep != sweep_interval - || new_retention != done_retention - { + if new_sweep != sweep_interval || new_retention != done_retention { slog!( "[watcher] hot-reload: sweep_interval={}s done_retention={}s", cfg.watcher.sweep_interval_secs, @@ -535,14 +545,14 @@ mod tests { let tmp = TempDir::new().unwrap(); init_git_repo(tmp.path()); let stage_dir = make_stage_dir(tmp.path(), "2_current"); - fs::write( - stage_dir.join("42_story_foo.md"), - "---\nname: test\n---\n", - ) - .unwrap(); + fs::write(stage_dir.join("42_story_foo.md"), "---\nname: test\n---\n").unwrap(); - let result = git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo"); - assert_eq!(result, Ok(true), "should return Ok(true) when a commit was made"); + let result = git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo"); + assert_eq!( + result, + Ok(true), + "should return Ok(true) when a commit was made" + ); } #[test] @@ -550,17 +560,13 @@ mod tests { let tmp = TempDir::new().unwrap(); init_git_repo(tmp.path()); let stage_dir = make_stage_dir(tmp.path(), "2_current"); - fs::write( - stage_dir.join("42_story_foo.md"), - "---\nname: test\n---\n", - ) - .unwrap(); + fs::write(stage_dir.join("42_story_foo.md"), "---\nname: test\n---\n").unwrap(); // First commit — should succeed. - git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo").unwrap(); + git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo").unwrap(); // Second call with no changes — should return Ok(false). - let result = git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo"); + let result = git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo"); assert_eq!( result, Ok(false), @@ -595,7 +601,7 @@ mod tests { assert_eq!(stage, "1_backlog"); assert_eq!(item_id, "42_story_foo"); assert_eq!(action, "create"); - assert_eq!(commit_msg, "story-kit: create 42_story_foo"); + assert_eq!(commit_msg, "storkit: create 42_story_foo"); } other => panic!("unexpected event: {other:?}"), } @@ -608,7 +614,7 @@ mod tests { .expect("git log"); let log_msg = String::from_utf8_lossy(&log.stdout); assert!( - log_msg.contains("story-kit: create 42_story_foo"), + log_msg.contains("storkit: create 42_story_foo"), "terminal stage should produce a git commit" ); } @@ -639,7 +645,7 @@ mod tests { assert_eq!(stage, "2_current"); assert_eq!(item_id, "42_story_foo"); assert_eq!(action, "start"); - assert_eq!(commit_msg, "story-kit: start 42_story_foo"); + assert_eq!(commit_msg, "storkit: start 42_story_foo"); } other => panic!("unexpected event: {other:?}"), } @@ -652,7 +658,7 @@ mod tests { .expect("git log"); let log_msg = String::from_utf8_lossy(&log.stdout); assert!( - !log_msg.contains("story-kit:"), + !log_msg.contains("storkit:"), "intermediate stage should NOT produce a git commit" ); } @@ -660,11 +666,11 @@ mod tests { #[test] fn flush_pending_broadcasts_for_all_pipeline_stages() { let stages = [ - ("1_backlog", "create", "story-kit: create 10_story_x"), - ("3_qa", "qa", "story-kit: queue 10_story_x for QA"), - ("4_merge", "merge", "story-kit: queue 10_story_x for merge"), - ("5_done", "done", "story-kit: done 10_story_x"), - ("6_archived", "accept", "story-kit: accept 10_story_x"), + ("1_backlog", "create", "storkit: create 10_story_x"), + ("3_qa", "qa", "storkit: queue 10_story_x for QA"), + ("4_merge", "merge", "storkit: queue 10_story_x for merge"), + ("5_done", "done", "storkit: done 10_story_x"), + ("6_archived", "accept", "storkit: accept 10_story_x"), ]; for (stage, expected_action, expected_msg) in stages { @@ -714,7 +720,9 @@ mod tests { flush_pending(&pending, tmp.path(), &tx); // Even when nothing was committed (file never existed), an event is broadcast. - let evt = rx.try_recv().expect("expected a broadcast event for deletion"); + let evt = rx + .try_recv() + .expect("expected a broadcast event for deletion"); match evt { WatcherEvent::WorkItem { action, item_id, .. @@ -882,7 +890,10 @@ mod tests { flush_pending(&pending, tmp.path(), &tx); let contents = fs::read_to_string(&story_path).unwrap(); - assert_eq!(contents, original, "file without merge_failure should be unchanged"); + assert_eq!( + contents, original, + "file without merge_failure should be unchanged" + ); } // ── stage_for_path (additional edge cases) ──────────────────────────────── @@ -929,7 +940,9 @@ mod tests { // A path that only contains the word "worktrees" as part of a longer // segment (not an exact component) must NOT be filtered out. assert_eq!( - stage_for_path(&PathBuf::from("/proj/.storkit/work/2_current/not_worktrees_story.md")), + stage_for_path(&PathBuf::from( + "/proj/.storkit/work/2_current/not_worktrees_story.md" + )), Some("2_current".to_string()), ); } @@ -952,15 +965,15 @@ mod tests { fn stage_metadata_returns_correct_actions() { let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap(); assert_eq!(action, "start"); - assert_eq!(msg, "story-kit: start 42_story_foo"); + assert_eq!(msg, "storkit: start 42_story_foo"); let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap(); assert_eq!(action, "done"); - assert_eq!(msg, "story-kit: done 42_story_foo"); + assert_eq!(msg, "storkit: done 42_story_foo"); let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap(); assert_eq!(action, "accept"); - assert_eq!(msg, "story-kit: accept 42_story_foo"); + assert_eq!(msg, "storkit: accept 42_story_foo"); assert!(stage_metadata("unknown", "id").is_none()); } @@ -976,9 +989,8 @@ mod tests { fn is_config_file_rejects_worktree_copies() { let git_root = PathBuf::from("/proj"); // project.toml inside a worktree must NOT be treated as the root config. - let worktree_config = PathBuf::from( - "/proj/.storkit/worktrees/42_story_foo/.storkit/project.toml", - ); + let worktree_config = + PathBuf::from("/proj/.storkit/worktrees/42_story_foo/.storkit/project.toml"); assert!(!is_config_file(&worktree_config, &git_root)); } @@ -1019,14 +1031,16 @@ mod tests { let past = SystemTime::now() .checked_sub(Duration::from_secs(5 * 60 * 60)) .unwrap(); - filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)) - .unwrap(); + filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap(); let retention = Duration::from_secs(4 * 60 * 60); // tmp.path() has no worktrees dir — prune_worktree_sync is a no-op. sweep_done_to_archived(&work_dir, tmp.path(), retention); - assert!(!story_path.exists(), "old item should be moved out of 5_done/"); + assert!( + !story_path.exists(), + "old item should be moved out of 5_done/" + ); assert!( archived_dir.join("10_story_old.md").exists(), "old item should appear in 6_archived/" @@ -1064,8 +1078,7 @@ mod tests { let past = SystemTime::now() .checked_sub(Duration::from_secs(120)) .unwrap(); - filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)) - .unwrap(); + filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap(); // With a 1-minute retention, the 2-minute-old file should be swept. sweep_done_to_archived(&work_dir, tmp.path(), Duration::from_secs(60)); @@ -1093,8 +1106,7 @@ mod tests { let past = SystemTime::now() .checked_sub(Duration::from_secs(30)) .unwrap(); - filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)) - .unwrap(); + filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap(); // With a 1-minute retention, the 30-second-old file should stay. sweep_done_to_archived(&work_dir, tmp.path(), Duration::from_secs(60)); @@ -1138,8 +1150,7 @@ mod tests { let past = SystemTime::now() .checked_sub(Duration::from_secs(5 * 60 * 60)) .unwrap(); - filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)) - .unwrap(); + filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap(); // Create a real git worktree for this story. let wt_path = crate::worktree::worktree_path(&git_root, story_id); @@ -1151,16 +1162,19 @@ mod tests { sweep_done_to_archived(&work_dir, &git_root, retention); // Story must be archived. + assert!(!story_path.exists(), "story should be moved out of 5_done/"); assert!( - !story_path.exists(), - "story should be moved out of 5_done/" - ); - assert!( - work_dir.join("6_archived").join(format!("{story_id}.md")).exists(), + work_dir + .join("6_archived") + .join(format!("{story_id}.md")) + .exists(), "story should appear in 6_archived/" ); // Worktree must be removed. - assert!(!wt_path.exists(), "worktree should be removed after archiving"); + assert!( + !wt_path.exists(), + "worktree should be removed after archiving" + ); } #[test] @@ -1190,9 +1204,15 @@ mod tests { sweep_done_to_archived(&work_dir, &git_root, retention); // Stale worktree should be pruned. - assert!(!wt_path.exists(), "stale worktree should be pruned by sweep"); + assert!( + !wt_path.exists(), + "stale worktree should be pruned by sweep" + ); // Story file must remain untouched. - assert!(story_path.exists(), "archived story file must not be removed"); + assert!( + story_path.exists(), + "archived story file must not be removed" + ); } #[test] @@ -1214,8 +1234,7 @@ mod tests { let past = SystemTime::now() .checked_sub(Duration::from_secs(5 * 60 * 60)) .unwrap(); - filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)) - .unwrap(); + filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap(); // Create a plain directory at the expected worktree path — not a real // git worktree, so `git worktree remove` will fail. @@ -1231,7 +1250,10 @@ mod tests { "story should be archived even when worktree removal fails" ); assert!( - work_dir.join("6_archived").join(format!("{story_id}.md")).exists(), + work_dir + .join("6_archived") + .join(format!("{story_id}.md")) + .exists(), "story should appear in 6_archived/ despite worktree removal failure" ); } diff --git a/server/src/llm/providers/claude_code.rs b/server/src/llm/providers/claude_code.rs index 7721e3f..eeaed41 100644 --- a/server/src/llm/providers/claude_code.rs +++ b/server/src/llm/providers/claude_code.rs @@ -185,10 +185,10 @@ fn run_pty_session( // are emitted and tool-start activity signals never fire. cmd.arg("--include-partial-messages"); // Delegate permission decisions to the MCP prompt_permission tool. - // Claude Code will call this tool via the story-kit MCP server when + // Claude Code will call this tool via the storkit MCP server when // a tool requires user approval, instead of using PTY stdin/stdout. cmd.arg("--permission-prompt-tool"); - cmd.arg("mcp__story-kit__prompt_permission"); + cmd.arg("mcp__storkit__prompt_permission"); // Note: --system is not a valid Claude Code CLI flag. System-level // instructions (like bot name) are prepended to the user prompt instead. cmd.cwd(cwd); @@ -198,7 +198,7 @@ fn run_pty_session( cmd.env("CLAUDECODE", ""); slog!( - "[pty-debug] Spawning: claude -p \"{}\" {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__story-kit__prompt_permission", + "[pty-debug] Spawning: claude -p \"{}\" {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__storkit__prompt_permission", user_message, resume_session_id .map(|s| format!("--resume {s}")) @@ -210,10 +210,7 @@ fn run_pty_session( .spawn_command(cmd) .map_err(|e| format!("Failed to spawn claude: {e}"))?; - slog!( - "[pty-debug] Process spawned, pid: {:?}", - child.process_id() - ); + slog!("[pty-debug] Process spawned, pid: {:?}", child.process_id()); drop(pair.slave); let reader = pair @@ -274,7 +271,14 @@ fn run_pty_session( // Try to parse as JSON if let Ok(json) = serde_json::from_str::(trimmed) - && process_json_event(&json, &token_tx, &thinking_tx, &activity_tx, &msg_tx, &mut sid_tx) + && process_json_event( + &json, + &token_tx, + &thinking_tx, + &activity_tx, + &msg_tx, + &mut sid_tx, + ) { got_result = true; } @@ -462,10 +466,7 @@ fn parse_assistant_message( /// /// Claude Code injects tool results into the conversation as `user` role /// messages. Each `tool_result` block becomes a separate `Message { role: Tool }`. -fn parse_tool_results( - content: &[serde_json::Value], - msg_tx: &std::sync::mpsc::Sender, -) { +fn parse_tool_results(content: &[serde_json::Value], msg_tx: &std::sync::mpsc::Sender) { for block in content { if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") { continue; @@ -484,7 +485,9 @@ fn parse_tool_results( arr.iter() .filter_map(|b| { if b.get("type").and_then(|t| t.as_str()) == Some("text") { - b.get("text").and_then(|t| t.as_str()).map(|s| s.to_string()) + b.get("text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) } else { None } @@ -537,9 +540,7 @@ fn handle_stream_event( } } "thinking_delta" => { - if let Some(thinking) = - delta.get("thinking").and_then(|t| t.as_str()) - { + if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) { let _ = thinking_tx.send(thinking.to_string()); } } @@ -566,9 +567,7 @@ mod tests { use super::*; use serde_json::json; - fn collect_messages( - f: impl Fn(&std::sync::mpsc::Sender), - ) -> Vec { + fn collect_messages(f: impl Fn(&std::sync::mpsc::Sender)) -> Vec { let (tx, rx) = std::sync::mpsc::channel(); f(&tx); drop(tx); @@ -755,7 +754,10 @@ mod tests { } v }; - assert!(tokens.is_empty(), "thinking token leaked into token channel"); + assert!( + tokens.is_empty(), + "thinking token leaked into token channel" + ); // thinking token must appear in the dedicated thinking channel, without prefix let thinking: Vec = { let mut v = vec![]; @@ -897,7 +899,9 @@ mod tests { let (thi_tx, thi_rx) = tokio::sync::mpsc::unbounded_channel(); let (act_tx, act_rx) = tokio::sync::mpsc::unbounded_channel(); let (msg_tx, msg_rx) = std::sync::mpsc::channel(); - (tok_tx, tok_rx, thi_tx, thi_rx, act_tx, act_rx, msg_tx, msg_rx) + ( + tok_tx, tok_rx, thi_tx, thi_rx, act_tx, act_rx, msg_tx, msg_rx, + ) } #[test] @@ -906,7 +910,14 @@ mod tests { let (sid_tx, _sid_rx) = tokio::sync::oneshot::channel::(); let mut sid_tx_opt = Some(sid_tx); let json = json!({"type": "result", "subtype": "success"}); - assert!(process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx_opt)); + assert!(process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx_opt + )); } #[test] @@ -914,7 +925,14 @@ mod tests { let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, _act_rx, msg_tx, _msg_rx) = make_channels(); let mut sid_tx = None::>; let json = json!({"type": "system", "subtype": "init", "apiKeySource": "env"}); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); } #[test] @@ -922,7 +940,14 @@ mod tests { let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, _act_rx, msg_tx, _msg_rx) = make_channels(); let mut sid_tx = None::>; let json = json!({"type": "rate_limit_event"}); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); } #[test] @@ -930,7 +955,14 @@ mod tests { let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, _act_rx, msg_tx, _msg_rx) = make_channels(); let mut sid_tx = None::>; let json = json!({"type": "some_future_event"}); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); } #[test] @@ -938,7 +970,14 @@ mod tests { let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, _act_rx, msg_tx, _msg_rx) = make_channels(); let mut sid_tx = None::>; let json = json!({"content": "no type field"}); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); } #[test] @@ -967,7 +1006,8 @@ mod tests { #[test] fn process_json_event_stream_event_forwards_token() { - let (tok_tx, mut tok_rx, thi_tx, _thi_rx, act_tx, _act_rx, msg_tx, _msg_rx) = make_channels(); + let (tok_tx, mut tok_rx, thi_tx, _thi_rx, act_tx, _act_rx, msg_tx, _msg_rx) = + make_channels(); let mut sid_tx = None::>; let json = json!({ "type": "stream_event", @@ -977,7 +1017,14 @@ mod tests { "delta": {"type": "text_delta", "text": "word"} } }); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(tok_tx); let tokens: Vec = { let mut v = vec![]; @@ -993,7 +1040,8 @@ mod tests { fn process_json_event_stream_event_tool_use_fires_activity() { // This is the primary activity path: stream_event wrapping content_block_start // with a tool_use block. Requires --include-partial-messages to be enabled. - let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = make_channels(); + let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = + make_channels(); let mut sid_tx = None::>; let json = json!({ "type": "stream_event", @@ -1004,7 +1052,14 @@ mod tests { "content_block": {"type": "tool_use", "id": "toolu_abc", "name": "Bash", "input": {}} } }); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(act_tx); let activities: Vec = { let mut v = vec![]; @@ -1018,7 +1073,8 @@ mod tests { #[test] fn process_json_event_assistant_with_tool_use_fires_activity() { - let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = make_channels(); + let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = + make_channels(); let mut sid_tx = None::>; let json = json!({ "type": "assistant", @@ -1029,7 +1085,14 @@ mod tests { ] } }); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(act_tx); let activities: Vec = { let mut v = vec![]; @@ -1043,7 +1106,8 @@ mod tests { #[test] fn process_json_event_assistant_with_multiple_tool_uses_fires_all_activities() { - let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = make_channels(); + let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = + make_channels(); let mut sid_tx = None::>; let json = json!({ "type": "assistant", @@ -1054,7 +1118,14 @@ mod tests { ] } }); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(act_tx); let activities: Vec = { let mut v = vec![]; @@ -1068,7 +1139,8 @@ mod tests { #[test] fn process_json_event_assistant_text_only_no_activity() { - let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = make_channels(); + let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = + make_channels(); let mut sid_tx = None::>; let json = json!({ "type": "assistant", @@ -1076,7 +1148,14 @@ mod tests { "content": [{"type": "text", "text": "Just text, no tools."}] } }); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(act_tx); let activities: Vec = { let mut v = vec![]; @@ -1098,7 +1177,14 @@ mod tests { "content": [{"type": "text", "text": "Hi!"}] } }); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(msg_tx); let msgs: Vec = msg_rx.try_iter().collect(); assert_eq!(msgs.len(), 1); @@ -1115,7 +1201,14 @@ mod tests { "content": [{"type": "tool_result", "tool_use_id": "tid1", "content": "done"}] } }); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(msg_tx); let msgs: Vec = msg_rx.try_iter().collect(); assert_eq!(msgs.len(), 1); @@ -1131,7 +1224,14 @@ mod tests { "type": "assistant", "message": {"content": "not an array"} }); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(msg_tx); let msgs: Vec = msg_rx.try_iter().collect(); assert!(msgs.is_empty()); @@ -1142,7 +1242,14 @@ mod tests { let (tok_tx, _tok_rx, thi_tx, _thi_rx, act_tx, _act_rx, msg_tx, msg_rx) = make_channels(); let mut sid_tx = None::>; let json = json!({"type": "user", "message": {"content": null}}); - assert!(!process_json_event(&json, &tok_tx, &thi_tx, &act_tx, &msg_tx, &mut sid_tx)); + assert!(!process_json_event( + &json, + &tok_tx, + &thi_tx, + &act_tx, + &msg_tx, + &mut sid_tx + )); drop(msg_tx); let msgs: Vec = msg_rx.try_iter().collect(); assert!(msgs.is_empty()); diff --git a/server/src/main.rs b/server/src/main.rs index 1a8495d..22cddc6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -10,12 +10,12 @@ mod io; mod llm; pub mod log_buffer; mod matrix; +pub mod slack; mod state; mod store; pub mod transport; -mod workflow; -pub mod slack; pub mod whatsapp; +mod workflow; mod worktree; use crate::agents::AgentPool; @@ -96,7 +96,10 @@ async fn main() -> Result<(), std::io::Error> { } else { // No .storkit/ found — fall back to cwd so existing behaviour is preserved. // TRACE:MERGE-DEBUG — remove once root cause is found - slog!("[MERGE-DEBUG] main: no .storkit/ found, falling back to cwd {:?}", cwd); + slog!( + "[MERGE-DEBUG] main: no .storkit/ found, falling back to cwd {:?}", + cwd + ); *app_state.project_root.lock().unwrap() = Some(cwd.clone()); } } @@ -125,12 +128,7 @@ async fn main() -> Result<(), std::io::Error> { let watcher_config = config::ProjectConfig::load(root) .map(|c| c.watcher) .unwrap_or_default(); - io::watcher::start_watcher( - work_dir, - root.clone(), - watcher_tx.clone(), - watcher_config, - ); + io::watcher::start_watcher(work_dir, root.clone(), watcher_tx.clone(), watcher_config); } } @@ -139,8 +137,7 @@ async fn main() -> Result<(), std::io::Error> { { let watcher_auto_rx = watcher_tx.subscribe(); let watcher_auto_agents = Arc::clone(&agents); - let watcher_auto_root: Option = - app_state.project_root.lock().unwrap().clone(); + let watcher_auto_root: Option = app_state.project_root.lock().unwrap().clone(); if let Some(root) = watcher_auto_root { tokio::spawn(async move { let mut rx = watcher_auto_rx; @@ -152,9 +149,7 @@ async fn main() -> Result<(), std::io::Error> { "[auto-assign] Watcher detected work item in {stage}/; \ triggering auto-assign." ); - watcher_auto_agents - .auto_assign_available_work(&root) - .await; + watcher_auto_agents.auto_assign_available_work(&root).await; } } }); @@ -162,8 +157,7 @@ async fn main() -> Result<(), std::io::Error> { } // Reconciliation progress channel: startup reconciliation → WebSocket clients. - let (reconciliation_tx, _) = - broadcast::channel::(64); + let (reconciliation_tx, _) = broadcast::channel::(64); // Permission channel: MCP prompt_permission → WebSocket handler. let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -262,11 +256,15 @@ async fn main() -> Result<(), std::io::Error> { let app = build_routes(ctx, whatsapp_ctx, slack_ctx); - // Optional Matrix bot: connect to the homeserver and start listening for // messages if `.storkit/bot.toml` is present and enabled. if let Some(ref root) = startup_root { - matrix::spawn_bot(root, watcher_tx_for_bot, perm_rx_for_bot, Arc::clone(&startup_agents)); + matrix::spawn_bot( + root, + watcher_tx_for_bot, + perm_rx_for_bot, + Arc::clone(&startup_agents), + ); } // On startup: @@ -275,15 +273,11 @@ async fn main() -> Result<(), std::io::Error> { // 2. Auto-assign free agents to remaining unassigned work in the pipeline. if let Some(root) = startup_root { tokio::spawn(async move { - slog!( - "[startup] Reconciling completed worktrees from previous session." - ); + slog!("[startup] Reconciling completed worktrees from previous session."); startup_agents .reconcile_on_startup(&root, &startup_reconciliation_tx) .await; - slog!( - "[auto-assign] Scanning pipeline stages for unassigned work." - ); + slog!("[auto-assign] Scanning pipeline stages for unassigned work."); startup_agents.auto_assign_available_work(&root).await; }); } @@ -292,7 +286,7 @@ async fn main() -> Result<(), std::io::Error> { println!( "\x1b[95;1m ____ _ _ ___ _ \n / ___|| |_ ___ _ __| | _|_ _| |_ \n \\___ \\| __/ _ \\| '__| |/ /| || __|\n ___) | || (_) | | | < | || |_ \n |____/ \\__\\___/|_| |_|\\_\\___|\\__|\n\x1b[0m" ); - println!("STORYKIT_PORT={port}"); + println!("STORKIT_PORT={port}"); println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m"); println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m"); @@ -352,7 +346,9 @@ name = "coder" let args = vec!["/some/absolute/path".to_string()]; let result = parse_project_path_arg(&args, &cwd).unwrap(); // Absolute path returned as-is (canonicalize may fail, fallback used) - assert!(result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")); + assert!( + result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path") + ); } #[test] diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index ce55254..d6304fd 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -735,7 +735,14 @@ async fn on_room_message( let room_id_str = incoming_room_id.to_string(); let is_ambient = ctx.ambient_rooms.lock().unwrap().contains(&room_id_str); - if !is_addressed && !is_ambient { + // Always let "ambient on" through — it is the one command that must work + // even when the bot is not mentioned and ambient mode is off, otherwise + // there is no way to re-enable ambient mode without an @-mention. + let is_ambient_on = body + .to_ascii_lowercase() + .contains("ambient on"); + + if !is_addressed && !is_ambient && !is_ambient_on { slog!( "[matrix-bot] Ignoring unaddressed message from {}", ev.sender diff --git a/server/src/matrix/delete.rs b/server/src/matrix/delete.rs index b41c8e0..4b99015 100644 --- a/server/src/matrix/delete.rs +++ b/server/src/matrix/delete.rs @@ -104,9 +104,7 @@ pub async fn handle_delete( let (path, stage, story_id) = match found { Some(f) => f, None => { - return format!( - "No story, bug, or spike with number **{story_number}** found." - ); + return format!("No story, bug, or spike with number **{story_number}** found."); } }; @@ -135,9 +133,7 @@ pub async fn handle_delete( let mut stopped_agents: Vec = Vec::new(); for (sid, agent_name) in &running_agents { if let Err(e) = agents.stop_agent(project_root, sid, agent_name).await { - return format!( - "Failed to stop agent '{agent_name}' for story {story_number}: {e}" - ); + return format!("Failed to stop agent '{agent_name}' for story {story_number}: {e}"); } stopped_agents.push(agent_name.clone()); } @@ -151,7 +147,7 @@ pub async fn handle_delete( } // Commit the deletion to git. - let commit_msg = format!("story-kit: delete {story_id}"); + let commit_msg = format!("storkit: delete {story_id}"); let work_rel = std::path::PathBuf::from(".storkit").join("work"); let _ = std::process::Command::new("git") .args(["add", "-A"]) @@ -171,9 +167,7 @@ pub async fn handle_delete( response.push_str(&format!(" Stopped agent(s): {agent_list}.")); } - crate::slog!( - "[matrix-bot] delete command: removed {story_id} from {stage} (bot={bot_name})" - ); + crate::slog!("[matrix-bot] delete command: removed {story_id} from {stage} (bot={bot_name})"); response } @@ -240,25 +234,45 @@ mod tests { fn extract_with_full_user_id() { let cmd = extract_delete_command("@timmy:home.local delete 42", "Timmy", "@timmy:home.local"); - assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "42".to_string() })); + assert_eq!( + cmd, + Some(DeleteCommand::Delete { + story_number: "42".to_string() + }) + ); } #[test] fn extract_with_display_name() { let cmd = extract_delete_command("Timmy delete 310", "Timmy", "@timmy:home.local"); - assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "310".to_string() })); + assert_eq!( + cmd, + Some(DeleteCommand::Delete { + story_number: "310".to_string() + }) + ); } #[test] fn extract_with_localpart() { let cmd = extract_delete_command("@timmy delete 7", "Timmy", "@timmy:home.local"); - assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "7".to_string() })); + assert_eq!( + cmd, + Some(DeleteCommand::Delete { + story_number: "7".to_string() + }) + ); } #[test] fn extract_case_insensitive_command() { let cmd = extract_delete_command("Timmy DELETE 99", "Timmy", "@timmy:home.local"); - assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "99".to_string() })); + assert_eq!( + cmd, + Some(DeleteCommand::Delete { + story_number: "99".to_string() + }) + ); } #[test] @@ -285,7 +299,12 @@ mod tests { // Without mention prefix the raw text is "delete 42" — cmd is "delete", args "42" // strip_mention returns the full trimmed text when no prefix matches, // so this is a valid delete command addressed to no-one (ambient mode). - assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "42".to_string() })); + assert_eq!( + cmd, + Some(DeleteCommand::Delete { + story_number: "42".to_string() + }) + ); } // -- handle_delete (integration-style, uses temp filesystem) ----------- @@ -295,7 +314,14 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let project_root = tmp.path(); // Create the pipeline directories. - for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] { + for stage in &[ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ] { std::fs::create_dir_all(project_root.join(".storkit").join("work").join(stage)) .unwrap(); } @@ -332,11 +358,7 @@ mod tests { let backlog_dir = project_root.join(".storkit").join("work").join("1_backlog"); std::fs::create_dir_all(&backlog_dir).unwrap(); let story_path = backlog_dir.join("42_story_some_feature.md"); - std::fs::write( - &story_path, - "---\nname: Some Feature\n---\n\n# Story 42\n", - ) - .unwrap(); + std::fs::write(&story_path, "---\nname: Some Feature\n---\n\n# Story 42\n").unwrap(); // Initial commit so git doesn't complain about no commits. std::process::Command::new("git") diff --git a/server/src/worktree.rs b/server/src/worktree.rs index b995f36..52147c5 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -1,5 +1,5 @@ -use crate::slog; use crate::config::ProjectConfig; +use crate::slog; use std::path::{Path, PathBuf}; use std::process::Command; @@ -7,7 +7,7 @@ use std::process::Command; /// at the given port. pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> { let content = format!( - "{{\n \"mcpServers\": {{\n \"story-kit\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" + "{{\n \"mcpServers\": {{\n \"storkit\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" ); std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}")) } @@ -104,15 +104,10 @@ pub async fn create_worktree( }) } -fn create_worktree_sync( - project_root: &Path, - wt_path: &Path, - branch: &str, -) -> Result<(), String> { +fn create_worktree_sync(project_root: &Path, wt_path: &Path, branch: &str) -> Result<(), String> { // Ensure the parent directory exists if let Some(parent) = wt_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Create worktree dir: {e}"))?; + std::fs::create_dir_all(parent).map_err(|e| format!("Create worktree dir: {e}"))?; } // Prune stale worktree references (e.g. directories deleted externally) @@ -129,12 +124,7 @@ fn create_worktree_sync( // Create worktree let output = Command::new("git") - .args([ - "worktree", - "add", - &wt_path.to_string_lossy(), - branch, - ]) + .args(["worktree", "add", &wt_path.to_string_lossy(), branch]) .current_dir(project_root) .output() .map_err(|e| format!("git worktree add: {e}"))?; @@ -238,9 +228,7 @@ pub fn list_worktrees(project_root: &Path) -> Result, Str return Ok(Vec::new()); } let mut entries = Vec::new(); - for entry in - std::fs::read_dir(&worktrees_dir).map_err(|e| format!("list worktrees: {e}"))? - { + for entry in std::fs::read_dir(&worktrees_dir).map_err(|e| format!("list worktrees: {e}"))? { let entry = entry.map_err(|e| format!("list worktrees entry: {e}"))?; let path = entry.path(); if path.is_dir() { @@ -255,19 +243,10 @@ pub fn list_worktrees(project_root: &Path) -> Result, Str Ok(entries) } -fn remove_worktree_sync( - project_root: &Path, - wt_path: &Path, - branch: &str, -) -> Result<(), String> { +fn remove_worktree_sync(project_root: &Path, wt_path: &Path, branch: &str) -> Result<(), String> { // Remove worktree let output = Command::new("git") - .args([ - "worktree", - "remove", - "--force", - &wt_path.to_string_lossy(), - ]) + .args(["worktree", "remove", "--force", &wt_path.to_string_lossy()]) .current_dir(project_root) .output() .map_err(|e| format!("git worktree remove: {e}"))?; @@ -645,7 +624,10 @@ mod tests { .unwrap(); let mcp = fs::read_to_string(info2.path.join(".mcp.json")).unwrap(); - assert!(mcp.contains("3002"), "MCP json should be updated to new port"); + assert!( + mcp.contains("3002"), + "MCP json should be updated to new port" + ); } #[test] @@ -708,9 +690,12 @@ mod tests { .await .unwrap(); - let result = - remove_worktree_by_story_id(&project_root, "88_remove_by_id", &config).await; - assert!(result.is_ok(), "Expected removal to succeed: {:?}", result.err()); + let result = remove_worktree_by_story_id(&project_root, "88_remove_by_id", &config).await; + assert!( + result.is_ok(), + "Expected removal to succeed: {:?}", + result.err() + ); } // ── prune_worktree_sync ────────────────────────────────────────────────── @@ -720,7 +705,11 @@ mod tests { let tmp = TempDir::new().unwrap(); // No worktree directory exists — must return Ok without touching git. let result = prune_worktree_sync(tmp.path(), "42_story_nonexistent"); - assert!(result.is_ok(), "Expected Ok when worktree dir absent: {:?}", result.err()); + assert!( + result.is_ok(), + "Expected Ok when worktree dir absent: {:?}", + result.err() + ); } #[test] @@ -732,11 +721,20 @@ mod tests { let story_id = "55_story_prune_test"; let wt_path = worktree_path(&project_root, story_id); - create_worktree_sync(&project_root, &wt_path, &format!("feature/story-{story_id}")).unwrap(); + create_worktree_sync( + &project_root, + &wt_path, + &format!("feature/story-{story_id}"), + ) + .unwrap(); assert!(wt_path.exists(), "worktree dir should exist before prune"); let result = prune_worktree_sync(&project_root, story_id); - assert!(result.is_ok(), "prune_worktree_sync must return Ok: {:?}", result.err()); + assert!( + result.is_ok(), + "prune_worktree_sync must return Ok: {:?}", + result.err() + ); assert!(!wt_path.exists(), "worktree dir should be gone after prune"); } @@ -810,8 +808,7 @@ mod tests { max_retries: 2, }; // Second call — worktree exists, setup commands fail, must still succeed - let result = - create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await; + let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await; assert!( result.is_ok(), "create_worktree reuse must succeed even if setup commands fail: {:?}", @@ -841,7 +838,9 @@ mod tests { let path = info.path.clone(); assert!(path.exists()); - remove_worktree(&project_root, &info, &config).await.unwrap(); + remove_worktree(&project_root, &info, &config) + .await + .unwrap(); assert!(!path.exists()); } }