Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3595df4d9d | |||
| 5d84100c41 | |||
| dd436ad186 | |||
| b811b9188f | |||
| 9935311c35 | |||
| be0036922a | |||
| 361f9dff0d | |||
| fc160b5c5f | |||
| 9092b8a2c9 | |||
| dfe3d96313 | |||
| bcefa6a25d | |||
| 50bfeddcb5 | |||
| 8e6b8ef338 | |||
| d363eb63e2 | |||
| 422cec370d | |||
| 973b7d6f72 | |||
| 49b78f3642 | |||
| 93576e3f83 | |||
| dd7f71dd87 | |||
| 9a8492c72f | |||
| ac9bdde164 | |||
| 0b2ec64c74 | |||
| fe0a032e8e | |||
| eff8f6a6a6 | |||
| e45eab82f2 | |||
| 310ad365e6 | |||
| 0b50c66caa | |||
| 9feed0f882 | |||
| bb3301c5af | |||
| a2123274a5 | |||
| 3cbbc5387a | |||
| 4e828fbdd1 | |||
| 6d88595e0d | |||
| aa90646edf | |||
| 7235ab7c7c | |||
| a0326dae78 | |||
| 953fce2ca6 | |||
| 5035b84de5 |
+9
-4
@@ -9,16 +9,21 @@
|
|||||||
|
|
||||||
When you start a new session with this project:
|
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:
|
1. **Check Setup Wizard:** Call `wizard_status` to check if project setup is complete. If the wizard is not complete, guide the user through the remaining steps. Important rules for the wizard flow:
|
||||||
|
- **Be conversational.** Don't show tool names, step numbers, or raw wizard output to the user.
|
||||||
|
- **On projects with existing code:** Read the codebase and generate each file, then show the user what you wrote and ask if it looks right.
|
||||||
|
- **On bare projects with no code:** Ask the user what they want to build, what language/framework they plan to use, and generate files from their answers.
|
||||||
|
- Use `wizard_generate` to create content, show it to the user, then call `wizard_confirm` (they approve), `wizard_retry` (they want changes), or `wizard_skip` (they want to skip this step).
|
||||||
|
2. **Check for MCP Tools:** Read `.mcp.json` to discover the MCP server endpoint. Then list available tools by calling:
|
||||||
```bash
|
```bash
|
||||||
curl -s "$(jq -r '.mcpServers["storkit"].url' .mcp.json)" \
|
curl -s "$(jq -r '.mcpServers["storkit"].url' .mcp.json)" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||||
```
|
```
|
||||||
This returns the full tool catalog (create stories, spawn agents, record tests, manage worktrees, etc.). Familiarize yourself with the available tools before proceeding. These tools allow you to directly manipulate the workflow and spawn subsidiary agents without manual file manipulation.
|
This returns the full tool catalog (create stories, spawn agents, record tests, manage worktrees, etc.). Familiarize yourself with the available tools before proceeding. These tools allow you to directly manipulate the workflow and spawn subsidiary agents without manual file manipulation.
|
||||||
2. **Read Context:** Check `.story_kit/specs/00_CONTEXT.md` for high-level project goals.
|
3. **Read Context:** Check `.storkit/specs/00_CONTEXT.md` for high-level project goals.
|
||||||
3. **Read Stack:** Check `.story_kit/specs/tech/STACK.md` for technical constraints and patterns.
|
4. **Read Stack:** Check `.storkit/specs/tech/STACK.md` for technical constraints and patterns.
|
||||||
4. **Check Work Items:** Look at `.story_kit/work/1_backlog/` and `.story_kit/work/2_current/` to see what work is pending.
|
5. **Check Work Items:** Look at `.storkit/work/1_backlog/` and `.storkit/work/2_current/` to see what work is pending.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+114
-48
@@ -63,30 +63,52 @@ system_prompt = "You are a full-stack engineer working autonomously in a git wor
|
|||||||
[[agent]]
|
[[agent]]
|
||||||
name = "qa-2"
|
name = "qa-2"
|
||||||
stage = "qa"
|
stage = "qa"
|
||||||
role = "Reviews coder work in worktrees: runs quality gates, generates testing plans, and reports findings."
|
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||||
model = "sonnet"
|
model = "sonnet"
|
||||||
max_turns = 40
|
max_turns = 40
|
||||||
max_budget_usd = 4.00
|
max_budget_usd = 4.00
|
||||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to review the coder's work in the worktree and produce a structured QA report.
|
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||||
|
|
||||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||||
|
|
||||||
## Your Workflow
|
## Your Workflow
|
||||||
|
|
||||||
### 1. Code Quality Scan
|
### 0. Read the Story
|
||||||
- Run `git diff master...HEAD --stat` to see what files changed
|
- Read the story file at `.storkit/work/3_qa/{{story_id}}.md`
|
||||||
- Run `git diff master...HEAD` to review the actual changes for obvious coding mistakes (unused imports, dead code, unhandled errors, hardcoded values)
|
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||||
- Run `cargo clippy --all-targets --all-features` and note any warnings
|
- Keep this list in mind for Step 3
|
||||||
|
|
||||||
|
### 1. Deterministic Gates (Prerequisites)
|
||||||
|
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||||
|
- Run `cargo clippy --all-targets --all-features` — must show 0 errors, 0 warnings
|
||||||
|
- Run `cargo test` and verify all tests pass
|
||||||
- If a `frontend/` directory exists:
|
- If a `frontend/` directory exists:
|
||||||
- Run `npm run build` and note any TypeScript errors
|
- Run `npm run build` and note any TypeScript errors
|
||||||
- Run `npx @biomejs/biome check src/` and note any linting issues
|
- Run `npx @biomejs/biome check src/` and note any linting issues
|
||||||
|
- Run `npm test` and verify all frontend tests pass
|
||||||
|
|
||||||
### 2. Test Verification
|
### 2. Code Change Review
|
||||||
- Run `cargo test` and verify all tests pass
|
- Run `git diff master...HEAD --stat` to see what files changed
|
||||||
- If `frontend/` exists: run `npm test` and verify all frontend tests pass
|
- Run `git diff master...HEAD` to review the actual changes
|
||||||
- Review test quality: look for tests that are trivial or don't assert meaningful behavior
|
- Flag any incomplete implementations:
|
||||||
|
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||||
|
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||||
|
- Empty match arms or arms that just return `Default::default()`
|
||||||
|
- Hardcoded values where real logic is expected
|
||||||
|
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||||
|
|
||||||
### 3. Manual Testing Support
|
### 3. Acceptance Criteria Review
|
||||||
|
For each AC extracted in Step 0:
|
||||||
|
- Review the diff and test files to determine if the code addresses this AC
|
||||||
|
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||||
|
- FAIL: explain exactly what is missing or incorrect
|
||||||
|
|
||||||
|
An AC fails if:
|
||||||
|
- No code change or test relates to it
|
||||||
|
- The implementation is stubbed out (todo!/unimplemented!)
|
||||||
|
- A test exists but doesn't actually assert the behaviour described
|
||||||
|
|
||||||
|
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||||
- Build the server: run `cargo build` and note success/failure
|
- Build the server: run `cargo build` and note success/failure
|
||||||
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
|
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
|
||||||
- Generate a testing plan including:
|
- Generate a testing plan including:
|
||||||
@@ -95,8 +117,8 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
|||||||
- curl commands to exercise relevant API endpoints
|
- curl commands to exercise relevant API endpoints
|
||||||
- Kill the test server when done: `pkill -f 'target.*storkit' || true` (NEVER use `pkill -f storkit` — 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
|
### 5. Produce Structured Report and Verdict
|
||||||
Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format:
|
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||||
|
|
||||||
```
|
```
|
||||||
## QA Report for {{story_id}}
|
## QA Report for {{story_id}}
|
||||||
@@ -105,27 +127,38 @@ Print your QA report to stdout before your process exits. The server will automa
|
|||||||
- clippy: PASS/FAIL (details)
|
- clippy: PASS/FAIL (details)
|
||||||
- TypeScript build: PASS/FAIL/SKIP (details)
|
- TypeScript build: PASS/FAIL/SKIP (details)
|
||||||
- Biome lint: PASS/FAIL/SKIP (details)
|
- Biome lint: PASS/FAIL/SKIP (details)
|
||||||
- Code review findings: (list any issues found, or "None")
|
|
||||||
|
|
||||||
### Test Verification
|
|
||||||
- cargo test: PASS/FAIL (N tests)
|
- cargo test: PASS/FAIL (N tests)
|
||||||
- npm test: PASS/FAIL/SKIP (N tests)
|
- npm test: PASS/FAIL/SKIP (N tests)
|
||||||
- Test quality issues: (list any trivial/weak tests, or "None")
|
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||||
|
- Other code review findings: (list any issues found, or "None")
|
||||||
|
|
||||||
|
### Acceptance Criteria Review
|
||||||
|
- AC: <criterion text>
|
||||||
|
Result: PASS/FAIL
|
||||||
|
Evidence: <how the code addresses it, or what is missing>
|
||||||
|
|
||||||
|
(repeat for each AC)
|
||||||
|
|
||||||
### Manual Testing Plan
|
### Manual Testing Plan
|
||||||
- Server URL: http://localhost:PORT (or "Build failed")
|
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||||
- Pages to visit: (list)
|
- Pages to visit: (list, or "N/A")
|
||||||
- Things to check: (list)
|
- Things to check: (list, or "N/A")
|
||||||
- curl commands: (list)
|
- curl commands: (list, or "N/A")
|
||||||
|
|
||||||
### Overall: PASS/FAIL
|
### Overall: PASS/FAIL
|
||||||
|
Reason: (summary of why it passed or the primary reason it failed)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After printing the report:
|
||||||
|
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||||
|
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
- Do NOT modify any code — read-only review only
|
- Do NOT modify any code — read-only review only
|
||||||
- If the server fails to start, still provide the testing plan with curl commands
|
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||||
- The server automatically runs acceptance gates when your process exits"""
|
- If any AC is not met, the overall result is FAIL
|
||||||
system_prompt = "You are a QA agent. Your job is read-only: review code quality, run tests, try to start the server, and produce a structured QA report. Do not modify code. The server automatically runs acceptance gates when your process exits."
|
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||||
|
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||||
|
|
||||||
[[agent]]
|
[[agent]]
|
||||||
name = "coder-opus"
|
name = "coder-opus"
|
||||||
@@ -140,30 +173,52 @@ system_prompt = "You are a senior full-stack engineer working autonomously in a
|
|||||||
[[agent]]
|
[[agent]]
|
||||||
name = "qa"
|
name = "qa"
|
||||||
stage = "qa"
|
stage = "qa"
|
||||||
role = "Reviews coder work in worktrees: runs quality gates, generates testing plans, and reports findings."
|
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||||
model = "sonnet"
|
model = "sonnet"
|
||||||
max_turns = 40
|
max_turns = 40
|
||||||
max_budget_usd = 4.00
|
max_budget_usd = 4.00
|
||||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to review the coder's work in the worktree and produce a structured QA report.
|
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||||
|
|
||||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||||
|
|
||||||
## Your Workflow
|
## Your Workflow
|
||||||
|
|
||||||
### 1. Code Quality Scan
|
### 0. Read the Story
|
||||||
- Run `git diff master...HEAD --stat` to see what files changed
|
- Read the story file at `.storkit/work/3_qa/{{story_id}}.md`
|
||||||
- Run `git diff master...HEAD` to review the actual changes for obvious coding mistakes (unused imports, dead code, unhandled errors, hardcoded values)
|
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||||
- Run `cargo clippy --all-targets --all-features` and note any warnings
|
- Keep this list in mind for Step 3
|
||||||
|
|
||||||
|
### 1. Deterministic Gates (Prerequisites)
|
||||||
|
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||||
|
- Run `cargo clippy --all-targets --all-features` — must show 0 errors, 0 warnings
|
||||||
|
- Run `cargo test` and verify all tests pass
|
||||||
- If a `frontend/` directory exists:
|
- If a `frontend/` directory exists:
|
||||||
- Run `npm run build` and note any TypeScript errors
|
- Run `npm run build` and note any TypeScript errors
|
||||||
- Run `npx @biomejs/biome check src/` and note any linting issues
|
- Run `npx @biomejs/biome check src/` and note any linting issues
|
||||||
|
- Run `npm test` and verify all frontend tests pass
|
||||||
|
|
||||||
### 2. Test Verification
|
### 2. Code Change Review
|
||||||
- Run `cargo test` and verify all tests pass
|
- Run `git diff master...HEAD --stat` to see what files changed
|
||||||
- If `frontend/` exists: run `npm test` and verify all frontend tests pass
|
- Run `git diff master...HEAD` to review the actual changes
|
||||||
- Review test quality: look for tests that are trivial or don't assert meaningful behavior
|
- Flag any incomplete implementations:
|
||||||
|
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||||
|
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||||
|
- Empty match arms or arms that just return `Default::default()`
|
||||||
|
- Hardcoded values where real logic is expected
|
||||||
|
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||||
|
|
||||||
### 3. Manual Testing Support
|
### 3. Acceptance Criteria Review
|
||||||
|
For each AC extracted in Step 0:
|
||||||
|
- Review the diff and test files to determine if the code addresses this AC
|
||||||
|
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||||
|
- FAIL: explain exactly what is missing or incorrect
|
||||||
|
|
||||||
|
An AC fails if:
|
||||||
|
- No code change or test relates to it
|
||||||
|
- The implementation is stubbed out (todo!/unimplemented!)
|
||||||
|
- A test exists but doesn't actually assert the behaviour described
|
||||||
|
|
||||||
|
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||||
- Build the server: run `cargo build` and note success/failure
|
- Build the server: run `cargo build` and note success/failure
|
||||||
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
|
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
|
||||||
- Generate a testing plan including:
|
- Generate a testing plan including:
|
||||||
@@ -172,8 +227,8 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
|||||||
- curl commands to exercise relevant API endpoints
|
- curl commands to exercise relevant API endpoints
|
||||||
- Kill the test server when done: `pkill -f 'target.*storkit' || true` (NEVER use `pkill -f storkit` — 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
|
### 5. Produce Structured Report and Verdict
|
||||||
Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format:
|
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||||
|
|
||||||
```
|
```
|
||||||
## QA Report for {{story_id}}
|
## QA Report for {{story_id}}
|
||||||
@@ -182,27 +237,38 @@ Print your QA report to stdout before your process exits. The server will automa
|
|||||||
- clippy: PASS/FAIL (details)
|
- clippy: PASS/FAIL (details)
|
||||||
- TypeScript build: PASS/FAIL/SKIP (details)
|
- TypeScript build: PASS/FAIL/SKIP (details)
|
||||||
- Biome lint: PASS/FAIL/SKIP (details)
|
- Biome lint: PASS/FAIL/SKIP (details)
|
||||||
- Code review findings: (list any issues found, or "None")
|
|
||||||
|
|
||||||
### Test Verification
|
|
||||||
- cargo test: PASS/FAIL (N tests)
|
- cargo test: PASS/FAIL (N tests)
|
||||||
- npm test: PASS/FAIL/SKIP (N tests)
|
- npm test: PASS/FAIL/SKIP (N tests)
|
||||||
- Test quality issues: (list any trivial/weak tests, or "None")
|
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||||
|
- Other code review findings: (list any issues found, or "None")
|
||||||
|
|
||||||
|
### Acceptance Criteria Review
|
||||||
|
- AC: <criterion text>
|
||||||
|
Result: PASS/FAIL
|
||||||
|
Evidence: <how the code addresses it, or what is missing>
|
||||||
|
|
||||||
|
(repeat for each AC)
|
||||||
|
|
||||||
### Manual Testing Plan
|
### Manual Testing Plan
|
||||||
- Server URL: http://localhost:PORT (or "Build failed")
|
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||||
- Pages to visit: (list)
|
- Pages to visit: (list, or "N/A")
|
||||||
- Things to check: (list)
|
- Things to check: (list, or "N/A")
|
||||||
- curl commands: (list)
|
- curl commands: (list, or "N/A")
|
||||||
|
|
||||||
### Overall: PASS/FAIL
|
### Overall: PASS/FAIL
|
||||||
|
Reason: (summary of why it passed or the primary reason it failed)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After printing the report:
|
||||||
|
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||||
|
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
- Do NOT modify any code — read-only review only
|
- Do NOT modify any code — read-only review only
|
||||||
- If the server fails to start, still provide the testing plan with curl commands
|
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||||
- The server automatically runs acceptance gates when your process exits"""
|
- If any AC is not met, the overall result is FAIL
|
||||||
system_prompt = "You are a QA agent. Your job is read-only: review code quality, run tests, try to start the server, and produce a structured QA report. Do not modify code. The server automatically runs acceptance gates when your process exits."
|
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||||
|
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||||
|
|
||||||
[[agent]]
|
[[agent]]
|
||||||
name = "mergemaster"
|
name = "mergemaster"
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "Setup wizard interviews user on bare projects with no existing code"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 433: Setup wizard interviews user on bare projects with no existing code
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a developer starting a brand new project from an empty directory, I want the setup wizard to ask me what I'm building and what tech stack I plan to use, so that it can generate meaningful CONTEXT.md and STACK.md without any codebase to analyze.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] wizard_generate detects when the project directory has no source code files
|
||||||
|
- [ ] On bare projects, the wizard asks the user what they want to build instead of trying to analyze code
|
||||||
|
- [ ] Wizard asks about intended tech stack, frameworks, and language choices
|
||||||
|
- [ ] Conversation continues until the user confirms the generated CONTEXT.md captures their intent
|
||||||
|
- [ ] STACK.md is generated from the user's stated tech choices rather than from codebase detection
|
||||||
|
- [ ] script/test and script/release are generated with appropriate stubs for the stated stack
|
||||||
|
- [ ] The interview flow works via both MCP tools (Claude Code terminal) and bot commands (Matrix/WhatsApp/Slack)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Wizard auto-checks completion on first conversation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 434: Wizard auto-checks completion on first conversation
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a developer opening Claude Code on a storkit project for the first time, I want the wizard to automatically check if setup is complete and prompt me through remaining steps, so I don't have to know to ask for it.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Scaffolded CLAUDE.md includes an IMPORTANT instruction telling Claude to call wizard_status on first conversation
|
||||||
|
- [ ] If wizard is incomplete, Claude guides the user through remaining steps without being asked
|
||||||
|
- [ ] If wizard is already complete, no wizard prompt appears — Claude behaves normally
|
||||||
|
- [ ] Works on both existing projects with code and bare projects with no code
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: "Unblock command handles all stuck states not just blocked flag"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 435: Unblock command handles all stuck states not just blocked flag
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want the unblock command to clear any stuck state on a story — not just the blocked flag — so that I have a single command to unstick stories regardless of why they're stuck.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Unblock clears merge_failure field in addition to blocked flag
|
||||||
|
- [ ] Unblock clears review_hold field
|
||||||
|
- [ ] Unblock reports which fields were cleared in the confirmation message
|
||||||
|
- [ ] Unblock works on stories in any pipeline stage (backlog, current, qa, merge, done)
|
||||||
|
- [ ] If no stuck state is found (no blocked, merge_failure, or review_hold), returns a clear message saying so
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: "Unify story stuck states into a single status field"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor 436: Unify story stuck states into a single status field
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
Replace the separate blocked, merge_failure, and review_hold front matter fields with a single status field (e.g. status: blocked, status: merge_failure, status: review_hold). Simplifies the unblock command, auto-assign checks, and pipeline advance logic.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Replace blocked: true, merge_failure: string, and review_hold: true with a single status: field in story front matter
|
||||||
|
- [ ] Auto-assign checks a single field instead of three separate ones
|
||||||
|
- [ ] Pipeline advance and lifecycle code reads/writes the unified status field
|
||||||
|
- [ ] Unblock command clears the status field regardless of which stuck state it was
|
||||||
|
- [ ] retry_count remains a separate field (it's a counter, not a state)
|
||||||
|
- [ ] Migration: existing stories with old fields are handled gracefully on read
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+20
@@ -49,6 +49,26 @@ Story moved to done with no code on master. The merge-queue commit exists on a d
|
|||||||
|
|
||||||
Pipeline should verify that the cherry-pick produced a merge commit on master before advancing to done. If cherry-pick fails or is missing, the story should remain in merge stage with a merge_failure flag.
|
Pipeline should verify that the cherry-pick produced a merge commit on master before advancing to done. If cherry-pick fails or is missing, the story should remain in merge stage with a merge_failure flag.
|
||||||
|
|
||||||
|
## Suggested Fix
|
||||||
|
|
||||||
|
The code path is: `merge.rs::run_squash_merge` → `pipeline/merge.rs::start_merge_agent_work` → `lifecycle.rs::move_story_to_archived`.
|
||||||
|
|
||||||
|
`run_squash_merge` (merge.rs:354) cherry-picks the merge-queue commit onto `project_root` and checks `cp.status.success()`. If it returns `success: true`, `start_merge_agent_work` (pipeline/merge.rs:106) immediately calls `move_story_to_archived`, which moves the story file to `5_done/`. The watcher then commits "storkit: done".
|
||||||
|
|
||||||
|
The gap: between the cherry-pick returning success and the story moving to done, nobody verifies the cherry-pick actually produced a code commit on master. Possible failure modes:
|
||||||
|
|
||||||
|
1. `project_root` is not on master (e.g. checked out to a merge-queue branch from a concurrent merge)
|
||||||
|
2. Cherry-pick exits 0 but produces an empty commit (no code diff)
|
||||||
|
3. Cherry-pick succeeds on the wrong branch
|
||||||
|
|
||||||
|
**Fix:** After the cherry-pick in `run_squash_merge` succeeds (line 384), before returning `success: true`:
|
||||||
|
|
||||||
|
1. Verify `project_root` is on master: `git rev-parse --abbrev-ref HEAD` must equal the base branch
|
||||||
|
2. Verify the HEAD commit on master contains the expected merge message (e.g. matches `storkit: merge <story_id>`) or has a non-empty diff
|
||||||
|
3. If either check fails, abort the cherry-pick and return `success: false`
|
||||||
|
|
||||||
|
This keeps the fix entirely within `run_squash_merge` — no changes needed to the pipeline advance or lifecycle code.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Pipeline must not move a story to done unless a merge commit containing the feature code exists on master
|
- [ ] Pipeline must not move a story to done unless a merge commit containing the feature code exists on master
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Interactive project setup wizard for new storkit projects"
|
||||||
|
agent: coder-opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 429: Interactive project setup wizard for new storkit projects
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a developer adopting storkit on an existing project, I want a guided setup process that scaffolds the .storkit directory and has an agent generate project-specific configuration files, so that I can get up and running without manually writing specs and scripts.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] storkit init scaffolds .storkit/ directory structure, project.toml, and .mcp.json without clobbering any existing files (especially CLAUDE.md)
|
||||||
|
- [ ] Setup wizard tracks progress through ordered steps, resumable if interrupted
|
||||||
|
- [ ] Step 1: scaffold .storkit/ directory structure and project.toml
|
||||||
|
- [ ] Step 2: agent reads codebase and generates specs/00_CONTEXT.md, user confirms or requests revision
|
||||||
|
- [ ] Step 3: agent reads tech stack and generates specs/tech/STACK.md, user confirms or requests revision
|
||||||
|
- [ ] Step 4: agent creates script/test that runs the project's actual test suite, user runs it to verify, then confirms
|
||||||
|
- [ ] Step 5: agent creates script/release tailored to the project's deployment, user confirms
|
||||||
|
- [ ] Step 6: agent creates script/test_coverage if the stack supports it, user confirms
|
||||||
|
- [ ] Each step gates on user confirmation before advancing to the next
|
||||||
|
- [ ] Existing CLAUDE.md is preserved — storkit appends its content or leaves it untouched
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Status command traffic light dots not coloured in Matrix"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 430: Status command traffic light dots not coloured in Matrix
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The traffic light dots in the status command use plain Unicode characters (○ ● ◑ ✗) which render without colour in Matrix. The HTML formatted_body should use data-mx-color to colour them green/yellow/red.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
Send the status command to the bot in Matrix. Observe the dots are monochrome.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Dots render as plain monochrome Unicode characters.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Dots render in colour: green (● running), yellow (◑ throttled), red (✗ blocked), grey (○ idle). Use font tag with data-mx-color attribute for Matrix HTML formatted_body.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] HTML formatted_body uses <font data-mx-color="#colour">dot</font> for each traffic light state
|
||||||
|
- [ ] Green (#00cc00) for running, yellow (#ffaa00) for throttled, red (#cc0000) for blocked, grey (#888888) for idle
|
||||||
|
- [ ] Plain text fallback remains unchanged (Unicode dots for non-HTML transports)
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: "QA agent reviews code changes against acceptance criteria"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 431: QA agent reviews code changes against acceptance criteria
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want the QA agent to actually verify that the coder's implementation matches the story's acceptance criteria, so that incomplete or incorrect work is caught before merge.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] QA agent reads the story's acceptance criteria before reviewing code
|
||||||
|
- [ ] QA agent reads the full diff against master to understand what changed
|
||||||
|
- [ ] For each AC, QA agent verifies the code addresses it and explains how
|
||||||
|
- [ ] QA agent flags incomplete implementations: todo!(), unimplemented!(), missing match arms, placeholder values
|
||||||
|
- [ ] QA agent checks that new code has corresponding test coverage
|
||||||
|
- [ ] QA agent produces a structured report: each AC with pass/fail and explanation
|
||||||
|
- [ ] If any AC is not met, QA rejects the story with a clear reason so the coder can fix it
|
||||||
|
- [ ] Deterministic gates (clippy, tests) still run as a prerequisite before the AC review
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Complete setup wizard with MCP tools and agent-driven file generation"
|
||||||
|
agent: "coder-opus"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 432: Complete setup wizard with MCP tools and agent-driven file generation
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a developer running storkit init on a new project, I want the setup wizard to walk me through each step interactively — generating files, letting me review them, and confirming before moving on — so that my project is correctly configured without manual file editing.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] MCP tool wizard_status returns the current wizard state: which step is active, which are done/skipped/pending
|
||||||
|
- [ ] MCP tool wizard_generate triggers the agent to read the codebase and generate content for the current step (CONTEXT.md, STACK.md, script/test, script/release, script/test_coverage)
|
||||||
|
- [ ] MCP tool wizard_confirm confirms the current step and advances to the next
|
||||||
|
- [ ] MCP tool wizard_skip skips the current step and advances to the next
|
||||||
|
- [ ] MCP tool wizard_retry re-generates content for the current step if the user isn't happy with it
|
||||||
|
- [ ] Bot command setup shows wizard progress and the current step with instructions
|
||||||
|
- [ ] Bot command setup confirm / setup skip / setup retry drive the wizard from chat
|
||||||
|
- [ ] Generated files are written to disk only after user confirmation, not during generation preview
|
||||||
|
- [ ] The wizard works from Claude Code terminal via MCP tools without requiring the web UI or chat bot
|
||||||
|
- [ ] Existing files (especially CLAUDE.md) are never overwritten — wizard appends or skips
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
Generated
+1
-1
@@ -4019,7 +4019,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "storkit"
|
name = "storkit"
|
||||||
version = "0.7.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -1,6 +1,37 @@
|
|||||||
# Storkit
|
# Storkit
|
||||||
|
|
||||||
A story-driven development server that manages work items, spawns coding agents, and runs them through a pipeline from backlog to done. Ships as a single Rust binary with an embedded React frontend. Communicates via Matrix, WhatsApp, and Slack bot transports, and exposes MCP tools for programmatic access.
|
A story-driven development server that manages work items, spawns coding agents, and runs them through a pipeline from backlog to done. Ships as a single Rust binary with an embedded React frontend.
|
||||||
|
|
||||||
|
## Getting started with Claude Code
|
||||||
|
|
||||||
|
1. Download the storkit binary (or build from source — see below).
|
||||||
|
|
||||||
|
2. From your project directory, scaffold and start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
storkit init --port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a `.storkit/` directory with the pipeline structure, `project.toml`, and `.mcp.json`. The `.mcp.json` file lets Claude Code discover storkit's MCP tools automatically.
|
||||||
|
|
||||||
|
3. Open a Claude Code session in the same project directory. Claude will pick up the MCP tools from `.mcp.json`.
|
||||||
|
|
||||||
|
4. Tell Claude: "help me set up this project with storkit." Claude will walk you through the setup wizard — generating project context, tech stack docs, and test/release scripts. Review each step and confirm or ask to retry.
|
||||||
|
|
||||||
|
Once setup is complete, Claude can create stories, start agents, check status, and manage the full pipeline via MCP tools — no commands to memorize.
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
Storkit also ships an embedded React frontend. Once the server is running, open `http://localhost:3000` to see the pipeline board, agent status, and chat interface.
|
||||||
|
|
||||||
|
## Chat transports
|
||||||
|
|
||||||
|
Storkit can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Slack**. Configure a transport in `.storkit/bot.toml` — see the example files:
|
||||||
|
|
||||||
|
- `.storkit/bot.toml.matrix.example`
|
||||||
|
- `.storkit/bot.toml.whatsapp-meta.example`
|
||||||
|
- `.storkit/bot.toml.whatsapp-twilio.example`
|
||||||
|
- `.storkit/bot.toml.slack.example`
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -49,7 +80,11 @@ Configuration lives in `.storkit/project.toml`. See `.storkit/bot.toml.*.example
|
|||||||
Requires a Gitea API token in `.env` (`GITEA_TOKEN=your_token`).
|
Requires a Gitea API token in `.env` (`GITEA_TOKEN=your_token`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
script/release 0.6.1
|
script/release 0.7.1
|
||||||
```
|
```
|
||||||
|
|
||||||
This bumps version in `Cargo.toml` and `package.json`, builds macOS arm64 and Linux amd64 binaries, tags the repo, and publishes a Gitea release with changelog and binaries attached.
|
This bumps version in `Cargo.toml` and `package.json`, builds macOS arm64 and Linux amd64 binaries, tags the repo, and publishes a Gitea release with changelog and binaries attached.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0. See [LICENSE](LICENSE).
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"version": "0.7.0",
|
"version": "0.8.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"version": "0.7.0",
|
"version": "0.8.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.0",
|
"version": "0.8.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ export type WsRequest =
|
|||||||
config: ProviderConfig;
|
config: ProviderConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface WizardStepInfo {
|
||||||
|
step: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardStateData {
|
||||||
|
steps: WizardStepInfo[];
|
||||||
|
current_step_index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentAssignment {
|
export interface AgentAssignment {
|
||||||
agent_name: string;
|
agent_name: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -80,6 +93,13 @@ export type WsResponse =
|
|||||||
| { type: "pong" }
|
| { type: "pong" }
|
||||||
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
||||||
| { type: "onboarding_status"; needs_onboarding: boolean }
|
| { type: "onboarding_status"; needs_onboarding: boolean }
|
||||||
|
/** Sent on connect when a setup wizard is active. */
|
||||||
|
| {
|
||||||
|
type: "wizard_state";
|
||||||
|
steps: WizardStepInfo[];
|
||||||
|
current_step_index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
||||||
| { type: "thinking_token"; content: string }
|
| { type: "thinking_token"; content: string }
|
||||||
/** Streaming token from a /btw side question response. */
|
/** Streaming token from a /btw side question response. */
|
||||||
@@ -438,6 +458,7 @@ export class ChatWebSocket {
|
|||||||
private onAgentConfigChanged?: () => void;
|
private onAgentConfigChanged?: () => void;
|
||||||
private onAgentStateChanged?: () => void;
|
private onAgentStateChanged?: () => void;
|
||||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
|
private onWizardState?: (state: WizardStateData) => void;
|
||||||
private onSideQuestionToken?: (content: string) => void;
|
private onSideQuestionToken?: (content: string) => void;
|
||||||
private onSideQuestionDone?: (response: string) => void;
|
private onSideQuestionDone?: (response: string) => void;
|
||||||
private onLogEntry?: (
|
private onLogEntry?: (
|
||||||
@@ -528,6 +549,12 @@ export class ChatWebSocket {
|
|||||||
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
||||||
if (data.type === "onboarding_status")
|
if (data.type === "onboarding_status")
|
||||||
this.onOnboardingStatus?.(data.needs_onboarding);
|
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||||
|
if (data.type === "wizard_state")
|
||||||
|
this.onWizardState?.({
|
||||||
|
steps: data.steps,
|
||||||
|
current_step_index: data.current_step_index,
|
||||||
|
completed: data.completed,
|
||||||
|
});
|
||||||
if (data.type === "side_question_token")
|
if (data.type === "side_question_token")
|
||||||
this.onSideQuestionToken?.(data.content);
|
this.onSideQuestionToken?.(data.content);
|
||||||
if (data.type === "side_question_done")
|
if (data.type === "side_question_done")
|
||||||
@@ -587,6 +614,7 @@ export class ChatWebSocket {
|
|||||||
onAgentConfigChanged?: () => void;
|
onAgentConfigChanged?: () => void;
|
||||||
onAgentStateChanged?: () => void;
|
onAgentStateChanged?: () => void;
|
||||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
|
onWizardState?: (state: WizardStateData) => void;
|
||||||
onSideQuestionToken?: (content: string) => void;
|
onSideQuestionToken?: (content: string) => void;
|
||||||
onSideQuestionDone?: (response: string) => void;
|
onSideQuestionDone?: (response: string) => void;
|
||||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||||
@@ -606,6 +634,7 @@ export class ChatWebSocket {
|
|||||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||||
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
||||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||||
|
this.onWizardState = handlers.onWizardState;
|
||||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||||
this.onLogEntry = handlers.onLogEntry;
|
this.onLogEntry = handlers.onLogEntry;
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|||||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import type { AgentConfigInfo } from "../api/agents";
|
import type { AgentConfigInfo } from "../api/agents";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import type { AnthropicModelInfo, PipelineState } from "../api/client";
|
import type {
|
||||||
|
AnthropicModelInfo,
|
||||||
|
PipelineState,
|
||||||
|
WizardStateData,
|
||||||
|
} from "../api/client";
|
||||||
import { api, ChatWebSocket } from "../api/client";
|
import { api, ChatWebSocket } from "../api/client";
|
||||||
import { useChatHistory } from "../hooks/useChatHistory";
|
import { useChatHistory } from "../hooks/useChatHistory";
|
||||||
import type { Message, ProviderConfig } from "../types";
|
import type { Message, ProviderConfig } from "../types";
|
||||||
@@ -17,6 +21,7 @@ import { LozengeFlyProvider } from "./LozengeFlyContext";
|
|||||||
import { MessageItem } from "./MessageItem";
|
import { MessageItem } from "./MessageItem";
|
||||||
import type { LogEntry } from "./ServerLogsPanel";
|
import type { LogEntry } from "./ServerLogsPanel";
|
||||||
import { ServerLogsPanel } from "./ServerLogsPanel";
|
import { ServerLogsPanel } from "./ServerLogsPanel";
|
||||||
|
import SetupWizard from "./SetupWizard";
|
||||||
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
||||||
import { StagePanel } from "./StagePanel";
|
import { StagePanel } from "./StagePanel";
|
||||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||||
@@ -217,6 +222,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
|
const [wizardState, setWizardState] = useState<WizardStateData | null>(null);
|
||||||
const onboardingTriggeredRef = useRef(false);
|
const onboardingTriggeredRef = useRef(false);
|
||||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -466,6 +472,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onOnboardingStatus: (onboarding: boolean) => {
|
onOnboardingStatus: (onboarding: boolean) => {
|
||||||
setNeedsOnboarding(onboarding);
|
setNeedsOnboarding(onboarding);
|
||||||
},
|
},
|
||||||
|
onWizardState: (state: WizardStateData) => {
|
||||||
|
setWizardState(state);
|
||||||
|
},
|
||||||
onSideQuestionToken: (content) => {
|
onSideQuestionToken: (content) => {
|
||||||
setSideQuestion((prev) =>
|
setSideQuestion((prev) =>
|
||||||
prev ? { ...prev, response: prev.response + content } : prev,
|
prev ? { ...prev, response: prev.response + content } : prev,
|
||||||
@@ -978,7 +987,20 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
gap: "24px",
|
gap: "24px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{needsOnboarding && messages.length === 0 && !loading && (
|
{wizardState &&
|
||||||
|
!wizardState.completed &&
|
||||||
|
messages.length === 0 &&
|
||||||
|
!loading && (
|
||||||
|
<SetupWizard
|
||||||
|
wizardState={wizardState}
|
||||||
|
onWizardUpdate={setWizardState}
|
||||||
|
sendMessage={sendMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{needsOnboarding &&
|
||||||
|
!wizardState &&
|
||||||
|
messages.length === 0 &&
|
||||||
|
!loading && (
|
||||||
<div
|
<div
|
||||||
data-testid="onboarding-welcome"
|
data-testid="onboarding-welcome"
|
||||||
style={{
|
style={{
|
||||||
@@ -1005,9 +1027,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
This project needs to be set up before you can start writing
|
This project needs to be set up before you can start
|
||||||
stories. The agent will guide you through configuring your
|
writing stories. The agent will guide you through
|
||||||
project goals and tech stack.
|
configuring your project goals and tech stack.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { WizardStateData, WizardStepInfo } from "../api/client";
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
interface SetupWizardProps {
|
||||||
|
wizardState: WizardStateData;
|
||||||
|
onWizardUpdate: (state: WizardStateData) => void;
|
||||||
|
sendMessage: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Style constants for the wizard UI. */
|
||||||
|
const STEP_BG_PENDING = "#1a1f2e";
|
||||||
|
const STEP_BG_ACTIVE = "#1c2a1c";
|
||||||
|
const STEP_BG_DONE = "#1a2a1a";
|
||||||
|
const STEP_BORDER_PENDING = "#2a2f3e";
|
||||||
|
const STEP_BORDER_ACTIVE = "#2d4a2d";
|
||||||
|
const STEP_BORDER_DONE = "#2d4a2d";
|
||||||
|
const COLOR_LABEL = "#ccc";
|
||||||
|
const COLOR_LABEL_DONE = "#a0d4a0";
|
||||||
|
const COLOR_ACCENT = "#a0d4a0";
|
||||||
|
|
||||||
|
function statusIcon(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "confirmed":
|
||||||
|
return "\u2713";
|
||||||
|
case "skipped":
|
||||||
|
return "\u2013";
|
||||||
|
case "generating":
|
||||||
|
return "\u2026";
|
||||||
|
case "awaiting_confirmation":
|
||||||
|
return "?";
|
||||||
|
default:
|
||||||
|
return "\u00B7";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBackground(status: string, isActive: boolean): string {
|
||||||
|
if (status === "confirmed" || status === "skipped") return STEP_BG_DONE;
|
||||||
|
if (isActive) return STEP_BG_ACTIVE;
|
||||||
|
return STEP_BG_PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBorder(status: string, isActive: boolean): string {
|
||||||
|
if (status === "confirmed" || status === "skipped") return STEP_BORDER_DONE;
|
||||||
|
if (isActive) return STEP_BORDER_ACTIVE;
|
||||||
|
return STEP_BORDER_PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messages sent to the chat to trigger agent generation for each step. */
|
||||||
|
const STEP_PROMPTS: Record<string, string> = {
|
||||||
|
context:
|
||||||
|
"Read the codebase and generate .storkit/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard API to store the content: PUT /api/wizard/step/context/content",
|
||||||
|
stack:
|
||||||
|
"Read the tech stack and generate .storkit/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard API to store the content: PUT /api/wizard/step/stack/content",
|
||||||
|
test_script:
|
||||||
|
"Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard API: PUT /api/wizard/step/test_script/content",
|
||||||
|
release_script:
|
||||||
|
"Read the project's deployment setup and create script/release tailored to the project. Then call the wizard API: PUT /api/wizard/step/release_script/content",
|
||||||
|
test_coverage:
|
||||||
|
"If the stack supports coverage reporting, create script/test_coverage. Then call the wizard API: PUT /api/wizard/step/test_coverage/content",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function apiPost(path: string): Promise<WizardStateData | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}${path}`, { method: "POST" });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return (await resp.json()) as WizardStateData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepCard({
|
||||||
|
step,
|
||||||
|
isActive,
|
||||||
|
onGenerate,
|
||||||
|
onConfirm,
|
||||||
|
onSkip,
|
||||||
|
}: {
|
||||||
|
step: WizardStepInfo;
|
||||||
|
isActive: boolean;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}) {
|
||||||
|
const isDone = step.status === "confirmed" || step.status === "skipped";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`wizard-step-${step.step}`}
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: stepBackground(step.status, isActive),
|
||||||
|
border: `1px solid ${stepBorder(step.status, isActive)}`,
|
||||||
|
opacity: !isActive && !isDone ? 0.5 : 1,
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: isDone ? COLOR_ACCENT : "transparent",
|
||||||
|
border: isDone ? "none" : `1px solid ${COLOR_LABEL}`,
|
||||||
|
color: isDone ? "#1a1a1a" : COLOR_LABEL,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusIcon(step.status)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: isDone ? COLOR_LABEL_DONE : COLOR_LABEL,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
{isActive && step.status === "pending" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-generate-${step.step}`}
|
||||||
|
onClick={onGenerate}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: COLOR_ACCENT,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && step.status === "generating" && (
|
||||||
|
<span style={{ color: "#aaa", fontSize: "0.85rem" }}>
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step.content && step.status === "awaiting_confirmation" && (
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
<pre
|
||||||
|
data-testid={`wizard-preview-${step.step}`}
|
||||||
|
style={{
|
||||||
|
background: "#111",
|
||||||
|
padding: "12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#ddd",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
maxHeight: "200px",
|
||||||
|
overflow: "auto",
|
||||||
|
margin: "0 0 12px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.content}
|
||||||
|
</pre>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-confirm-${step.step}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: COLOR_ACCENT,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-revise-${step.step}`}
|
||||||
|
onClick={onGenerate}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #555",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#ccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revise
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-skip-${step.step}`}
|
||||||
|
onClick={onSkip}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #555",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#888",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && step.status === "pending" && !step.content && (
|
||||||
|
<div style={{ marginTop: "8px", display: "flex", gap: "8px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-skip-${step.step}`}
|
||||||
|
onClick={onSkip}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #444",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#888",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip this step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SetupWizard({
|
||||||
|
wizardState,
|
||||||
|
onWizardUpdate,
|
||||||
|
sendMessage,
|
||||||
|
}: SetupWizardProps) {
|
||||||
|
const [, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(
|
||||||
|
(step: WizardStepInfo) => {
|
||||||
|
const prompt = STEP_PROMPTS[step.step];
|
||||||
|
if (prompt) {
|
||||||
|
sendMessage(prompt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(
|
||||||
|
async (step: WizardStepInfo) => {
|
||||||
|
const result = await apiPost(`/wizard/step/${step.step}/confirm`);
|
||||||
|
if (result) {
|
||||||
|
onWizardUpdate(result);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onWizardUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSkip = useCallback(
|
||||||
|
async (step: WizardStepInfo) => {
|
||||||
|
const result = await apiPost(`/wizard/step/${step.step}/skip`);
|
||||||
|
if (result) {
|
||||||
|
onWizardUpdate(result);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onWizardUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wizardState.completed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="wizard-complete"
|
||||||
|
style={{
|
||||||
|
padding: "24px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: STEP_BG_DONE,
|
||||||
|
border: `1px solid ${STEP_BORDER_DONE}`,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: "0 0 8px 0", color: COLOR_ACCENT }}>
|
||||||
|
Setup Complete
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, color: COLOR_LABEL }}>
|
||||||
|
Your project is configured. You can start writing stories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="setup-wizard"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "8px" }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: "0 0 4px 0",
|
||||||
|
color: COLOR_ACCENT,
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Project Setup Wizard
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, color: "#999", fontSize: "0.85rem" }}>
|
||||||
|
Step {wizardState.current_step_index + 1} of{" "}
|
||||||
|
{wizardState.steps.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wizardState.steps.map((step, idx) => (
|
||||||
|
<StepCard
|
||||||
|
key={step.step}
|
||||||
|
step={step}
|
||||||
|
isActive={idx === wizardState.current_step_index}
|
||||||
|
onGenerate={() => handleGenerate(step)}
|
||||||
|
onConfirm={() => handleConfirm(step)}
|
||||||
|
onSkip={() => handleSkip(step)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-5
@@ -59,7 +59,11 @@ PACKAGE_LOCK="${SCRIPT_DIR}/frontend/package-lock.json"
|
|||||||
echo "==> Regenerated package-lock.json"
|
echo "==> Regenerated package-lock.json"
|
||||||
|
|
||||||
git add "$CARGO_TOML" "$CARGO_LOCK" "$PACKAGE_JSON" "$PACKAGE_LOCK"
|
git add "$CARGO_TOML" "$CARGO_LOCK" "$PACKAGE_JSON" "$PACKAGE_LOCK"
|
||||||
git commit -m "Bump version to ${VERSION}"
|
if git diff --cached --quiet; then
|
||||||
|
echo "==> Version already at ${VERSION}, skipping commit"
|
||||||
|
else
|
||||||
|
git commit -m "Bump version to ${VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v cross >/dev/null 2>&1; then
|
if ! command -v cross >/dev/null 2>&1; then
|
||||||
echo "Error: 'cross' is not installed. Run: cargo install cross"
|
echo "Error: 'cross' is not installed. Run: cargo install cross"
|
||||||
@@ -109,10 +113,10 @@ fi
|
|||||||
MERGE_RE="^(storkit|story-kit): merge "
|
MERGE_RE="^(storkit|story-kit): merge "
|
||||||
if [ -n "$LOG_RANGE" ]; then
|
if [ -n "$LOG_RANGE" ]; then
|
||||||
MERGED_RAW=$(git log "$LOG_RANGE" --pretty=format:"%s" --no-merges \
|
MERGED_RAW=$(git log "$LOG_RANGE" --pretty=format:"%s" --no-merges \
|
||||||
| grep -E "$MERGE_RE" | sed -E "s/$MERGE_RE//" | sort -u)
|
| grep -E "$MERGE_RE" | sed -E "s/$MERGE_RE//" | sort -u || true)
|
||||||
else
|
else
|
||||||
MERGED_RAW=$(git log --pretty=format:"%s" --no-merges \
|
MERGED_RAW=$(git log --pretty=format:"%s" --no-merges \
|
||||||
| grep -E "$MERGE_RE" | sed -E "s/$MERGE_RE//" | sort -u)
|
| grep -E "$MERGE_RE" | sed -E "s/$MERGE_RE//" | sort -u || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Categorise merged work items and format names.
|
# Categorise merged work items and format names.
|
||||||
@@ -138,13 +142,13 @@ if [ -n "$LOG_RANGE" ]; then
|
|||||||
| grep -Ev "^(storkit|story-kit): " \
|
| grep -Ev "^(storkit|story-kit): " \
|
||||||
| grep -Ev "^Revert \"(storkit|story-kit): " \
|
| grep -Ev "^Revert \"(storkit|story-kit): " \
|
||||||
| grep -v "^Bump version" \
|
| grep -v "^Bump version" \
|
||||||
| sed 's/^/- /')
|
| sed 's/^/- /' || true)
|
||||||
else
|
else
|
||||||
MANUAL=$(git log --pretty=format:"%s" --no-merges \
|
MANUAL=$(git log --pretty=format:"%s" --no-merges \
|
||||||
| grep -Ev "^(storkit|story-kit): " \
|
| grep -Ev "^(storkit|story-kit): " \
|
||||||
| grep -Ev "^Revert \"(storkit|story-kit): " \
|
| grep -Ev "^Revert \"(storkit|story-kit): " \
|
||||||
| grep -v "^Bump version" \
|
| grep -v "^Bump version" \
|
||||||
| sed 's/^/- /')
|
| sed 's/^/- /' || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Generate summary overview ─────────────────────────────────
|
# ── Generate summary overview ─────────────────────────────────
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "storkit"
|
name = "storkit"
|
||||||
version = "0.7.0"
|
version = "0.8.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str)
|
|||||||
/// * If the story is in `4_merge/`, it is moved to `5_done/` and committed.
|
/// * If the story is in `4_merge/`, it is moved to `5_done/` and committed.
|
||||||
/// * If the story is already in `5_done/` or `6_archived/`, this is a no-op (idempotent).
|
/// * If the story is already in `5_done/` or `6_archived/`, this is a no-op (idempotent).
|
||||||
/// * If the story is not found in `2_current/`, `4_merge/`, `5_done/`, or `6_archived/`, an error is returned.
|
/// * If the story is not found in `2_current/`, `4_merge/`, `5_done/`, or `6_archived/`, an error is returned.
|
||||||
pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> {
|
pub fn move_story_to_done(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||||
let sk = project_root.join(".storkit").join("work");
|
let sk = project_root.join(".storkit").join("work");
|
||||||
let current_path = sk.join("2_current").join(format!("{story_id}.md"));
|
let current_path = sk.join("2_current").join(format!("{story_id}.md"));
|
||||||
let merge_path = sk.join("4_merge").join(format!("{story_id}.md"));
|
let merge_path = sk.join("4_merge").join(format!("{story_id}.md"));
|
||||||
@@ -584,10 +584,10 @@ mod tests {
|
|||||||
assert!(result.unwrap_err().contains("not found in work/2_current/"));
|
assert!(result.unwrap_err().contains("not found in work/2_current/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── move_story_to_archived tests ──────────────────────────────────────────
|
// ── move_story_to_done tests ──────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_story_to_archived_finds_in_merge_dir() {
|
fn move_story_to_done_finds_in_merge_dir() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
@@ -595,16 +595,16 @@ mod tests {
|
|||||||
fs::create_dir_all(&merge_dir).unwrap();
|
fs::create_dir_all(&merge_dir).unwrap();
|
||||||
fs::write(merge_dir.join("22_story_test.md"), "test").unwrap();
|
fs::write(merge_dir.join("22_story_test.md"), "test").unwrap();
|
||||||
|
|
||||||
move_story_to_archived(root, "22_story_test").unwrap();
|
move_story_to_done(root, "22_story_test").unwrap();
|
||||||
|
|
||||||
assert!(!merge_dir.join("22_story_test.md").exists());
|
assert!(!merge_dir.join("22_story_test.md").exists());
|
||||||
assert!(root.join(".storkit/work/5_done/22_story_test.md").exists());
|
assert!(root.join(".storkit/work/5_done/22_story_test.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_story_to_archived_error_when_not_in_current_or_merge() {
|
fn move_story_to_done_error_when_not_in_current_or_merge() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let result = move_story_to_archived(tmp.path(), "99_nonexistent");
|
let result = move_story_to_done(tmp.path(), "99_nonexistent");
|
||||||
assert!(result.unwrap_err().contains("4_merge"));
|
assert!(result.unwrap_err().contains("4_merge"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ pub(crate) fn run_squash_merge(
|
|||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("Failed to check merge diff: {e}"))?;
|
.map_err(|e| format!("Failed to check merge diff: {e}"))?;
|
||||||
let changed_files = String::from_utf8_lossy(&diff_check.stdout);
|
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/work/"));
|
||||||
if !has_code_changes {
|
if !has_code_changes {
|
||||||
all_output.push_str(
|
all_output.push_str(
|
||||||
"=== Merge commit contains only .storkit/ file moves, no code changes ===\n",
|
"=== Merge commit contains only .storkit/ file moves, no code changes ===\n",
|
||||||
@@ -383,6 +383,72 @@ pub(crate) fn run_squash_merge(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Verify code landed on the correct branch ──────────────────
|
||||||
|
// Guard against the cherry-pick silently landing on the wrong branch
|
||||||
|
// (e.g. a merge-queue branch from a concurrent merge). If the current
|
||||||
|
// branch is not the base branch, or the HEAD commit has no code diff,
|
||||||
|
// treat the merge as failed so the story stays in the merge stage.
|
||||||
|
let current_branch = Command::new("git")
|
||||||
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let base_branch = crate::config::ProjectConfig::load(project_root)
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| c.base_branch.clone())
|
||||||
|
.unwrap_or_else(|| "master".to_string());
|
||||||
|
|
||||||
|
if current_branch != base_branch {
|
||||||
|
all_output.push_str(&format!(
|
||||||
|
"=== VERIFICATION FAILED: expected branch '{base_branch}' but HEAD is on \
|
||||||
|
'{current_branch}'. Cherry-pick landed on wrong branch. ===\n"
|
||||||
|
));
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details: Some(format!(
|
||||||
|
"Cherry-pick landed on '{current_branch}' instead of '{base_branch}'"
|
||||||
|
)),
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify HEAD commit has actual code changes (not an empty cherry-pick).
|
||||||
|
// Exclude .storkit/work/ (pipeline file moves) but keep .storkit/project.toml
|
||||||
|
// and other config files which are legitimate deliverables.
|
||||||
|
let diff_stat = Command::new("git")
|
||||||
|
.args(["diff", "--stat", "HEAD~1..HEAD", "--", ".", ":(exclude).storkit/work"])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if diff_stat.is_empty() {
|
||||||
|
all_output.push_str(
|
||||||
|
"=== VERIFICATION FAILED: cherry-pick produced no code changes on master. ===\n",
|
||||||
|
);
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details: Some(
|
||||||
|
"Cherry-pick commit contains no code changes (empty diff)".to_string(),
|
||||||
|
),
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
all_output.push_str(&format!(
|
||||||
|
"=== Verified: cherry-pick landed on '{base_branch}' with code changes ===\n"
|
||||||
|
));
|
||||||
|
|
||||||
// ── Clean up ──────────────────────────────────────────────────
|
// ── Clean up ──────────────────────────────────────────────────
|
||||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
all_output.push_str("=== Merge-queue cleanup complete ===\n");
|
all_output.push_str("=== Merge-queue cleanup complete ===\n");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::config::AgentConfig;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub use lifecycle::{
|
pub use lifecycle::{
|
||||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived,
|
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
||||||
move_story_to_merge, move_story_to_qa, move_story_to_stage, reject_story_from_qa,
|
move_story_to_merge, move_story_to_qa, move_story_to_stage, reject_story_from_qa,
|
||||||
};
|
};
|
||||||
pub use pool::AgentPool;
|
pub use pool::AgentPool;
|
||||||
|
|||||||
@@ -1729,7 +1729,7 @@ stage = "coder"
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn archiving_story_removes_agent_entries_from_pool() {
|
async fn archiving_story_removes_agent_entries_from_pool() {
|
||||||
use crate::agents::lifecycle::move_story_to_archived;
|
use crate::agents::lifecycle::move_story_to_done;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -1746,7 +1746,7 @@ stage = "coder"
|
|||||||
|
|
||||||
assert_eq!(pool.list_agents().unwrap().len(), 3);
|
assert_eq!(pool.list_agents().unwrap().len(), 3);
|
||||||
|
|
||||||
move_story_to_archived(root, "60_story_cleanup").unwrap();
|
move_story_to_done(root, "60_story_cleanup").unwrap();
|
||||||
pool.remove_agents_for_story("60_story_cleanup");
|
pool.remove_agents_for_story("60_story_cleanup");
|
||||||
|
|
||||||
let remaining = pool.list_agents().unwrap();
|
let remaining = pool.list_agents().unwrap();
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ impl AgentPool {
|
|||||||
"[pipeline] Post-merge tests passed for '{story_id}'. Moving to done."
|
"[pipeline] Post-merge tests passed for '{story_id}'. Moving to done."
|
||||||
);
|
);
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
crate::agents::lifecycle::move_story_to_archived(&project_root, story_id)
|
crate::agents::lifecycle::move_story_to_done(&project_root, story_id)
|
||||||
{
|
{
|
||||||
slog_error!("[pipeline] Failed to move '{story_id}' to done: {e}");
|
slog_error!("[pipeline] Failed to move '{story_id}' to done: {e}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let story_archived =
|
let story_archived =
|
||||||
crate::agents::lifecycle::move_story_to_archived(project_root, story_id).is_ok();
|
crate::agents::lifecycle::move_story_to_done(project_root, story_id).is_ok();
|
||||||
if story_archived {
|
if story_archived {
|
||||||
self.remove_agents_for_story(story_id);
|
self.remove_agents_for_story(story_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod help;
|
|||||||
pub(crate) mod loc;
|
pub(crate) mod loc;
|
||||||
mod move_story;
|
mod move_story;
|
||||||
mod overview;
|
mod overview;
|
||||||
|
mod setup;
|
||||||
mod show;
|
mod show;
|
||||||
mod status;
|
mod status;
|
||||||
mod timer;
|
mod timer;
|
||||||
@@ -177,9 +178,62 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
description: "Show stories merged to master since the last release tag",
|
description: "Show stories merged to master since the last release tag",
|
||||||
handler: unreleased::handle_unreleased,
|
handler: unreleased::handle_unreleased,
|
||||||
},
|
},
|
||||||
|
BotCommand {
|
||||||
|
name: "setup",
|
||||||
|
description: "Show setup wizard progress; or `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
|
||||||
|
handler: setup::handle_setup,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`try_handle_command`] but returns `(plain_body, html_body)`.
|
||||||
|
///
|
||||||
|
/// The plain body is unchanged Markdown text suitable for the Matrix `body`
|
||||||
|
/// field (non-HTML clients). The HTML body is suitable for `formatted_body`.
|
||||||
|
///
|
||||||
|
/// The pipeline-status command (no args) injects Matrix `<font data-mx-color>`
|
||||||
|
/// tags on the traffic-light dots. All other commands produce HTML by running
|
||||||
|
/// the plain body through pulldown-cmark.
|
||||||
|
pub fn try_handle_command_with_html(
|
||||||
|
dispatch: &CommandDispatch<'_>,
|
||||||
|
message: &str,
|
||||||
|
) -> Option<(String, String)> {
|
||||||
|
let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
|
||||||
|
let trimmed = command_text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
|
||||||
|
Some((c, a)) => (c, a.trim()),
|
||||||
|
None => (trimmed, ""),
|
||||||
|
};
|
||||||
|
// Only the no-arg status variant shows the pipeline with traffic-light
|
||||||
|
// dots; `status <number>` is a triage dump that needs no colour tags.
|
||||||
|
if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() {
|
||||||
|
let body = status::build_pipeline_status(dispatch.project_root, dispatch.agents);
|
||||||
|
let html = status::build_pipeline_status_html(dispatch.project_root, dispatch.agents);
|
||||||
|
return Some((body, html));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Generic path: plain text body → Markdown-to-HTML.
|
||||||
|
let body = try_handle_command(dispatch, message)?;
|
||||||
|
let html = plain_to_html(&body);
|
||||||
|
Some((body, html))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a Markdown string to HTML using the same options as the Matrix
|
||||||
|
/// transport's `markdown_to_html` helper.
|
||||||
|
fn plain_to_html(markdown: &str) -> String {
|
||||||
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
|
let normalized = crate::chat::util::normalize_line_breaks(markdown);
|
||||||
|
let options = Options::ENABLE_TABLES
|
||||||
|
| Options::ENABLE_FOOTNOTES
|
||||||
|
| Options::ENABLE_STRIKETHROUGH
|
||||||
|
| Options::ENABLE_TASKLISTS;
|
||||||
|
let parser = Parser::new_ext(&normalized, options);
|
||||||
|
let mut out = String::new();
|
||||||
|
html::push_html(&mut out, parser);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to match a user message against a registered bot command.
|
/// Try to match a user message against a registered bot command.
|
||||||
///
|
///
|
||||||
/// The message is expected to be the raw body text (e.g., `"@timmy help"`).
|
/// The message is expected to be the raw body text (e.g., `"@timmy help"`).
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! Handler for the `setup` bot command.
|
||||||
|
//!
|
||||||
|
//! Drives the setup wizard from any chat transport (Matrix, Slack, WhatsApp).
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! - `setup` — show wizard progress and current step instructions
|
||||||
|
//! - `setup confirm` — confirm the current step (writes staged content to disk)
|
||||||
|
//! - `setup skip` — skip the current step
|
||||||
|
//! - `setup retry` — discard staged content and reset the current step
|
||||||
|
|
||||||
|
use super::CommandContext;
|
||||||
|
use crate::http::mcp::wizard_tools::{is_script_step, step_output_path, write_if_missing};
|
||||||
|
use crate::io::wizard::{format_wizard_state, StepStatus, WizardState};
|
||||||
|
|
||||||
|
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
||||||
|
let sub = ctx.args.trim().to_ascii_lowercase();
|
||||||
|
|
||||||
|
match sub.as_str() {
|
||||||
|
"" => Some(wizard_status_reply(ctx)),
|
||||||
|
"confirm" => Some(wizard_confirm_reply(ctx)),
|
||||||
|
"skip" => Some(wizard_skip_reply(ctx)),
|
||||||
|
"retry" => Some(wizard_retry_reply(ctx)),
|
||||||
|
_ => Some(format!(
|
||||||
|
"Unknown sub-command `{sub}`. Usage: `setup`, `setup confirm`, `setup skip`, `setup retry`."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a status reply for the `setup` command (no args).
|
||||||
|
fn wizard_status_reply(ctx: &CommandContext) -> String {
|
||||||
|
match WizardState::load(ctx.project_root) {
|
||||||
|
Some(state) => format_wizard_state(&state),
|
||||||
|
None => {
|
||||||
|
"No setup wizard active. Run `storkit init` in the project root to begin.".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm the current wizard step, writing any staged content to disk.
|
||||||
|
fn wizard_confirm_reply(ctx: &CommandContext) -> String {
|
||||||
|
let root = ctx.project_root;
|
||||||
|
let mut state = match WizardState::load(root) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return "No wizard active.".to_string(),
|
||||||
|
};
|
||||||
|
if state.completed {
|
||||||
|
return "Wizard is already complete.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = state.current_step_index();
|
||||||
|
let step = state.steps[idx].step;
|
||||||
|
let content = state.steps[idx].content.clone();
|
||||||
|
|
||||||
|
// Write content to disk (only if a file path exists and the file is absent).
|
||||||
|
let write_msg =
|
||||||
|
if let (Some(c), Some(ref path)) = (&content, step_output_path(root, step)) {
|
||||||
|
let executable = is_script_step(step);
|
||||||
|
match write_if_missing(path, c, executable) {
|
||||||
|
Ok(true) => format!(" File written: `{}`.", path.display()),
|
||||||
|
Ok(false) => format!(" File `{}` already exists — skipped.", path.display()),
|
||||||
|
Err(e) => return format!("Error: {e}"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = state.confirm_step(step) {
|
||||||
|
return format!("Cannot confirm step: {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = state.save(root) {
|
||||||
|
return format!("Failed to save wizard state: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.completed {
|
||||||
|
format!(
|
||||||
|
"Step '{}' confirmed.{write_msg}\n\nSetup wizard complete!",
|
||||||
|
step.label()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let next = &state.steps[state.current_step_index()];
|
||||||
|
format!(
|
||||||
|
"Step '{}' confirmed.{write_msg}\n\nNext: {} — run `wizard_generate` to begin.",
|
||||||
|
step.label(),
|
||||||
|
next.step.label()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip the current wizard step without writing any file.
|
||||||
|
fn wizard_skip_reply(ctx: &CommandContext) -> String {
|
||||||
|
let root = ctx.project_root;
|
||||||
|
let mut state = match WizardState::load(root) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return "No wizard active.".to_string(),
|
||||||
|
};
|
||||||
|
if state.completed {
|
||||||
|
return "Wizard is already complete.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = state.current_step_index();
|
||||||
|
let step = state.steps[idx].step;
|
||||||
|
|
||||||
|
if let Err(e) = state.skip_step(step) {
|
||||||
|
return format!("Cannot skip step: {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = state.save(root) {
|
||||||
|
return format!("Failed to save wizard state: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.completed {
|
||||||
|
format!(
|
||||||
|
"Step '{}' skipped. Setup wizard complete!",
|
||||||
|
step.label()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let next = &state.steps[state.current_step_index()];
|
||||||
|
format!(
|
||||||
|
"Step '{}' skipped.\n\nNext: {} — run `wizard_generate` to begin.",
|
||||||
|
step.label(),
|
||||||
|
next.step.label()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard staged content and reset the current step to pending.
|
||||||
|
fn wizard_retry_reply(ctx: &CommandContext) -> String {
|
||||||
|
let root = ctx.project_root;
|
||||||
|
let mut state = match WizardState::load(root) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return "No wizard active.".to_string(),
|
||||||
|
};
|
||||||
|
if state.completed {
|
||||||
|
return "Wizard is already complete.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = state.current_step_index();
|
||||||
|
let step = state.steps[idx].step;
|
||||||
|
|
||||||
|
if let Some(s) = state.steps.iter_mut().find(|s| s.step == step) {
|
||||||
|
s.status = StepStatus::Pending;
|
||||||
|
s.content = None;
|
||||||
|
}
|
||||||
|
if let Err(e) = state.save(root) {
|
||||||
|
return format!("Failed to save wizard state: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"Step '{}' reset to pending. Run `wizard_generate` to regenerate content.",
|
||||||
|
step.label()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::io::wizard::WizardState;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn make_ctx<'a>(
|
||||||
|
args: &'a str,
|
||||||
|
project_root: &'a std::path::Path,
|
||||||
|
agents: &'a Arc<crate::agents::AgentPool>,
|
||||||
|
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
|
||||||
|
) -> CommandContext<'a> {
|
||||||
|
CommandContext {
|
||||||
|
bot_name: "Bot",
|
||||||
|
args,
|
||||||
|
project_root,
|
||||||
|
agents,
|
||||||
|
ambient_rooms,
|
||||||
|
room_id: "!test:example.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_no_wizard_returns_helpful_message() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4000));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("storkit init"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_with_wizard_shows_status() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4001));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("Setup wizard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_skip_advances_wizard() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4002));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("skip", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("skipped"));
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(state.current_step_index(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_confirm_advances_wizard() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4003));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("confirm", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("confirmed"));
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(state.current_step_index(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_retry_resets_step() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
// Stage some content first.
|
||||||
|
{
|
||||||
|
let mut state = WizardState::load(dir.path()).unwrap();
|
||||||
|
state.set_step_status(
|
||||||
|
crate::io::wizard::WizardStep::Context,
|
||||||
|
crate::io::wizard::StepStatus::AwaitingConfirmation,
|
||||||
|
Some("content".to_string()),
|
||||||
|
);
|
||||||
|
state.save(dir.path()).unwrap();
|
||||||
|
}
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4004));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("retry", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("reset"));
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state.steps[1].status,
|
||||||
|
crate::io::wizard::StepStatus::Pending
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_unknown_sub_command_returns_usage() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4005));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("foobar", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("Unknown sub-command"));
|
||||||
|
assert!(result.contains("Usage"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,34 @@ fn read_stage_items(
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the HTML `formatted_body` for the pipeline status with Matrix colour
|
||||||
|
/// tags on the traffic-light dots.
|
||||||
|
///
|
||||||
|
/// Converts the plain-text pipeline status (Markdown) to HTML via
|
||||||
|
/// pulldown-cmark and wraps each traffic-light character in a
|
||||||
|
/// `<font data-mx-color="#rrggbb">` tag so Matrix clients display them in
|
||||||
|
/// colour.
|
||||||
|
pub(super) fn build_pipeline_status_html(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
||||||
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
|
|
||||||
|
let plain = build_pipeline_status(project_root, agents);
|
||||||
|
let normalized = crate::chat::util::normalize_line_breaks(&plain);
|
||||||
|
let options = Options::ENABLE_TABLES
|
||||||
|
| Options::ENABLE_FOOTNOTES
|
||||||
|
| Options::ENABLE_STRIKETHROUGH
|
||||||
|
| Options::ENABLE_TASKLISTS;
|
||||||
|
let parser = Parser::new_ext(&normalized, options);
|
||||||
|
let mut html_out = String::new();
|
||||||
|
html::push_html(&mut html_out, parser);
|
||||||
|
|
||||||
|
// Wrap each traffic-light character with a Matrix colour tag.
|
||||||
|
html_out
|
||||||
|
.replace('\u{2717}', "<font data-mx-color=\"#cc0000\">\u{2717}</font>") // ✗ blocked
|
||||||
|
.replace('\u{25D1}', "<font data-mx-color=\"#ffaa00\">\u{25D1}</font>") // ◑ throttled
|
||||||
|
.replace('\u{25CF}', "<font data-mx-color=\"#00cc00\">\u{25CF}</font>") // ● running
|
||||||
|
.replace('\u{25CB}', "<font data-mx-color=\"#888888\">\u{25CB}</font>") // ○ idle
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the full pipeline status text formatted for Matrix (markdown).
|
/// Build the full pipeline status text formatted for Matrix (markdown).
|
||||||
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
||||||
// Build a map from story_id → active AgentInfo for quick lookup.
|
// Build a map from story_id → active AgentInfo for quick lookup.
|
||||||
@@ -444,6 +472,81 @@ mod tests {
|
|||||||
|
|
||||||
// -- traffic_light_dot --------------------------------------------------
|
// -- traffic_light_dot --------------------------------------------------
|
||||||
|
|
||||||
|
// -- build_pipeline_status_html (colored dots) --------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_status_colors_idle_dot_grey() {
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
||||||
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||||
|
|
||||||
|
let story_path = stage_dir.join("42_story_idle.md");
|
||||||
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||||
|
writeln!(f, "---\nname: Idle Story\n---\n").unwrap();
|
||||||
|
|
||||||
|
let agents = AgentPool::new_test(3000);
|
||||||
|
let html = build_pipeline_status_html(tmp.path(), &agents);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
html.contains("<font data-mx-color=\"#888888\">\u{25CB}</font>"),
|
||||||
|
"idle dot should be grey (#888888): {html}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_status_colors_blocked_dot_red() {
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
||||||
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||||
|
|
||||||
|
let story_path = stage_dir.join("42_story_blocked.md");
|
||||||
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||||
|
writeln!(f, "---\nname: Blocked Story\nblocked: true\n---\n").unwrap();
|
||||||
|
|
||||||
|
let agents = AgentPool::new_test(3000);
|
||||||
|
let html = build_pipeline_status_html(tmp.path(), &agents);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
html.contains("<font data-mx-color=\"#cc0000\">\u{2717}</font>"),
|
||||||
|
"blocked dot should be red (#cc0000): {html}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_status_plain_text_body_unchanged() {
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
||||||
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||||
|
|
||||||
|
let story_path = stage_dir.join("42_story_idle.md");
|
||||||
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||||
|
writeln!(f, "---\nname: Idle Story\n---\n").unwrap();
|
||||||
|
|
||||||
|
let agents = AgentPool::new_test(3000);
|
||||||
|
let plain = build_pipeline_status(tmp.path(), &agents);
|
||||||
|
|
||||||
|
// Plain text must still use bare Unicode dots (no HTML tags).
|
||||||
|
assert!(
|
||||||
|
plain.contains('\u{25CB}'),
|
||||||
|
"plain text should have bare Unicode idle dot: {plain}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!plain.contains("data-mx-color"),
|
||||||
|
"plain text must not contain HTML colour attributes: {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- traffic_light_dot --------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dot_idle_when_no_agent() {
|
fn dot_idle_when_no_agent() {
|
||||||
assert_eq!(traffic_light_dot(false, false, false), "\u{25CB} "); // ○
|
assert_eq!(traffic_light_dot(false, false, false), "\u{25CB} "); // ○
|
||||||
|
|||||||
@@ -98,16 +98,28 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
|||||||
|
|
||||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||||
|
|
||||||
if meta.blocked != Some(true) {
|
let has_blocked = meta.blocked == Some(true);
|
||||||
|
let has_merge_failure = meta.merge_failure.is_some();
|
||||||
|
|
||||||
|
if !has_blocked && !has_merge_failure {
|
||||||
return format!(
|
return format!(
|
||||||
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the blocked flag (reads + writes the file).
|
// Clear the blocked flag if present.
|
||||||
|
if has_blocked {
|
||||||
if let Err(e) = clear_front_matter_field(path, "blocked") {
|
if let Err(e) = clear_front_matter_field(path, "blocked") {
|
||||||
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
|
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear merge_failure if present.
|
||||||
|
if has_merge_failure {
|
||||||
|
if let Err(e) = clear_front_matter_field(path, "merge_failure") {
|
||||||
|
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset retry_count to 0 (re-read the updated file, modify, write).
|
// Reset retry_count to 0 (re-read the updated file, modify, write).
|
||||||
let updated_contents = match std::fs::read_to_string(path) {
|
let updated_contents = match std::fs::read_to_string(path) {
|
||||||
@@ -119,7 +131,10 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
|||||||
return format!("Failed to reset retry_count on **{story_id}**: {e}");
|
return format!("Failed to reset retry_count on **{story_id}**: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
format!("Unblocked **{story_name}** ({story_id}). Retry count reset to 0.")
|
let mut cleared = Vec::new();
|
||||||
|
if has_blocked { cleared.push("blocked"); }
|
||||||
|
if has_merge_failure { cleared.push("merge_failure"); }
|
||||||
|
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -186,10 +186,9 @@ pub(super) async fn on_room_message(
|
|||||||
ambient_rooms: &ctx.ambient_rooms,
|
ambient_rooms: &ctx.ambient_rooms,
|
||||||
room_id: &room_id_str,
|
room_id: &room_id_str,
|
||||||
};
|
};
|
||||||
if let Some(response) = super::super::commands::try_handle_command(&dispatch, &user_message) {
|
if let Some((response, response_html)) = super::super::commands::try_handle_command_with_html(&dispatch, &user_message) {
|
||||||
slog!("[matrix-bot] Handled bot command from {sender}");
|
slog!("[matrix-bot] Handled bot command from {sender}");
|
||||||
let html = markdown_to_html(&response);
|
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &response_html).await
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ pub mod git_tools;
|
|||||||
pub mod merge_tools;
|
pub mod merge_tools;
|
||||||
pub mod qa_tools;
|
pub mod qa_tools;
|
||||||
pub mod shell_tools;
|
pub mod shell_tools;
|
||||||
pub mod story_tools;
|
|
||||||
pub mod status_tools;
|
pub mod status_tools;
|
||||||
|
pub mod story_tools;
|
||||||
|
pub mod wizard_tools;
|
||||||
|
|
||||||
/// Returns true when the Accept header includes text/event-stream.
|
/// Returns true when the Accept header includes text/event-stream.
|
||||||
fn wants_sse(req: &Request) -> bool {
|
fn wants_sse(req: &Request) -> bool {
|
||||||
@@ -1164,6 +1165,51 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
"required": ["file_path"]
|
"required": ["file_path"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wizard_status",
|
||||||
|
"description": "Return the current setup wizard state: which step is active, and which are done/skipped/pending. Use this to inspect progress before calling wizard_generate, wizard_confirm, wizard_skip, or wizard_retry.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wizard_generate",
|
||||||
|
"description": "Drive content generation for the current wizard step. Call with no arguments to mark the step as 'generating' and receive a hint about what to produce. Call again with a 'content' argument (the full file body you generated) to stage it for review. Content is NOT written to disk until wizard_confirm is called.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The generated file content to stage for the current step. Omit to receive a generation hint and mark the step as generating."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wizard_confirm",
|
||||||
|
"description": "Confirm the current wizard step: writes any staged content to disk (only if the target file does not already exist) and advances to the next step. Existing files are never overwritten.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wizard_skip",
|
||||||
|
"description": "Skip the current wizard step without writing any file. Use when a step does not apply to this project.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wizard_retry",
|
||||||
|
"description": "Discard any staged content for the current wizard step and reset it to pending so it can be regenerated. Use when the generated content needs improvement.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
@@ -1258,6 +1304,12 @@ async fn handle_tools_call(
|
|||||||
"status" => status_tools::tool_status(&args, ctx).await,
|
"status" => status_tools::tool_status(&args, ctx).await,
|
||||||
// File line count
|
// File line count
|
||||||
"loc_file" => diagnostics::tool_loc_file(&args, ctx),
|
"loc_file" => diagnostics::tool_loc_file(&args, ctx),
|
||||||
|
// Setup wizard tools
|
||||||
|
"wizard_status" => wizard_tools::tool_wizard_status(ctx),
|
||||||
|
"wizard_generate" => wizard_tools::tool_wizard_generate(&args, ctx),
|
||||||
|
"wizard_confirm" => wizard_tools::tool_wizard_confirm(ctx),
|
||||||
|
"wizard_skip" => wizard_tools::tool_wizard_skip(ctx),
|
||||||
|
"wizard_retry" => wizard_tools::tool_wizard_retry(ctx),
|
||||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1376,7 +1428,7 @@ mod tests {
|
|||||||
assert!(names.contains(&"git_log"));
|
assert!(names.contains(&"git_log"));
|
||||||
assert!(names.contains(&"status"));
|
assert!(names.contains(&"status"));
|
||||||
assert!(names.contains(&"loc_file"));
|
assert!(names.contains(&"loc_file"));
|
||||||
assert_eq!(tools.len(), 51);
|
assert_eq!(tools.len(), 56);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::agents::{
|
use crate::agents::{
|
||||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived,
|
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
||||||
};
|
};
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::{
|
use crate::http::workflow::{
|
||||||
@@ -246,7 +246,7 @@ pub(super) fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result<String
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
move_story_to_archived(&project_root, story_id)?;
|
move_story_to_done(&project_root, story_id)?;
|
||||||
ctx.agents.remove_agents_for_story(story_id);
|
ctx.agents.remove_agents_for_story(story_id);
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
@@ -1331,7 +1331,7 @@ mod tests {
|
|||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Create story file in current/ so move_story_to_archived would work.
|
// Create story file in current/ so move_story_to_done would work.
|
||||||
let current_dir = tmp.path().join(".storkit/work/2_current");
|
let current_dir = tmp.path().join(".storkit/work/2_current");
|
||||||
std::fs::create_dir_all(¤t_dir).unwrap();
|
std::fs::create_dir_all(¤t_dir).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
//! MCP tool implementations for the interactive setup wizard.
|
||||||
|
//!
|
||||||
|
//! These tools allow Claude Code (and other MCP clients) to drive the setup
|
||||||
|
//! wizard entirely from the terminal without requiring the web UI or chat bot.
|
||||||
|
//!
|
||||||
|
//! Typical flow:
|
||||||
|
//! 1. `wizard_status` — inspect current state
|
||||||
|
//! 2. `wizard_generate` — read the codebase and call again with `content` to
|
||||||
|
//! stage generated text for review
|
||||||
|
//! 3. `wizard_confirm` — write staged content to disk and advance the wizard
|
||||||
|
//! 4. `wizard_skip` — skip a step that does not apply
|
||||||
|
//! 5. `wizard_retry` — discard staged content and regenerate from scratch
|
||||||
|
|
||||||
|
use crate::http::context::AppContext;
|
||||||
|
use crate::io::wizard::{StepStatus, WizardState, WizardStep, format_wizard_state};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Return the filesystem path (relative to `project_root`) for a step's output.
|
||||||
|
///
|
||||||
|
/// Returns `None` for `Scaffold` since that step has no single output file — it
|
||||||
|
/// creates the full `.storkit/` directory structure and is handled by
|
||||||
|
/// `storkit init` before the server starts.
|
||||||
|
pub(crate) fn step_output_path(project_root: &Path, step: WizardStep) -> Option<std::path::PathBuf> {
|
||||||
|
match step {
|
||||||
|
WizardStep::Context => Some(
|
||||||
|
project_root
|
||||||
|
.join(".storkit")
|
||||||
|
.join("specs")
|
||||||
|
.join("00_CONTEXT.md"),
|
||||||
|
),
|
||||||
|
WizardStep::Stack => Some(
|
||||||
|
project_root
|
||||||
|
.join(".storkit")
|
||||||
|
.join("specs")
|
||||||
|
.join("tech")
|
||||||
|
.join("STACK.md"),
|
||||||
|
),
|
||||||
|
WizardStep::TestScript => Some(project_root.join("script").join("test")),
|
||||||
|
WizardStep::ReleaseScript => Some(project_root.join("script").join("release")),
|
||||||
|
WizardStep::TestCoverage => Some(project_root.join("script").join("test_coverage")),
|
||||||
|
WizardStep::Scaffold => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_script_step(step: WizardStep) -> bool {
|
||||||
|
matches!(
|
||||||
|
step,
|
||||||
|
WizardStep::TestScript | WizardStep::ReleaseScript | WizardStep::TestCoverage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `content` to `path` only when the file does not already exist.
|
||||||
|
///
|
||||||
|
/// Existing files (including `CLAUDE.md`) are never overwritten — the wizard
|
||||||
|
/// appends or skips per the acceptance criteria. For script steps the file is
|
||||||
|
/// also made executable after writing.
|
||||||
|
pub(crate) fn write_if_missing(path: &Path, content: &str, executable: bool) -> Result<bool, String> {
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(false); // already present — skip silently
|
||||||
|
}
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?;
|
||||||
|
}
|
||||||
|
fs::write(path, content)
|
||||||
|
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
||||||
|
|
||||||
|
if executable {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(path)
|
||||||
|
.map_err(|e| format!("Failed to read permissions: {e}"))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(path, perms)
|
||||||
|
.map_err(|e| format!("Failed to set permissions: {e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise a `WizardStep` to its snake_case string (e.g. `"test_script"`).
|
||||||
|
fn step_slug(step: WizardStep) -> String {
|
||||||
|
serde_json::to_value(step)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MCP tool handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// `wizard_status` — return current wizard state as a human-readable summary.
|
||||||
|
pub(super) fn tool_wizard_status(ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
let state =
|
||||||
|
WizardState::load(&root).ok_or("No wizard active. Run `storkit init` to begin setup.")?;
|
||||||
|
Ok(format_wizard_state(&state))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wizard_generate` — mark the current step as generating or stage content.
|
||||||
|
///
|
||||||
|
/// Call with no `content` argument to mark the step as `Generating` and
|
||||||
|
/// receive a hint describing what to generate. Call again with a `content`
|
||||||
|
/// argument (the generated file body) to stage it for review; the step will
|
||||||
|
/// transition to `AwaitingConfirmation`. Content is **not** written to disk
|
||||||
|
/// until `wizard_confirm` is called.
|
||||||
|
pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
let mut state = WizardState::load(&root).ok_or("No wizard active.")?;
|
||||||
|
|
||||||
|
if state.completed {
|
||||||
|
return Ok("Wizard is already complete.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_idx = state.current_step_index();
|
||||||
|
let step = state.steps[current_idx].step;
|
||||||
|
|
||||||
|
// If content is provided, stage it for confirmation.
|
||||||
|
if let Some(content) = args.get("content").and_then(|v| v.as_str()) {
|
||||||
|
state.set_step_status(
|
||||||
|
step,
|
||||||
|
StepStatus::AwaitingConfirmation,
|
||||||
|
Some(content.to_string()),
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.save(&root)
|
||||||
|
.map_err(|e| format!("Failed to save wizard state: {e}"))?;
|
||||||
|
return Ok(format!(
|
||||||
|
"Content staged for '{}'. Run `wizard_confirm` to write it to disk, `wizard_retry` to regenerate, or `wizard_skip` to skip.",
|
||||||
|
step.label()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No content provided — mark as generating and return a hint.
|
||||||
|
state.set_step_status(step, StepStatus::Generating, None);
|
||||||
|
state
|
||||||
|
.save(&root)
|
||||||
|
.map_err(|e| format!("Failed to save wizard state: {e}"))?;
|
||||||
|
|
||||||
|
let hint = generation_hint(step, &root);
|
||||||
|
let slug = step_slug(step);
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Step '{}' marked as generating.\n\n{hint}\n\nOnce you have the content, call `wizard_generate` again with a `content` argument (or PUT /wizard/step/{slug}/content). Then call `wizard_confirm` to write it to disk.",
|
||||||
|
step.label(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return true if the project directory has no meaningful source files.
|
||||||
|
fn is_bare_project(project_root: &Path) -> bool {
|
||||||
|
let dominated_by_storkit = std::fs::read_dir(project_root)
|
||||||
|
.ok()
|
||||||
|
.map(|entries| {
|
||||||
|
let names: Vec<String> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
// A bare project only has storkit scaffolding and no real code
|
||||||
|
names.iter().all(|n| {
|
||||||
|
n.starts_with('.')
|
||||||
|
|| n == "CLAUDE.md"
|
||||||
|
|| n == "LICENSE"
|
||||||
|
|| n == "README.md"
|
||||||
|
|| n == "script"
|
||||||
|
|| n == "store.json"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(true);
|
||||||
|
dominated_by_storkit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a generation hint for a step based on the project root.
|
||||||
|
fn generation_hint(step: WizardStep, project_root: &Path) -> String {
|
||||||
|
let bare = is_bare_project(project_root);
|
||||||
|
|
||||||
|
match step {
|
||||||
|
WizardStep::Context => {
|
||||||
|
if bare {
|
||||||
|
"This is a bare project with no existing code. Ask the user what they want \
|
||||||
|
to build — the project's purpose, goals, target users, and key features. \
|
||||||
|
Then generate `.storkit/specs/00_CONTEXT.md` from their answers covering:\n\
|
||||||
|
- High-level goal of the project\n\
|
||||||
|
- Core features\n\
|
||||||
|
- Domain concepts and entities\n\
|
||||||
|
- Glossary of abbreviations and technical terms".to_string()
|
||||||
|
} else {
|
||||||
|
"Read the project source tree and generate a `.storkit/specs/00_CONTEXT.md` describing:\n\
|
||||||
|
- High-level goal of the project\n\
|
||||||
|
- Core features\n\
|
||||||
|
- Domain concepts and entities\n\
|
||||||
|
- Glossary of abbreviations and technical terms".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WizardStep::Stack => {
|
||||||
|
if bare {
|
||||||
|
"This is a bare project with no existing code. Ask the user what language, \
|
||||||
|
frameworks, and tools they plan to use. Then generate `.storkit/specs/tech/STACK.md` \
|
||||||
|
from their answers covering:\n\
|
||||||
|
- Language, frameworks, and runtimes\n\
|
||||||
|
- Coding standards and linting rules\n\
|
||||||
|
- Quality gates (commands that must pass before merging)\n\
|
||||||
|
- Approved libraries and their purpose".to_string()
|
||||||
|
} else {
|
||||||
|
"Read the project source tree and generate a `.storkit/specs/tech/STACK.md` describing:\n\
|
||||||
|
- Language, frameworks, and runtimes\n\
|
||||||
|
- Coding standards and linting rules\n\
|
||||||
|
- Quality gates (commands that must pass before merging)\n\
|
||||||
|
- Approved libraries and their purpose".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WizardStep::TestScript => {
|
||||||
|
let has_cargo = project_root.join("Cargo.toml").exists();
|
||||||
|
let has_pkg = project_root.join("package.json").exists();
|
||||||
|
let has_pnpm = project_root.join("pnpm-lock.yaml").exists();
|
||||||
|
let mut cmds = Vec::new();
|
||||||
|
if has_cargo {
|
||||||
|
cmds.push("cargo nextest run");
|
||||||
|
}
|
||||||
|
if has_pkg {
|
||||||
|
cmds.push(if has_pnpm { "pnpm test" } else { "npm test" });
|
||||||
|
}
|
||||||
|
if cmds.is_empty() {
|
||||||
|
"Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's test suite.".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}",
|
||||||
|
cmds.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WizardStep::ReleaseScript => {
|
||||||
|
"Generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds and releases the project (e.g. `cargo build --release` or `npm run build`).".to_string()
|
||||||
|
}
|
||||||
|
WizardStep::TestCoverage => {
|
||||||
|
"Generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) that generates a test coverage report (e.g. `cargo llvm-cov nextest` or `npm run coverage`).".to_string()
|
||||||
|
}
|
||||||
|
WizardStep::Scaffold => "Scaffold step is handled automatically by `storkit init`.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wizard_confirm` — confirm the current step and write its content to disk.
|
||||||
|
///
|
||||||
|
/// If the step has staged content, the content is written to its target file
|
||||||
|
/// (only if that file does not already exist — existing files are never
|
||||||
|
/// overwritten). The step is then marked as `Confirmed` and the wizard
|
||||||
|
/// advances to the next pending step.
|
||||||
|
pub(super) fn tool_wizard_confirm(ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
let mut state = WizardState::load(&root).ok_or("No wizard active.")?;
|
||||||
|
|
||||||
|
if state.completed {
|
||||||
|
return Ok("Wizard is already complete.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_idx = state.current_step_index();
|
||||||
|
let step = state.steps[current_idx].step;
|
||||||
|
let content = state.steps[current_idx].content.clone();
|
||||||
|
|
||||||
|
// Write content to disk (only if a file path exists and the file is absent).
|
||||||
|
let write_msg = if let (Some(c), Some(ref path)) = (&content, step_output_path(&root, step)) {
|
||||||
|
let executable = is_script_step(step);
|
||||||
|
match write_if_missing(path, c, executable)? {
|
||||||
|
true => format!(" File written: `{}`.", path.display()),
|
||||||
|
false => format!(" File `{}` already exists — skipped.", path.display()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.confirm_step(step)
|
||||||
|
.map_err(|e| format!("Cannot confirm step: {e}"))?;
|
||||||
|
state
|
||||||
|
.save(&root)
|
||||||
|
.map_err(|e| format!("Failed to save wizard state: {e}"))?;
|
||||||
|
|
||||||
|
let next_idx = state.current_step_index();
|
||||||
|
if state.completed {
|
||||||
|
Ok(format!(
|
||||||
|
"Step '{}' confirmed.{write_msg}\n\nSetup wizard complete! All steps done.",
|
||||||
|
step.label()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
let next = &state.steps[next_idx];
|
||||||
|
Ok(format!(
|
||||||
|
"Step '{}' confirmed.{write_msg}\n\nNext: {} — run `wizard_generate` to begin.",
|
||||||
|
step.label(),
|
||||||
|
next.step.label()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wizard_skip` — skip the current step without writing any file.
|
||||||
|
pub(super) fn tool_wizard_skip(ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
let mut state = WizardState::load(&root).ok_or("No wizard active.")?;
|
||||||
|
|
||||||
|
if state.completed {
|
||||||
|
return Ok("Wizard is already complete.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_idx = state.current_step_index();
|
||||||
|
let step = state.steps[current_idx].step;
|
||||||
|
|
||||||
|
state
|
||||||
|
.skip_step(step)
|
||||||
|
.map_err(|e| format!("Cannot skip step: {e}"))?;
|
||||||
|
state
|
||||||
|
.save(&root)
|
||||||
|
.map_err(|e| format!("Failed to save wizard state: {e}"))?;
|
||||||
|
|
||||||
|
let next_idx = state.current_step_index();
|
||||||
|
if state.completed {
|
||||||
|
Ok(format!(
|
||||||
|
"Step '{}' skipped. Setup wizard complete!",
|
||||||
|
step.label()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
let next = &state.steps[next_idx];
|
||||||
|
Ok(format!(
|
||||||
|
"Step '{}' skipped.\n\nNext: {} — run `wizard_generate` to begin.",
|
||||||
|
step.label(),
|
||||||
|
next.step.label()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wizard_retry` — discard staged content and reset the current step to
|
||||||
|
/// `Pending` so it can be regenerated.
|
||||||
|
pub(super) fn tool_wizard_retry(ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
let mut state = WizardState::load(&root).ok_or("No wizard active.")?;
|
||||||
|
|
||||||
|
if state.completed {
|
||||||
|
return Ok("Wizard is already complete.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_idx = state.current_step_index();
|
||||||
|
let step = state.steps[current_idx].step;
|
||||||
|
|
||||||
|
// Clear content and reset to pending.
|
||||||
|
if let Some(s) = state.steps.iter_mut().find(|s| s.step == step) {
|
||||||
|
s.status = StepStatus::Pending;
|
||||||
|
s.content = None;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.save(&root)
|
||||||
|
.map_err(|e| format!("Failed to save wizard state: {e}"))?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Step '{}' reset to pending. Run `wizard_generate` to regenerate content.",
|
||||||
|
step.label()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::http::context::AppContext;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup(dir: &TempDir) -> AppContext {
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
std::fs::create_dir_all(root.join(".storkit")).unwrap();
|
||||||
|
WizardState::init_if_missing(&root);
|
||||||
|
AppContext::new_test(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_status_returns_state() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = setup(&dir);
|
||||||
|
let result = tool_wizard_status(&ctx).unwrap();
|
||||||
|
assert!(result.contains("Setup wizard"));
|
||||||
|
assert!(result.contains("context"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_status_no_wizard_returns_error() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||||
|
assert!(tool_wizard_status(&ctx).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_generate_marks_generating() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = setup(&dir);
|
||||||
|
let result = tool_wizard_generate(&serde_json::json!({}), &ctx).unwrap();
|
||||||
|
assert!(result.contains("generating"));
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::Generating);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_generate_with_content_stages_content() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = setup(&dir);
|
||||||
|
let result = tool_wizard_generate(
|
||||||
|
&serde_json::json!({"content": "# My Project"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.contains("staged"));
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
|
||||||
|
assert_eq!(state.steps[1].content.as_deref(), Some("# My Project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_confirm_writes_file_and_advances() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = setup(&dir);
|
||||||
|
// Stage content for Context step.
|
||||||
|
tool_wizard_generate(
|
||||||
|
&serde_json::json!({"content": "# Context content"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = tool_wizard_confirm(&ctx).unwrap();
|
||||||
|
assert!(result.contains("confirmed"));
|
||||||
|
// File should now exist.
|
||||||
|
let context_path = dir
|
||||||
|
.path()
|
||||||
|
.join(".storkit")
|
||||||
|
.join("specs")
|
||||||
|
.join("00_CONTEXT.md");
|
||||||
|
assert!(context_path.exists());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&context_path).unwrap(),
|
||||||
|
"# Context content"
|
||||||
|
);
|
||||||
|
// Wizard should have advanced.
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::Confirmed);
|
||||||
|
assert_eq!(state.current_step_index(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_confirm_does_not_overwrite_existing_file() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = setup(&dir);
|
||||||
|
// Pre-create the specs directory and file.
|
||||||
|
let specs_dir = dir.path().join(".storkit").join("specs");
|
||||||
|
std::fs::create_dir_all(&specs_dir).unwrap();
|
||||||
|
let context_path = specs_dir.join("00_CONTEXT.md");
|
||||||
|
std::fs::write(&context_path, "original content").unwrap();
|
||||||
|
|
||||||
|
// Stage and confirm — existing file should NOT be overwritten.
|
||||||
|
tool_wizard_generate(
|
||||||
|
&serde_json::json!({"content": "new content"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = tool_wizard_confirm(&ctx).unwrap();
|
||||||
|
assert!(result.contains("already exists"));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&context_path).unwrap(),
|
||||||
|
"original content"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_skip_advances_wizard() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = setup(&dir);
|
||||||
|
let result = tool_wizard_skip(&ctx).unwrap();
|
||||||
|
assert!(result.contains("skipped"));
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::Skipped);
|
||||||
|
assert_eq!(state.current_step_index(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_retry_resets_to_pending() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = setup(&dir);
|
||||||
|
// Stage content first.
|
||||||
|
tool_wizard_generate(
|
||||||
|
&serde_json::json!({"content": "some content"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = tool_wizard_retry(&ctx).unwrap();
|
||||||
|
assert!(result.contains("reset"));
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::Pending);
|
||||||
|
assert!(state.steps[1].content.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_complete_returns_done_message() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = setup(&dir);
|
||||||
|
// Skip all remaining steps.
|
||||||
|
for _ in 0..5 {
|
||||||
|
tool_wizard_skip(&ctx).unwrap();
|
||||||
|
}
|
||||||
|
let result = tool_wizard_status(&ctx).unwrap();
|
||||||
|
assert!(result.contains("complete"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_wizard_state_shows_all_steps() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
let output = format_wizard_state(&state);
|
||||||
|
assert!(output.contains("✓"));
|
||||||
|
assert!(output.contains("Scaffold"));
|
||||||
|
assert!(output.contains("← current"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod settings;
|
|||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
pub mod wizard;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
use agents::AgentsApi;
|
use agents::AgentsApi;
|
||||||
@@ -131,6 +132,7 @@ type ApiTuple = (
|
|||||||
SettingsApi,
|
SettingsApi,
|
||||||
HealthApi,
|
HealthApi,
|
||||||
BotCommandApi,
|
BotCommandApi,
|
||||||
|
wizard::WizardApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||||
@@ -147,6 +149,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
SettingsApi { ctx: ctx.clone() },
|
SettingsApi { ctx: ctx.clone() },
|
||||||
HealthApi,
|
HealthApi,
|
||||||
BotCommandApi { ctx: ctx.clone() },
|
BotCommandApi { ctx: ctx.clone() },
|
||||||
|
wizard::WizardApi { ctx: ctx.clone() },
|
||||||
);
|
);
|
||||||
|
|
||||||
let api_service =
|
let api_service =
|
||||||
@@ -161,7 +164,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
AgentsApi { ctx: ctx.clone() },
|
AgentsApi { ctx: ctx.clone() },
|
||||||
SettingsApi { ctx: ctx.clone() },
|
SettingsApi { ctx: ctx.clone() },
|
||||||
HealthApi,
|
HealthApi,
|
||||||
BotCommandApi { ctx },
|
BotCommandApi { ctx: ctx.clone() },
|
||||||
|
wizard::WizardApi { ctx },
|
||||||
);
|
);
|
||||||
|
|
||||||
let docs_service =
|
let docs_service =
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
|
||||||
|
use crate::io::wizard::{StepStatus, WizardState, WizardStep};
|
||||||
|
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Tags)]
|
||||||
|
enum WizardTags {
|
||||||
|
Wizard,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for a single wizard step.
|
||||||
|
#[derive(Serialize, Object)]
|
||||||
|
struct StepResponse {
|
||||||
|
step: String,
|
||||||
|
label: String,
|
||||||
|
status: String,
|
||||||
|
#[oai(skip_serializing_if = "Option::is_none")]
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full wizard state response.
|
||||||
|
#[derive(Serialize, Object)]
|
||||||
|
struct WizardResponse {
|
||||||
|
steps: Vec<StepResponse>,
|
||||||
|
current_step_index: usize,
|
||||||
|
completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for confirming/skipping a step or submitting content.
|
||||||
|
#[derive(Deserialize, Object)]
|
||||||
|
struct StepActionPayload {
|
||||||
|
/// Optional content to store for the step (e.g., generated spec).
|
||||||
|
#[oai(skip_serializing_if = "Option::is_none")]
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&WizardState> for WizardResponse {
|
||||||
|
fn from(state: &WizardState) -> Self {
|
||||||
|
WizardResponse {
|
||||||
|
steps: state
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|s| StepResponse {
|
||||||
|
step: serde_json::to_value(s.step)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
label: s.step.label().to_string(),
|
||||||
|
status: serde_json::to_value(&s.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
content: s.content.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
current_step_index: state.current_step_index(),
|
||||||
|
completed: state.completed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_step(step_str: &str) -> Result<WizardStep, poem::Error> {
|
||||||
|
let quoted = format!("\"{step_str}\"");
|
||||||
|
serde_json::from_str::<WizardStep>("ed)
|
||||||
|
.map_err(|_| not_found(format!("Unknown wizard step: {step_str}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WizardApi {
|
||||||
|
pub ctx: Arc<AppContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OpenApi(tag = "WizardTags::Wizard")]
|
||||||
|
impl WizardApi {
|
||||||
|
/// Get the current wizard state.
|
||||||
|
///
|
||||||
|
/// Returns the full setup wizard progress including all steps and their
|
||||||
|
/// statuses. Returns 404 if no wizard is active.
|
||||||
|
#[oai(path = "/wizard", method = "get")]
|
||||||
|
async fn get_wizard_state(&self) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a step's content and mark it as awaiting confirmation.
|
||||||
|
///
|
||||||
|
/// Used after the agent generates content for a step. The content is
|
||||||
|
/// stored for preview and the step is marked as awaiting user confirmation.
|
||||||
|
#[oai(path = "/wizard/step/:step/content", method = "put")]
|
||||||
|
async fn set_step_content(
|
||||||
|
&self,
|
||||||
|
step: Path<String>,
|
||||||
|
payload: Json<StepActionPayload>,
|
||||||
|
) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let wizard_step = parse_step(&step.0)?;
|
||||||
|
let mut state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
|
||||||
|
state.set_step_status(
|
||||||
|
wizard_step,
|
||||||
|
StepStatus::AwaitingConfirmation,
|
||||||
|
payload.0.content,
|
||||||
|
);
|
||||||
|
state.save(&root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm a step and advance to the next.
|
||||||
|
///
|
||||||
|
/// The step must be the current active step. Returns the updated wizard state.
|
||||||
|
#[oai(path = "/wizard/step/:step/confirm", method = "post")]
|
||||||
|
async fn confirm_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let wizard_step = parse_step(&step.0)?;
|
||||||
|
let mut state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
|
||||||
|
state.confirm_step(wizard_step).map_err(bad_request)?;
|
||||||
|
state.save(&root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip a step and advance to the next.
|
||||||
|
///
|
||||||
|
/// The step must be the current active step.
|
||||||
|
#[oai(path = "/wizard/step/:step/skip", method = "post")]
|
||||||
|
async fn skip_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let wizard_step = parse_step(&step.0)?;
|
||||||
|
let mut state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
|
||||||
|
state.skip_step(wizard_step).map_err(bad_request)?;
|
||||||
|
state.save(&root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a step as generating (agent is working on it).
|
||||||
|
#[oai(path = "/wizard/step/:step/generating", method = "post")]
|
||||||
|
async fn mark_generating(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let wizard_step = parse_step(&step.0)?;
|
||||||
|
let mut state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
|
||||||
|
state.set_step_status(wizard_step, StepStatus::Generating, None);
|
||||||
|
state.save(&root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::http::context::AppContext;
|
||||||
|
use poem::http::StatusCode;
|
||||||
|
use poem::test::TestClient;
|
||||||
|
use poem_openapi::OpenApiService;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup() -> (TempDir, TestClient<impl poem::Endpoint>) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
std::fs::create_dir_all(root.join(".storkit")).unwrap();
|
||||||
|
|
||||||
|
let ctx = Arc::new(AppContext::new_test(root.clone()));
|
||||||
|
let api = WizardApi { ctx };
|
||||||
|
let service = OpenApiService::new(api, "test", "0.1.0");
|
||||||
|
let client = TestClient::new(service);
|
||||||
|
(dir, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_wizard_returns_404_when_no_wizard() {
|
||||||
|
let (_dir, client) = setup();
|
||||||
|
let resp = client.get("/wizard").send().await;
|
||||||
|
resp.assert_status(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_wizard_returns_state_when_active() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client.get("/wizard").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["current_step_index"], 1);
|
||||||
|
assert!(!body["completed"].as_bool().unwrap());
|
||||||
|
assert_eq!(body["steps"].as_array().unwrap().len(), 6);
|
||||||
|
assert_eq!(body["steps"][0]["status"], "confirmed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn confirm_step_advances_wizard() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client.post("/wizard/step/context/confirm").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["current_step_index"], 2);
|
||||||
|
assert_eq!(body["steps"][1]["status"], "confirmed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn confirm_wrong_step_returns_error() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
// Try to confirm step 3 (stack) when current is step 2 (context)
|
||||||
|
let resp = client.post("/wizard/step/stack/confirm").send().await;
|
||||||
|
resp.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn skip_step_advances_wizard() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client.post("/wizard/step/context/skip").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["steps"][1]["status"], "skipped");
|
||||||
|
assert_eq!(body["current_step_index"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_step_content_marks_awaiting_confirmation() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.put("/wizard/step/context/content")
|
||||||
|
.body_json(&serde_json::json!({
|
||||||
|
"content": "# My Project\n\nA great project."
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["steps"][1]["status"], "awaiting_confirmation");
|
||||||
|
assert_eq!(
|
||||||
|
body["steps"][1]["content"],
|
||||||
|
"# My Project\n\nA great project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_generating_updates_step() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("/wizard/step/context/generating")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["steps"][1]["status"], "generating");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_step_returns_404() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("/wizard/step/nonexistent/confirm")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
resp.assert_status(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_wizard_flow_completes() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
// Steps 2-6 (scaffold is already confirmed)
|
||||||
|
let steps = ["context", "stack", "test_script", "release_script", "test_coverage"];
|
||||||
|
for step in steps {
|
||||||
|
let resp = client
|
||||||
|
.post(format!("/wizard/step/{step}/confirm"))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check final state
|
||||||
|
let resp = client.get("/wizard").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert!(body["completed"].as_bool().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ use crate::http::context::{AppContext, PermissionDecision};
|
|||||||
use crate::http::workflow::{PipelineState, load_pipeline_state};
|
use crate::http::workflow::{PipelineState, load_pipeline_state};
|
||||||
use crate::io::onboarding;
|
use crate::io::onboarding;
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
|
use crate::io::wizard;
|
||||||
use crate::llm::chat;
|
use crate::llm::chat;
|
||||||
use crate::llm::types::Message;
|
use crate::llm::types::Message;
|
||||||
use crate::log_buffer;
|
use crate::log_buffer;
|
||||||
@@ -46,6 +47,16 @@ enum WsRequest {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialisable summary of a single wizard step for WebSocket broadcast.
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct WizardStepInfo {
|
||||||
|
pub step: String,
|
||||||
|
pub label: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
/// WebSocket response messages sent by the server.
|
/// WebSocket response messages sent by the server.
|
||||||
@@ -125,6 +136,13 @@ enum WsResponse {
|
|||||||
OnboardingStatus {
|
OnboardingStatus {
|
||||||
needs_onboarding: bool,
|
needs_onboarding: bool,
|
||||||
},
|
},
|
||||||
|
/// Sent on connect when a setup wizard is active. Contains the full
|
||||||
|
/// wizard state so the frontend can render the step-by-step UI.
|
||||||
|
WizardState {
|
||||||
|
steps: Vec<WizardStepInfo>,
|
||||||
|
current_step_index: usize,
|
||||||
|
completed: bool,
|
||||||
|
},
|
||||||
/// Streaming token from a `/btw` side question response.
|
/// Streaming token from a `/btw` side question response.
|
||||||
SideQuestionToken {
|
SideQuestionToken {
|
||||||
content: String,
|
content: String,
|
||||||
@@ -219,6 +237,35 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push wizard state if an active wizard exists.
|
||||||
|
{
|
||||||
|
if let Ok(root) = ctx.state.get_project_root()
|
||||||
|
&& let Some(ws) = wizard::WizardState::load(&root)
|
||||||
|
{
|
||||||
|
let steps: Vec<WizardStepInfo> = ws
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|s| WizardStepInfo {
|
||||||
|
step: serde_json::to_value(s.step)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
label: s.step.label().to_string(),
|
||||||
|
status: serde_json::to_value(&s.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
content: s.content.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let _ = tx.send(WsResponse::WizardState {
|
||||||
|
steps,
|
||||||
|
current_step_index: ws.current_step_index(),
|
||||||
|
completed: ws.completed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Push recent server log entries so the client has history on connect.
|
// Push recent server log entries so the client has history on connect.
|
||||||
{
|
{
|
||||||
let entries = log_buffer::global().get_recent_entries(100, None, None);
|
let entries = log_buffer::global().get_recent_entries(100, None, None);
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ The permission system validates the entire command string, and chained commands
|
|||||||
won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \
|
won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \
|
||||||
parallel calls work fine.\n\
|
parallel calls work fine.\n\
|
||||||
\n\
|
\n\
|
||||||
Read .storkit/README.md to see our dev process.\n";
|
Read .storkit/README.md to see our dev process.\n\
|
||||||
|
\n\
|
||||||
|
IMPORTANT: On your first conversation, call `wizard_status` to check if \
|
||||||
|
project setup is complete. If not, read .storkit/README.md for the full \
|
||||||
|
setup wizard instructions and guide the user through it conversationally.\n";
|
||||||
|
|
||||||
const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
|
const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
@@ -289,6 +293,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
|||||||
"work/4_merge/",
|
"work/4_merge/",
|
||||||
"logs/",
|
"logs/",
|
||||||
"token_usage.jsonl",
|
"token_usage.jsonl",
|
||||||
|
"wizard_state.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
let gitignore_path = root.join(".storkit").join(".gitignore");
|
let gitignore_path = root.join(".storkit").join(".gitignore");
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ pub mod search;
|
|||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod story_metadata;
|
pub mod story_metadata;
|
||||||
pub mod watcher;
|
pub mod watcher;
|
||||||
|
pub mod wizard;
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Ordered wizard steps for project setup.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WizardStep {
|
||||||
|
/// Step 1: scaffold .storkit/ directory structure and project.toml
|
||||||
|
Scaffold,
|
||||||
|
/// Step 2: generate specs/00_CONTEXT.md
|
||||||
|
Context,
|
||||||
|
/// Step 3: generate specs/tech/STACK.md
|
||||||
|
Stack,
|
||||||
|
/// Step 4: create script/test
|
||||||
|
TestScript,
|
||||||
|
/// Step 5: create script/release
|
||||||
|
ReleaseScript,
|
||||||
|
/// Step 6: create script/test_coverage
|
||||||
|
TestCoverage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WizardStep {
|
||||||
|
/// All steps in order.
|
||||||
|
pub const ALL: &[WizardStep] = &[
|
||||||
|
WizardStep::Scaffold,
|
||||||
|
WizardStep::Context,
|
||||||
|
WizardStep::Stack,
|
||||||
|
WizardStep::TestScript,
|
||||||
|
WizardStep::ReleaseScript,
|
||||||
|
WizardStep::TestCoverage,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Human-readable label for this step.
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
WizardStep::Scaffold => "Scaffold directory structure",
|
||||||
|
WizardStep::Context => "Generate project context (00_CONTEXT.md)",
|
||||||
|
WizardStep::Stack => "Generate tech stack spec (STACK.md)",
|
||||||
|
WizardStep::TestScript => "Create test script (script/test)",
|
||||||
|
WizardStep::ReleaseScript => "Create release script (script/release)",
|
||||||
|
WizardStep::TestCoverage => "Create test coverage script (script/test_coverage)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero-based index of this step.
|
||||||
|
pub fn index(&self) -> usize {
|
||||||
|
Self::ALL.iter().position(|s| s == self).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of an individual wizard step.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StepStatus {
|
||||||
|
/// Not yet started.
|
||||||
|
Pending,
|
||||||
|
/// Agent is generating content for this step.
|
||||||
|
Generating,
|
||||||
|
/// Content generated, awaiting user confirmation.
|
||||||
|
AwaitingConfirmation,
|
||||||
|
/// User confirmed this step.
|
||||||
|
Confirmed,
|
||||||
|
/// User skipped this step.
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of a single wizard step.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StepState {
|
||||||
|
pub step: WizardStep,
|
||||||
|
pub status: StepStatus,
|
||||||
|
/// The generated content (if any) for preview.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent wizard state, stored in `.storkit/wizard_state.json`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WizardState {
|
||||||
|
pub steps: Vec<StepState>,
|
||||||
|
/// True when all steps are confirmed or skipped.
|
||||||
|
pub completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WizardState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
steps: WizardStep::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|&step| StepState {
|
||||||
|
step,
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
content: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
completed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WizardState {
|
||||||
|
/// Path to the wizard state file relative to the project root.
|
||||||
|
fn state_path(project_root: &Path) -> std::path::PathBuf {
|
||||||
|
project_root.join(".storkit").join("wizard_state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load wizard state from disk, or return None if it doesn't exist.
|
||||||
|
pub fn load(project_root: &Path) -> Option<Self> {
|
||||||
|
let path = Self::state_path(project_root);
|
||||||
|
let content = fs::read_to_string(&path).ok()?;
|
||||||
|
serde_json::from_str(&content).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save wizard state to disk.
|
||||||
|
pub fn save(&self, project_root: &Path) -> Result<(), String> {
|
||||||
|
let path = Self::state_path(project_root);
|
||||||
|
let content =
|
||||||
|
serde_json::to_string_pretty(self).map_err(|e| format!("Serialize error: {e}"))?;
|
||||||
|
fs::write(&path, content).map_err(|e| format!("Failed to write wizard state: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create wizard state file if it doesn't already exist.
|
||||||
|
/// Step 1 (Scaffold) is automatically confirmed since `storkit init`
|
||||||
|
/// has already run the scaffold.
|
||||||
|
pub fn init_if_missing(project_root: &Path) {
|
||||||
|
if Self::load(project_root).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut state = Self::default();
|
||||||
|
// Scaffold step is done by the time the server starts.
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
let _ = state.save(project_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current step index (0-based).
|
||||||
|
pub fn current_step_index(&self) -> usize {
|
||||||
|
self.steps
|
||||||
|
.iter()
|
||||||
|
.position(|s| !matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped))
|
||||||
|
.unwrap_or(self.steps.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a step's status and update completion state.
|
||||||
|
pub fn set_step_status(
|
||||||
|
&mut self,
|
||||||
|
step: WizardStep,
|
||||||
|
status: StepStatus,
|
||||||
|
content: Option<String>,
|
||||||
|
) {
|
||||||
|
if let Some(s) = self.steps.iter_mut().find(|s| s.step == step) {
|
||||||
|
s.status = status;
|
||||||
|
if content.is_some() {
|
||||||
|
s.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.completed = self
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.all(|s| matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm a step. Returns error if the step is not the current one
|
||||||
|
/// (enforces sequential progression).
|
||||||
|
pub fn confirm_step(&mut self, step: WizardStep) -> Result<(), String> {
|
||||||
|
let current_idx = self.current_step_index();
|
||||||
|
let target_idx = step.index();
|
||||||
|
if target_idx != current_idx {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot confirm step {:?}: current step is {}",
|
||||||
|
step, current_idx
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.set_step_status(step, StepStatus::Confirmed, None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip a step. Only the current step can be skipped.
|
||||||
|
pub fn skip_step(&mut self, step: WizardStep) -> Result<(), String> {
|
||||||
|
let current_idx = self.current_step_index();
|
||||||
|
let target_idx = step.index();
|
||||||
|
if target_idx != current_idx {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot skip step {:?}: current step is {}",
|
||||||
|
step, current_idx
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.set_step_status(step, StepStatus::Skipped, None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a `WizardState` as a human-readable Markdown summary for display in
|
||||||
|
/// bot messages and MCP responses.
|
||||||
|
pub fn format_wizard_state(state: &WizardState) -> String {
|
||||||
|
let total = state.steps.len();
|
||||||
|
let current_idx = state.current_step_index();
|
||||||
|
|
||||||
|
let header = if state.completed {
|
||||||
|
format!("**Setup wizard — complete** ({total}/{total} steps done)")
|
||||||
|
} else {
|
||||||
|
format!("**Setup wizard — step {}/{}**", current_idx + 1, total)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = vec![header, String::new()];
|
||||||
|
|
||||||
|
for (i, step) in state.steps.iter().enumerate() {
|
||||||
|
let marker = match step.status {
|
||||||
|
StepStatus::Confirmed => "✓",
|
||||||
|
StepStatus::Skipped => "~",
|
||||||
|
StepStatus::Generating => "⟳",
|
||||||
|
StepStatus::AwaitingConfirmation => "?",
|
||||||
|
StepStatus::Pending => "○",
|
||||||
|
};
|
||||||
|
let is_current = !state.completed && i == current_idx;
|
||||||
|
let suffix = if is_current { " ← current" } else { "" };
|
||||||
|
let status_str = serde_json::to_value(&step.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default();
|
||||||
|
lines.push(format!(
|
||||||
|
" {} {} ({}){suffix}",
|
||||||
|
marker,
|
||||||
|
step.step.label(),
|
||||||
|
status_str
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.completed {
|
||||||
|
lines.push(String::new());
|
||||||
|
lines.push("All steps done. Your project is fully configured.".to_string());
|
||||||
|
} else {
|
||||||
|
let current = &state.steps[current_idx];
|
||||||
|
lines.push(String::new());
|
||||||
|
lines.push(format!("**Current:** {}", current.step.label()));
|
||||||
|
let hint = match current.status {
|
||||||
|
StepStatus::Pending => {
|
||||||
|
"Ready to generate. Proceed by calling wizard_generate.".to_string()
|
||||||
|
}
|
||||||
|
StepStatus::Generating => "Generating content…".to_string(),
|
||||||
|
StepStatus::AwaitingConfirmation => {
|
||||||
|
"Content ready for review. Show it to the user and ask if they're happy with it. Then call wizard_confirm, wizard_retry, or wizard_skip based on their response.".to_string()
|
||||||
|
}
|
||||||
|
StepStatus::Confirmed | StepStatus::Skipped => String::new(),
|
||||||
|
};
|
||||||
|
if !hint.is_empty() {
|
||||||
|
lines.push(hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup_project(dir: &TempDir) -> std::path::PathBuf {
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
let sk = root.join(".storkit");
|
||||||
|
std::fs::create_dir_all(&sk).unwrap();
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_state_has_all_steps_pending() {
|
||||||
|
let state = WizardState::default();
|
||||||
|
assert_eq!(state.steps.len(), 6);
|
||||||
|
for step in &state.steps {
|
||||||
|
assert_eq!(step.status, StepStatus::Pending);
|
||||||
|
}
|
||||||
|
assert!(!state.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_if_missing_creates_state_with_scaffold_confirmed() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = setup_project(&dir);
|
||||||
|
|
||||||
|
WizardState::init_if_missing(&root);
|
||||||
|
|
||||||
|
let state = WizardState::load(&root).unwrap();
|
||||||
|
assert_eq!(state.steps[0].status, StepStatus::Confirmed);
|
||||||
|
assert_eq!(state.steps[0].step, WizardStep::Scaffold);
|
||||||
|
// Rest should be pending
|
||||||
|
for step in &state.steps[1..] {
|
||||||
|
assert_eq!(step.status, StepStatus::Pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_if_missing_does_not_overwrite_existing() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = setup_project(&dir);
|
||||||
|
|
||||||
|
// Create a custom state
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
state.steps[1].status = StepStatus::Confirmed;
|
||||||
|
state.save(&root).unwrap();
|
||||||
|
|
||||||
|
// init_if_missing should not overwrite
|
||||||
|
WizardState::init_if_missing(&root);
|
||||||
|
|
||||||
|
let loaded = WizardState::load(&root).unwrap();
|
||||||
|
assert_eq!(loaded.steps[1].status, StepStatus::Confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_round_trip() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = setup_project(&dir);
|
||||||
|
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
state.steps[1].status = StepStatus::AwaitingConfirmation;
|
||||||
|
state.steps[1].content = Some("# My Project\n\nA cool project.".to_string());
|
||||||
|
state.save(&root).unwrap();
|
||||||
|
|
||||||
|
let loaded = WizardState::load(&root).unwrap();
|
||||||
|
assert_eq!(loaded.steps[0].status, StepStatus::Confirmed);
|
||||||
|
assert_eq!(loaded.steps[1].status, StepStatus::AwaitingConfirmation);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.steps[1].content.as_deref(),
|
||||||
|
Some("# My Project\n\nA cool project.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_step_index_correct() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
assert_eq!(state.current_step_index(), 1);
|
||||||
|
|
||||||
|
state.steps[1].status = StepStatus::Skipped;
|
||||||
|
assert_eq!(state.current_step_index(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_step_enforces_order() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
|
||||||
|
// Can confirm the current step (Context, index 1)
|
||||||
|
assert!(state.confirm_step(WizardStep::Context).is_ok());
|
||||||
|
|
||||||
|
// Cannot confirm a step that's not current
|
||||||
|
assert!(state.confirm_step(WizardStep::TestScript).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skip_step_works() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
|
||||||
|
assert!(state.skip_step(WizardStep::Context).is_ok());
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::Skipped);
|
||||||
|
assert_eq!(state.current_step_index(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_when_all_confirmed_or_skipped() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
for step in WizardStep::ALL {
|
||||||
|
state.set_step_status(*step, StepStatus::Confirmed, None);
|
||||||
|
}
|
||||||
|
assert!(state.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_completed_when_some_pending() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.set_step_status(WizardStep::Scaffold, StepStatus::Confirmed, None);
|
||||||
|
assert!(!state.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_step_status_with_content() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.set_step_status(
|
||||||
|
WizardStep::Context,
|
||||||
|
StepStatus::AwaitingConfirmation,
|
||||||
|
Some("generated content".to_string()),
|
||||||
|
);
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
|
||||||
|
assert_eq!(
|
||||||
|
state.steps[1].content.as_deref(),
|
||||||
|
Some("generated content")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_returns_none_when_no_file() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
assert!(WizardState::load(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_labels_are_non_empty() {
|
||||||
|
for step in WizardStep::ALL {
|
||||||
|
assert!(!step.label().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_indices_are_sequential() {
|
||||||
|
for (i, step) in WizardStep::ALL.iter().enumerate() {
|
||||||
|
assert_eq!(step.index(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
-110
@@ -32,61 +32,78 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
/// What the first CLI argument means.
|
/// Parsed CLI arguments.
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
enum CliDirective {
|
struct CliArgs {
|
||||||
/// `--help` / `-h`
|
/// Value from `--port <VALUE>` flag, if supplied.
|
||||||
Help,
|
port: Option<u16>,
|
||||||
/// `--version` / `-V`
|
/// Positional project path argument, if supplied.
|
||||||
Version,
|
path: Option<String>,
|
||||||
/// An unrecognised flag (starts with `-`).
|
/// Whether the `init` subcommand was given.
|
||||||
UnknownFlag(String),
|
init: bool,
|
||||||
/// A positional path argument.
|
|
||||||
Path,
|
|
||||||
/// No arguments at all.
|
|
||||||
None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inspect the raw CLI arguments and return the directive they imply.
|
/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`.
|
||||||
fn classify_cli_args(args: &[String]) -> CliDirective {
|
fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
|
||||||
match args.first().map(String::as_str) {
|
let mut port: Option<u16> = None;
|
||||||
None => CliDirective::None,
|
let mut path: Option<String> = None;
|
||||||
Some("--help" | "-h") => CliDirective::Help,
|
let mut init = false;
|
||||||
Some("--version" | "-V") => CliDirective::Version,
|
let mut i = 0;
|
||||||
Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()),
|
|
||||||
Some(_) => CliDirective::Path,
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
"--version" | "-V" => {
|
||||||
|
println!("storkit {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
"--port" => {
|
||||||
|
i += 1;
|
||||||
|
if i >= args.len() {
|
||||||
|
return Err("--port requires a value".to_string());
|
||||||
|
}
|
||||||
|
match args[i].parse::<u16>() {
|
||||||
|
Ok(p) => port = Some(p),
|
||||||
|
Err(_) => return Err(format!("invalid port value: '{}'", args[i])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a if a.starts_with("--port=") => {
|
||||||
|
let val = &a["--port=".len()..];
|
||||||
|
match val.parse::<u16>() {
|
||||||
|
Ok(p) => port = Some(p),
|
||||||
|
Err(_) => return Err(format!("invalid port value: '{val}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"init" => {
|
||||||
|
init = true;
|
||||||
|
}
|
||||||
|
a if a.starts_with('-') => {
|
||||||
|
return Err(format!("unknown option: {a}"));
|
||||||
|
}
|
||||||
|
a => {
|
||||||
|
if path.is_some() {
|
||||||
|
return Err(format!("unexpected argument: {a}"));
|
||||||
|
}
|
||||||
|
path = Some(a.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CliArgs { port, path, init })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the optional positional path argument (everything after the binary
|
fn print_help() {
|
||||||
/// name) into an absolute `PathBuf`. Returns `None` when no argument was
|
println!("storkit [OPTIONS] [PATH]");
|
||||||
/// supplied so that the caller can fall back to the auto-detect behaviour.
|
println!("storkit init [OPTIONS] [PATH]");
|
||||||
fn parse_project_path_arg(args: &[String], cwd: &std::path::Path) -> Option<PathBuf> {
|
|
||||||
args.first().map(|s| io::fs::resolve_cli_path(cwd, s))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
|
||||||
let app_state = Arc::new(SessionState::default());
|
|
||||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
||||||
let store = Arc::new(
|
|
||||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
|
||||||
);
|
|
||||||
|
|
||||||
let port = resolve_port();
|
|
||||||
|
|
||||||
// Collect CLI args, skipping the binary name (argv[0]).
|
|
||||||
let cli_args: Vec<String> = std::env::args().skip(1).collect();
|
|
||||||
|
|
||||||
// Handle CLI flags before treating anything as a project path.
|
|
||||||
match classify_cli_args(&cli_args) {
|
|
||||||
CliDirective::Help => {
|
|
||||||
println!("storkit [PATH]");
|
|
||||||
println!();
|
println!();
|
||||||
println!("Serve a storkit project.");
|
println!("Serve a storkit project.");
|
||||||
println!();
|
println!();
|
||||||
println!("USAGE:");
|
println!("COMMANDS:");
|
||||||
println!(" storkit [PATH]");
|
println!(" init Scaffold a new .storkit/ project and start the interactive setup wizard.");
|
||||||
println!();
|
println!();
|
||||||
println!("ARGS:");
|
println!("ARGS:");
|
||||||
println!(
|
println!(
|
||||||
@@ -97,21 +114,41 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
println!("OPTIONS:");
|
println!("OPTIONS:");
|
||||||
println!(" -h, --help Print this help and exit");
|
println!(" -h, --help Print this help and exit");
|
||||||
println!(" -V, --version Print the version and exit");
|
println!(" -V, --version Print the version and exit");
|
||||||
std::process::exit(0);
|
println!(" --port <PORT> Port to listen on (default: 3001). Persisted to project.toml.");
|
||||||
}
|
}
|
||||||
CliDirective::Version => {
|
|
||||||
println!("storkit {}", env!("CARGO_PKG_VERSION"));
|
/// Resolve the optional positional path argument into an absolute `PathBuf`.
|
||||||
std::process::exit(0);
|
fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option<PathBuf> {
|
||||||
}
|
path_str.map(|s| io::fs::resolve_cli_path(cwd, s))
|
||||||
CliDirective::UnknownFlag(flag) => {
|
}
|
||||||
eprintln!("error: unknown option: {flag}");
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
|
let app_state = Arc::new(SessionState::default());
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
let store = Arc::new(
|
||||||
|
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect CLI args, skipping the binary name (argv[0]).
|
||||||
|
let raw_args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
let cli = match parse_cli_args(&raw_args) {
|
||||||
|
Ok(args) => args,
|
||||||
|
Err(msg) => {
|
||||||
|
eprintln!("error: {msg}");
|
||||||
eprintln!("Run 'storkit --help' for usage.");
|
eprintln!("Run 'storkit --help' for usage.");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
CliDirective::Path | CliDirective::None => {}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
let explicit_path = parse_project_path_arg(&cli_args, &cwd);
|
let is_init = cli.init;
|
||||||
|
let explicit_path = resolve_path_arg(cli.path.as_deref(), &cwd);
|
||||||
|
|
||||||
|
// Port resolution: CLI flag > project.toml (loaded later) > default.
|
||||||
|
// Use the CLI port for scaffolding .mcp.json; final port is resolved
|
||||||
|
// after the project root is known.
|
||||||
|
let port = cli.port.unwrap_or_else(resolve_port);
|
||||||
|
|
||||||
// When a path is given explicitly on the CLI, it must already exist as a
|
// When a path is given explicitly on the CLI, it must already exist as a
|
||||||
// directory. We do not create directories from the command line.
|
// directory. We do not create directories from the command line.
|
||||||
@@ -126,7 +163,37 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(explicit_root) = explicit_path {
|
if is_init {
|
||||||
|
// `storkit init [PATH]` — always scaffold, never search parents.
|
||||||
|
let init_root = explicit_path.unwrap_or_else(|| cwd.clone());
|
||||||
|
if !init_root.exists() {
|
||||||
|
std::fs::create_dir_all(&init_root).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error: cannot create directory {}: {e}", init_root.display());
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match io::fs::open_project(
|
||||||
|
init_root.to_string_lossy().to_string(),
|
||||||
|
&app_state,
|
||||||
|
store.as_ref(),
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
if let Some(root) = app_state.project_root.lock().unwrap().as_ref() {
|
||||||
|
config::ProjectConfig::load(root)
|
||||||
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||||
|
// Initialize wizard state for the setup flow.
|
||||||
|
io::wizard::WizardState::init_if_missing(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(explicit_root) = explicit_path {
|
||||||
// An explicit path was given on the command line.
|
// An explicit path was given on the command line.
|
||||||
// Open it directly — scaffold .storkit/ if it is missing — and
|
// Open it directly — scaffold .storkit/ if it is missing — and
|
||||||
// exit with a clear error message if the path is invalid.
|
// exit with a clear error message if the path is invalid.
|
||||||
@@ -565,96 +632,106 @@ name = "coder"
|
|||||||
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── classify_cli_args ─────────────────────────────────────────────────
|
// ── parse_cli_args ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_none_when_no_args() {
|
fn parse_no_args() {
|
||||||
assert_eq!(classify_cli_args(&[]), CliDirective::None);
|
let result = parse_cli_args(&[]).unwrap();
|
||||||
|
assert_eq!(result.port, None);
|
||||||
|
assert_eq!(result.path, None);
|
||||||
|
assert!(!result.init);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_help_long() {
|
fn parse_unknown_flag_is_error() {
|
||||||
assert_eq!(
|
let args = vec!["--serve".to_string()];
|
||||||
classify_cli_args(&["--help".to_string()]),
|
assert!(parse_cli_args(&args).is_err());
|
||||||
CliDirective::Help
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_help_short() {
|
fn parse_path_only() {
|
||||||
assert_eq!(classify_cli_args(&["-h".to_string()]), CliDirective::Help);
|
let args = vec!["/some/path".to_string()];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert_eq!(result.path, Some("/some/path".to_string()));
|
||||||
|
assert_eq!(result.port, None);
|
||||||
|
assert!(!result.init);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_version_long() {
|
fn parse_port_flag() {
|
||||||
assert_eq!(
|
let args = vec!["--port".to_string(), "4000".to_string()];
|
||||||
classify_cli_args(&["--version".to_string()]),
|
let result = parse_cli_args(&args).unwrap();
|
||||||
CliDirective::Version
|
assert_eq!(result.port, Some(4000));
|
||||||
);
|
assert_eq!(result.path, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_version_short() {
|
fn parse_port_equals_syntax() {
|
||||||
assert_eq!(
|
let args = vec!["--port=5000".to_string()];
|
||||||
classify_cli_args(&["-V".to_string()]),
|
let result = parse_cli_args(&args).unwrap();
|
||||||
CliDirective::Version
|
assert_eq!(result.port, Some(5000));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_unknown_flag() {
|
fn parse_port_with_path() {
|
||||||
assert_eq!(
|
let args = vec!["--port".to_string(), "4200".to_string(), "/some/path".to_string()];
|
||||||
classify_cli_args(&["--serve".to_string()]),
|
let result = parse_cli_args(&args).unwrap();
|
||||||
CliDirective::UnknownFlag("--serve".to_string())
|
assert_eq!(result.port, Some(4200));
|
||||||
);
|
assert_eq!(result.path, Some("/some/path".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_path() {
|
fn parse_port_missing_value_is_error() {
|
||||||
assert_eq!(
|
let args = vec!["--port".to_string()];
|
||||||
classify_cli_args(&["/some/path".to_string()]),
|
assert!(parse_cli_args(&args).is_err());
|
||||||
CliDirective::Path
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── parse_project_path_arg ────────────────────────────────────────────
|
#[test]
|
||||||
|
fn parse_port_invalid_value_is_error() {
|
||||||
|
let args = vec!["--port".to_string(), "abc".to_string()];
|
||||||
|
assert!(parse_cli_args(&args).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_project_path_arg_none_when_no_args() {
|
fn parse_init_subcommand() {
|
||||||
|
let args = vec!["init".to_string()];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert!(result.init);
|
||||||
|
assert_eq!(result.path, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_init_with_path_and_port() {
|
||||||
|
let args = vec!["init".to_string(), "--port".to_string(), "3000".to_string(), "/my/project".to_string()];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert!(result.init);
|
||||||
|
assert_eq!(result.port, Some(3000));
|
||||||
|
assert_eq!(result.path, Some("/my/project".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resolve_path_arg ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_path_arg_none_when_no_path() {
|
||||||
let cwd = PathBuf::from("/home/user/project");
|
let cwd = PathBuf::from("/home/user/project");
|
||||||
let result = parse_project_path_arg(&[], &cwd);
|
let result = resolve_path_arg(None, &cwd);
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_project_path_arg_returns_path_for_absolute_arg() {
|
fn resolve_path_arg_returns_path_for_absolute_arg() {
|
||||||
let cwd = PathBuf::from("/home/user/project");
|
let cwd = PathBuf::from("/home/user/project");
|
||||||
let args = vec!["/some/absolute/path".to_string()];
|
let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap();
|
||||||
let result = parse_project_path_arg(&args, &cwd).unwrap();
|
|
||||||
// Absolute path returned as-is (canonicalize may fail, fallback used)
|
|
||||||
assert!(
|
assert!(
|
||||||
result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")
|
result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_project_path_arg_resolves_dot_to_cwd() {
|
fn resolve_path_arg_resolves_dot_to_cwd() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let cwd = tmp.path().to_path_buf();
|
let cwd = tmp.path().to_path_buf();
|
||||||
let args = vec![".".to_string()];
|
let result = resolve_path_arg(Some("."), &cwd).unwrap();
|
||||||
let result = parse_project_path_arg(&args, &cwd).unwrap();
|
|
||||||
// "." relative to an existing cwd should canonicalize to the cwd itself
|
|
||||||
assert_eq!(result, cwd.canonicalize().unwrap_or(cwd));
|
assert_eq!(result, cwd.canonicalize().unwrap_or(cwd));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_project_path_arg_resolves_relative_path() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let cwd = tmp.path().to_path_buf();
|
|
||||||
let subdir = cwd.join("myproject");
|
|
||||||
std::fs::create_dir_all(&subdir).unwrap();
|
|
||||||
let args = vec!["myproject".to_string()];
|
|
||||||
let result = parse_project_path_arg(&args, &cwd).unwrap();
|
|
||||||
assert_eq!(result, subdir.canonicalize().unwrap_or(subdir));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user