Compare commits
25 Commits
2ff294724a
...
2f0c54150a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f0c54150a | ||
|
|
a716ca312a | ||
|
|
8ff6e3963b | ||
|
|
2e25e2a46b | ||
|
|
7c3a756a5c | ||
|
|
225137fbdc | ||
|
|
cce3ceb55b | ||
|
|
b54f16b945 | ||
|
|
93d5dbd92a | ||
|
|
ec652a6fe8 | ||
|
|
568207687d | ||
|
|
3abea68f9e | ||
|
|
3a430dfaa2 | ||
|
|
6a7baa4a15 | ||
|
|
5f7647cbda | ||
|
|
1dcf043c53 | ||
|
|
523553197d | ||
|
|
8d0c74c7d0 | ||
|
|
ae115599a8 | ||
|
|
6a477de2e1 | ||
|
|
20a4f0c492 | ||
|
|
9fc7aebe22 | ||
|
|
f681d978b5 | ||
|
|
57a02d9a2b | ||
|
|
210d3924ff |
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"enabledMcpjsonServers": ["story-kit"],
|
||||
"enabledMcpjsonServers": [
|
||||
"story-kit"
|
||||
],
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(./server/target/debug/story-kit:*)",
|
||||
@@ -56,7 +58,8 @@
|
||||
"WebSearch",
|
||||
"mcp__story-kit__*",
|
||||
"Edit",
|
||||
"Write"
|
||||
"Write",
|
||||
"Bash(find *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[[component]]
|
||||
name = "frontend"
|
||||
path = "frontend"
|
||||
setup = ["pnpm install", "pnpm run build"]
|
||||
setup = ["npm install", "npm run build"]
|
||||
teardown = []
|
||||
|
||||
[[component]]
|
||||
@@ -56,8 +56,8 @@ role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits."
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
@@ -66,8 +66,8 @@ role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits."
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa-2"
|
||||
@@ -87,12 +87,12 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
- Run `git diff master...HEAD` to review the actual changes for obvious coding mistakes (unused imports, dead code, unhandled errors, hardcoded values)
|
||||
- Run `cargo clippy --all-targets --all-features` and note any warnings
|
||||
- If a `frontend/` directory exists:
|
||||
- Run `pnpm 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
|
||||
|
||||
### 2. Test Verification
|
||||
- Run `cargo test` and verify all tests pass
|
||||
- If `frontend/` exists: run `pnpm test --run` and verify all frontend tests pass
|
||||
- If `frontend/` exists: run `npm test` and verify all frontend tests pass
|
||||
- Review test quality: look for tests that are trivial or don't assert meaningful behavior
|
||||
|
||||
### 3. Manual Testing Support
|
||||
@@ -118,7 +118,7 @@ Print your QA report to stdout before your process exits. The server will automa
|
||||
|
||||
### Test Verification
|
||||
- cargo test: PASS/FAIL (N tests)
|
||||
- pnpm test: PASS/FAIL/SKIP (N tests)
|
||||
- npm test: PASS/FAIL/SKIP (N tests)
|
||||
- Test quality issues: (list any trivial/weak tests, or "None")
|
||||
|
||||
### Manual Testing Plan
|
||||
@@ -143,8 +143,8 @@ role = "Senior full-stack engineer for complex tasks. Implements features across
|
||||
model = "opus"
|
||||
max_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results."
|
||||
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits."
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
@@ -164,12 +164,12 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
- Run `git diff master...HEAD` to review the actual changes for obvious coding mistakes (unused imports, dead code, unhandled errors, hardcoded values)
|
||||
- Run `cargo clippy --all-targets --all-features` and note any warnings
|
||||
- If a `frontend/` directory exists:
|
||||
- Run `pnpm 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
|
||||
|
||||
### 2. Test Verification
|
||||
- Run `cargo test` and verify all tests pass
|
||||
- If `frontend/` exists: run `pnpm test --run` and verify all frontend tests pass
|
||||
- If `frontend/` exists: run `npm test` and verify all frontend tests pass
|
||||
- Review test quality: look for tests that are trivial or don't assert meaningful behavior
|
||||
|
||||
### 3. Manual Testing Support
|
||||
@@ -195,7 +195,7 @@ Print your QA report to stdout before your process exits. The server will automa
|
||||
|
||||
### Test Verification
|
||||
- cargo test: PASS/FAIL (N tests)
|
||||
- pnpm test: PASS/FAIL/SKIP (N tests)
|
||||
- npm test: PASS/FAIL/SKIP (N tests)
|
||||
- Test quality issues: (list any trivial/weak tests, or "None")
|
||||
|
||||
### Manual Testing Plan
|
||||
@@ -237,7 +237,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
The merge pipeline uses a temporary merge-queue branch and worktree to isolate merges from master. Simple additive conflicts (both branches adding code at the same location) are resolved automatically by keeping both additions. Complex conflicts (modifying the same lines differently) are reported without touching master.
|
||||
|
||||
## Fixing Minor Gate Failures
|
||||
If quality gates fail (cargo clippy, cargo test, pnpm build, pnpm test), attempt to fix minor issues yourself before reporting to the human.
|
||||
If quality gates fail (cargo clippy, cargo test, npm run build, npm test), attempt to fix minor issues yourself before reporting to the human.
|
||||
|
||||
**Fix yourself (up to 2 attempts total):**
|
||||
- Syntax errors (missing semicolons, brackets, commas)
|
||||
|
||||
@@ -9,7 +9,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React
|
||||
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
|
||||
* **Frontend:** TypeScript + React
|
||||
* **Build Tool:** Vite
|
||||
* **Package Manager:** pnpm (required)
|
||||
* **Package Manager:** npm
|
||||
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
|
||||
* **State Management:** React Context / Hooks
|
||||
* **Chat UI:** Rendered Markdown with syntax highlighting.
|
||||
@@ -91,8 +91,8 @@ To support both Remote and Local models, the system implements a `ModelProvider`
|
||||
* **Quality Gates:**
|
||||
* `npx @biomejs/biome check src/` must show 0 errors, 0 warnings
|
||||
* `npm run build` must succeed
|
||||
* `npx vitest run` must pass
|
||||
* `npx playwright test` must pass
|
||||
* `npm test` must pass
|
||||
* `npm run test:e2e` must pass
|
||||
* No `any` types allowed (use proper types or `unknown`)
|
||||
* React keys must use stable IDs, not array indices
|
||||
* All buttons must have explicit `type` attribute
|
||||
@@ -119,7 +119,7 @@ To support both Remote and Local models, the system implements a `ModelProvider`
|
||||
Multiple instances can run simultaneously in different worktrees. To avoid port conflicts:
|
||||
|
||||
- **Backend:** Set `STORYKIT_PORT` to a unique port (default is 3001). Example: `STORYKIT_PORT=3002 cargo run`
|
||||
- **Frontend:** Run `pnpm dev` from `frontend/`. It auto-selects the next unused port. It reads `STORYKIT_PORT` to know which backend to talk to, so export it before running: `export STORYKIT_PORT=3002 && cd frontend && pnpm dev`
|
||||
- **Frontend:** Run `npm run dev` from `frontend/`. It auto-selects the next unused port. It reads `STORYKIT_PORT` to know which backend to talk to, so export it before running: `export STORYKIT_PORT=3002 && cd frontend && npm run dev`
|
||||
|
||||
When running in a worktree, use a port that won't conflict with the main instance (3001). Ports 3002+ are good choices.
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "Add refactor work item type"
|
||||
---
|
||||
|
||||
# Story 254: Add refactor work item type
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner, I want a refactor work item type so that I can track and assign code restructuring tasks separately from features and bugs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] create_refactor MCP tool creates a refactor file in work/1_upcoming/ with deterministic filename (e.g. 254_refactor_split_agents_rs.md)
|
||||
- [ ] Refactor files use the naming convention {id}_refactor_{slug}.md
|
||||
- [ ] Refactor items flow through the same pipeline as stories and bugs (upcoming → current → qa → merge → done → archived)
|
||||
- [ ] list_refactors MCP tool lists open refactors in upcoming
|
||||
- [ ] Frontend displays refactor items distinctly from stories and bugs (different label/color)
|
||||
- [ ] Watcher recognizes refactor files and auto-commits moves like stories and bugs
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Agent assignment via story front matter"
|
||||
---
|
||||
|
||||
## Rejection Notes
|
||||
|
||||
**2026-03-16:** Previous coder only updated the serve submodule pointer — no actual implementation. Feature branch also reverted changes from stories 246 and 248. The agent front matter parsing and pipeline assignment logic was never written. Start fresh on a clean branch from master.
|
||||
|
||||
# Story 249: Agent assignment via story front matter
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner, I want to specify which agent should work on a story via a front matter field (e.g. agent: coder-opus) so that complex stories get assigned to the right coder automatically.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Story files support an optional agent front matter field (e.g. agent: coder-opus)
|
||||
- [ ] When the pipeline auto-assigns a coder to a story, it uses the agent specified in front matter if present
|
||||
- [ ] If the specified agent is busy, the story waits rather than falling back to a different coder
|
||||
- [ ] If no agent is specified in front matter, the existing default assignment behaviour is used
|
||||
- [ ] The supervisor agent respects the front matter assignment when starting coders
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "Watcher and auto-assign do not reinitialize when project root changes"
|
||||
---
|
||||
|
||||
# Bug 253: Watcher and auto-assign do not reinitialize when project root changes
|
||||
|
||||
## Description
|
||||
|
||||
When the server starts, if the frontend opens the project at the wrong path (e.g. server/ subdirectory instead of project root), the filesystem watcher and auto-assign run against that wrong path. When the frontend corrects itself by calling DELETE /project then open_project with the right path, the watcher and auto-assign do not reinitialize. This means:
|
||||
|
||||
1. The filesystem watcher watches the wrong directory for the entire session
|
||||
2. Auto-assign only runs once at startup (against the wrong root) and never re-runs
|
||||
3. Stories placed in 2_current/ are never auto-assigned to coders
|
||||
4. The archive sweep never fires (same watcher thread)
|
||||
|
||||
This is likely the root cause of bug 251 (archive sweep not working) and explains why coders are not being auto-assigned.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Start the Story Kit server\n2. Open a project in the frontend — note the first open_project sets project_root to the wrong subdirectory\n3. Frontend corrects by calling DELETE /project then open_project with the correct path\n4. Move a story into 2_current/\n5. Observe that no coder is auto-assigned
|
||||
|
||||
## Actual Result
|
||||
|
||||
Watcher and auto-assign remain bound to the initial (wrong) project root. No filesystem events are detected for the correct project directory. Stories in 2_current/ are never picked up.
|
||||
|
||||
## Expected Result
|
||||
|
||||
When project_root changes via open_project, the watcher thread should be stopped and restarted against the new root, and auto_assign_available_work() should re-run.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] When open_project is called with a new path, the filesystem watcher restarts watching the new project root
|
||||
- [ ] auto_assign_available_work() re-runs after a project root change
|
||||
- [ ] If DELETE /project is called, the watcher stops (no zombie watcher on a stale path)
|
||||
- [ ] Stories in 2_current/ are auto-assigned after the project root is corrected
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: "Chat history persistence lost on page refresh (story 145 regression)"
|
||||
---
|
||||
|
||||
## Rejection Notes
|
||||
|
||||
**2026-03-16:** Previous coder produced zero code changes — feature branch had no diff against master. The coder must actually use `git bisect` to find the breaking commit and produce a surgical fix. Do not submit with no code changes.
|
||||
|
||||
# Bug 245: Chat history persistence lost on page refresh (story 145 regression)
|
||||
|
||||
## Description
|
||||
|
||||
Story 145 implemented localStorage persistence for chat history across page reloads. This is no longer working — refreshing the page loses all conversation context. This is a regression of the feature delivered in story 145.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Open the web UI and have a conversation with the agent
|
||||
2. Refresh the page (F5 or Cmd+R)
|
||||
|
||||
## Actual Result
|
||||
|
||||
Chat history is gone after refresh — the UI shows a blank conversation.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Chat history is restored from localStorage on page load, as implemented in story 145.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Chat messages survive a full page refresh
|
||||
- [ ] Chat messages are restored from localStorage on component mount
|
||||
- [ ] Behaviour matches the original acceptance criteria from story 145
|
||||
|
||||
## Investigation Notes
|
||||
|
||||
**Use `git bisect` to find the commit that broke this.** Story 145 delivered working localStorage persistence — something after that regressed it. Find the breaking commit, understand the root cause, and fix it there. Do NOT layer on a new implementation. Revert or surgically fix the regression.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "Merge pipeline cherry-pick fails with bad revision on merge-queue branch"
|
||||
---
|
||||
|
||||
## Rejection Notes
|
||||
|
||||
**2026-03-16:** Previous coder (coder-opus) produced zero code changes. The feature branch had no diff against master. Actually fix the bug this time.
|
||||
|
||||
# Bug 250: Merge pipeline cherry-pick fails with bad revision on merge-queue branch
|
||||
|
||||
## Description
|
||||
|
||||
The mergemaster merge pipeline consistently fails at the cherry-pick step with: fatal: bad revision merge-queue/{story_id}. The merge-queue branch is created and the squash commit succeeds, but the branch reference is not accessible during the subsequent cherry-pick onto master. This affects every story that reaches the merge stage — no stories can be automatically merged. The issue is in the git reference handling within the merge pipeline, not a code conflict.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Have a completed story in 4_merge/ with a feature branch containing commits ahead of master
|
||||
2. Trigger merge_agent_work via MCP or let the mergemaster agent run
|
||||
3. Observe the cherry-pick failure
|
||||
|
||||
## Actual Result
|
||||
|
||||
Cherry-pick fails with fatal: bad revision merge-queue/{story_id}. The merge-queue branch was created and squash commit succeeded, but the branch reference is not found during cherry-pick. Master is untouched.
|
||||
|
||||
## Expected Result
|
||||
|
||||
The merge pipeline should successfully squash-merge the feature branch into master, run quality gates, move the story to done, and clean up the worktree and branch.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Use git bisect or git log to find when the merge pipeline broke
|
||||
- [ ] Fix the root cause — do not layer on a workaround
|
||||
- [ ] Merge pipeline successfully merges a story from 4_merge to master end-to-end
|
||||
- [ ] Quality gates run and pass before the merge commits to master
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "Archive sweep not moving stories from done to archived"
|
||||
---
|
||||
|
||||
# Bug 251: Archive sweep not moving stories from done to archived
|
||||
|
||||
## Description
|
||||
|
||||
Stories that have been in `5_done/` for well over the configured retention period (default 4 hours) are not being automatically swept to `6_archived/`. Items from March 14 are still sitting in `5_done/` as of March 16 — over 2 days past the threshold. The last items that successfully reached `6_archived/` date from Feb 23-24.
|
||||
|
||||
Additionally, story file moves (e.g. from one pipeline stage to another) are sometimes not being auto-committed, which used to work.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Start the Story Kit server
|
||||
2. Move a story to `5_done/`
|
||||
3. Wait longer than `done_retention_secs` (default 14400 seconds / 4 hours)
|
||||
4. Observe that the story is never moved to `6_archived/`
|
||||
|
||||
## Actual Result
|
||||
|
||||
Stories remain in `5_done/` indefinitely. No sweep log messages appear in the server output.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Stories older than `done_retention_secs` are automatically moved to `6_archived/` and the move is auto-committed.
|
||||
|
||||
## Investigation Notes
|
||||
|
||||
The sweep logic lives in `server/src/io/watcher.rs` around line 208 (`sweep_done_to_archived()`). The watcher runs on a dedicated OS thread (line 310) with a timer-based sweep interval (line 441, default 60s).
|
||||
|
||||
**Do NOT layer new code on top of this.** Use `git bisect` or `git log` to find when the sweep stopped working. The code looks structurally correct — the watcher thread may be dying silently with no restart mechanism, or something changed in how/when the sweep is triggered. Find the root cause and fix it there.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: /btw Side Question Slash Command
|
||||
---
|
||||
|
||||
## User Story
|
||||
|
||||
As a user, I want to ask the agent a quick side question using `/btw` so that I can get a fast answer from the current conversation context without disrupting the main chat thread.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can type `/btw <question>` in the chat input
|
||||
- [ ] The agent answers using the full conversation history as context
|
||||
- [ ] The question and response are displayed in a dismissible overlay, not in the main chat thread
|
||||
- [ ] The question and response are not added to the conversation history
|
||||
- [ ] No tool calls are made when answering a `/btw` question — the agent responds only from what is already in context
|
||||
- [ ] The overlay can be dismissed with Escape, Enter, or Space
|
||||
- [ ] `/btw` can be invoked while the agent is actively processing a response without interrupting it
|
||||
- [ ] The slash command detection and dispatch mechanism must be reusable — build a shared parser/router so future slash commands (e.g. /help, /status) can plug in without duplicating detection logic
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Follow-up turns within the side question
|
||||
- Tool usage in side question responses
|
||||
- Persisting side question history
|
||||
|
||||
## Rejection Notes
|
||||
|
||||
**2026-03-14:** Previous implementation was rejected. The frontend did nothing when the user typed `/btw` — the slash command was not wired up in the UI at all. The backend may have had changes but the feature was non-functional from the user's perspective. Ensure the full end-to-end flow works: typing `/btw <question>` in the chat input must visibly trigger the overlay with a response.
|
||||
20
.story_kit/work/5_done/242_story_status_slash_command.md
Normal file
20
.story_kit/work/5_done/242_story_status_slash_command.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "/status Slash Command"
|
||||
---
|
||||
|
||||
# Story 242: /status Slash Command
|
||||
|
||||
## User Story
|
||||
|
||||
As a user, I want to type /status in the chat input so that I can see the current state of the agent, active story, pipeline stage, and any running processes at a glance.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can type /status in the chat input
|
||||
- [ ] A status overlay or panel shows: current story (if any), pipeline stage, agent status, and running processes
|
||||
- [ ] The overlay can be dismissed with Escape, Enter, or Space
|
||||
- [ ] The slash command detection and dispatch mechanism is shared across all slash commands (reuse the same parser/router used by /btw and other slash commands — do not duplicate detection logic)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: "Enforce cryptographic identity verification for Matrix commands"
|
||||
---
|
||||
|
||||
# Story 246: Enforce cryptographic identity verification for Matrix commands
|
||||
|
||||
## User Story
|
||||
|
||||
As the operator of a Story Kit instance, I want the Matrix bot to always require cryptographic device verification before executing commands, so that a compromised homeserver cannot be used to execute unauthorized commands.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bot refuses to process commands from unencrypted rooms
|
||||
- [ ] Bot always verifies the sending device is cross-signing-verified by a trusted identity before executing any command
|
||||
- [ ] The require_verified_devices config option is removed — verification is always on with no way to disable it
|
||||
- [ ] Messages from unverified devices are rejected with a clear log message
|
||||
- [ ] Existing allowed_users check remains as a first-pass filter before the cryptographic check
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "Coder agents must find root causes for bugs"
|
||||
---
|
||||
|
||||
# Story 252: Coder agents must find root causes for bugs
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner, I want coder agents to always investigate and fix the root cause of bugs rather than layering new code on top, so that fixes are surgical, minimal, and don't introduce unnecessary complexity.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] When a coder agent picks up a bug, it must first investigate to find the root cause (e.g. using `git bisect`, `git log`, reading the relevant code history)
|
||||
- [ ] The coder's commit message must explain what broke and why, not just what was changed
|
||||
- [ ] Coders must not add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible
|
||||
- [ ] The system prompt or agent instructions for coder agents include clear guidance: "For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible."
|
||||
- [ ] If a coder cannot determine the root cause, it must document what it tried and why it was inconclusive, rather than guessing and shipping a speculative fix
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changes to how stories (non-bugs) are handled
|
||||
- Automated enforcement (this is guidance/instruction, not a gate)
|
||||
24
.story_kit/work/6_archived/241_story_help_slash_command.md
Normal file
24
.story_kit/work/6_archived/241_story_help_slash_command.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "/help Slash Command"
|
||||
---
|
||||
|
||||
# Story 241: /help Slash Command
|
||||
|
||||
## User Story
|
||||
|
||||
As a user, I want to type /help in the chat input so that I can see a list of available slash commands and what they do.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can type /help in the chat input
|
||||
- [ ] A help overlay or panel displays all available slash commands with brief descriptions
|
||||
- [ ] The overlay can be dismissed with Escape, Enter, or Space
|
||||
- [ ] The slash command detection and dispatch mechanism is shared across all slash commands (reuse the same parser/router used by /btw and other slash commands — do not duplicate detection logic)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
|
||||
## Rejection Notes
|
||||
|
||||
**2026-03-14:** Previous implementation was rejected. The frontend did nothing when the user typed `/help` — the slash command was not wired up in the UI at all. Ensure the full end-to-end flow works: typing `/help` in the chat input must visibly display the help overlay with slash command descriptions.
|
||||
28
.story_kit/work/6_archived/243_bug_replace_pnpm_with_npm.md
Normal file
28
.story_kit/work/6_archived/243_bug_replace_pnpm_with_npm.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Replace pnpm with npm"
|
||||
---
|
||||
|
||||
# Bug 243: Replace pnpm with npm
|
||||
|
||||
## Description
|
||||
|
||||
pnpm's reflink-based package import frequently fails with ERR_PNPM_ENOENT when running in git worktrees (.story_kit/merge_workspace), causing merge quality gates to fail repeatedly. No pnpm-specific features are in use.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
Move any story to merge. The mergemaster runs pnpm install in the merge worktree and it fails with ERR_PNPM_ENOENT reflink errors.
|
||||
|
||||
## Actual Result
|
||||
|
||||
pnpm install fails in merge worktrees, blocking all merges.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Package installation works reliably in all worktree contexts.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] pnpm-lock.yaml is removed and package-lock.json is generated
|
||||
- [ ] All pnpm references in project.toml are replaced with npm equivalents
|
||||
- [ ] npm install and npm run build succeed in a clean worktree
|
||||
- [ ] No other pnpm references remain in project configuration
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Human QA gate with rejection flow"
|
||||
---
|
||||
|
||||
# Story 247: Human QA gate with rejection flow
|
||||
|
||||
## User Story
|
||||
|
||||
As the project owner, I want stories to require my manual approval after machine QA before they can be merged, so that features that compile and pass tests but do not actually work correctly are caught before reaching master.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Story files support a manual_qa front matter field (defaults to true)
|
||||
- [ ] After machine QA passes in 3_qa, stories with manual_qa: true wait for human approval before moving to 4_merge
|
||||
- [ ] The UI shows a clear way to launch the app from the worktree for manual testing (single button click), with automatic port conflict handling via .story_kit_port
|
||||
- [ ] Frontend and backend are pre-compiled during machine QA so the app is ready to run instantly for manual testing
|
||||
- [ ] Only one QA app instance runs at a time — do not automatically spin up multiple instances
|
||||
- [ ] Human can approve a story from 3_qa to move it to 4_merge
|
||||
- [ ] Human can reject a story from 3_qa back to 2_current with notes about what is broken
|
||||
- [ ] Rejection notes are written into the story file so the coder can see what needs fixing
|
||||
- [ ] Stories with manual_qa: false skip the human gate and proceed directly from machine QA to 4_merge
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "Chat does not auto-scroll to new messages"
|
||||
---
|
||||
|
||||
# Bug 248: Chat does not auto-scroll to new messages
|
||||
|
||||
## Description
|
||||
|
||||
The chat UI does not automatically scroll to the bottom when new assistant messages stream in. The user has to manually scroll down to see the response, making it appear as if the bot stopped responding.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Send a message in the chat UI
|
||||
2. Wait for the assistant to respond with a long message or multi-turn tool use
|
||||
3. Observe that the viewport does not scroll to follow the new content
|
||||
|
||||
## Actual Result
|
||||
|
||||
The viewport stays at the current scroll position. New messages appear below the fold, invisible to the user.
|
||||
|
||||
## Expected Result
|
||||
|
||||
The chat viewport should auto-scroll to the bottom as new content streams in, keeping the latest message visible.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -1952,6 +1952,7 @@ version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -4007,6 +4008,7 @@ dependencies = [
|
||||
"futures",
|
||||
"homedir",
|
||||
"ignore",
|
||||
"libsqlite3-sys",
|
||||
"matrix-sdk",
|
||||
"mime_guess",
|
||||
"mockito",
|
||||
@@ -4024,7 +4026,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"toml 1.0.3+spec-1.1.0",
|
||||
"toml 1.0.6+spec-1.1.0",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
@@ -4369,9 +4371,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.0.3+spec-1.1.0"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
|
||||
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
@@ -4702,9 +4704,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"js-sys",
|
||||
|
||||
@@ -24,8 +24,8 @@ serde_yaml = "0.9"
|
||||
strip-ansi-escapes = "0.2"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
toml = "1.0.3"
|
||||
uuid = { version = "1.21.0", features = ["v4", "serde"] }
|
||||
toml = "1.0.6"
|
||||
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
||||
tokio-tungstenite = "0.28.0"
|
||||
walkdir = "2.5.0"
|
||||
filetime = "0.2"
|
||||
@@ -37,6 +37,3 @@ matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
||||
pulldown-cmark = { version = "0.13.1", default-features = false, features = [
|
||||
"html",
|
||||
] }
|
||||
|
||||
# Force bundled SQLite so static musl builds don't need a system libsqlite3
|
||||
libsqlite3-sys = { version = "*", features = ["bundled"] }
|
||||
|
||||
4
Makefile
4
Makefile
@@ -8,7 +8,7 @@ help:
|
||||
@echo " make release V=x.y.z Build both targets and publish a Gitea release"
|
||||
@echo ""
|
||||
@echo "Prerequisites:"
|
||||
@echo " build-macos: Rust stable toolchain, pnpm"
|
||||
@echo " build-macos: Rust stable toolchain, npm"
|
||||
@echo " build-linux: cargo install cross AND Docker Desktop running"
|
||||
@echo ""
|
||||
@echo "Output:"
|
||||
@@ -16,7 +16,7 @@ help:
|
||||
@echo " Linux : target/x86_64-unknown-linux-musl/release/story-kit"
|
||||
|
||||
## Build a native macOS release binary.
|
||||
## The frontend is compiled by build.rs (pnpm build) and embedded via rust-embed.
|
||||
## The frontend is compiled by build.rs (npm run build) and embedded via rust-embed.
|
||||
## Verify dynamic deps afterwards: otool -L target/release/story-kit
|
||||
build-macos:
|
||||
cargo build --release
|
||||
|
||||
10
README.md
10
README.md
@@ -10,8 +10,8 @@ You can also run the frontend and backend separately in development (Vite dev se
|
||||
```bash
|
||||
# Build the frontend
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Run the server (serves embedded frontend/dist/)
|
||||
cargo run
|
||||
@@ -35,7 +35,7 @@ Story Kit ships as a **single self-contained binary** with the React frontend em
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
# Native build – no extra tools required beyond Rust + pnpm
|
||||
# Native build – no extra tools required beyond Rust + npm
|
||||
make build-macos
|
||||
# Output: target/release/story-kit
|
||||
|
||||
@@ -109,10 +109,10 @@ The frontend uses **Vitest** for unit tests and **Playwright** for end-to-end te
|
||||
cd frontend
|
||||
|
||||
# Run unit tests
|
||||
pnpm test
|
||||
npm test
|
||||
|
||||
# Run end-to-end tests
|
||||
pnpm test:e2e
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### Backend Tests
|
||||
|
||||
995
frontend/package-lock.json
generated
995
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@
|
||||
"@biomejs/biome": "^2.4.2",
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.1.8",
|
||||
|
||||
5673
frontend/pnpm-lock.yaml
generated
5673
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,13 @@ export type WsRequest =
|
||||
approved: boolean;
|
||||
always_allow: boolean;
|
||||
}
|
||||
| { type: "ping" };
|
||||
| { type: "ping" }
|
||||
| {
|
||||
type: "side_question";
|
||||
question: string;
|
||||
context_messages: Message[];
|
||||
config: ProviderConfig;
|
||||
};
|
||||
|
||||
export interface AgentAssignment {
|
||||
agent_name: string;
|
||||
@@ -73,7 +79,11 @@ export type WsResponse =
|
||||
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
||||
| { type: "onboarding_status"; needs_onboarding: boolean }
|
||||
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
||||
| { type: "thinking_token"; content: string };
|
||||
| { type: "thinking_token"; content: string }
|
||||
/** Streaming token from a /btw side question response. */
|
||||
| { type: "side_question_token"; content: string }
|
||||
/** Final signal that the /btw side question has been fully answered. */
|
||||
| { type: "side_question_done"; response: string };
|
||||
|
||||
export interface ProviderConfig {
|
||||
provider: string;
|
||||
@@ -324,6 +334,8 @@ export class ChatWebSocket {
|
||||
private onAgentConfigChanged?: () => void;
|
||||
private onAgentStateChanged?: () => void;
|
||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
private onSideQuestionToken?: (content: string) => void;
|
||||
private onSideQuestionDone?: (response: string) => void;
|
||||
private connected = false;
|
||||
private closeTimer?: number;
|
||||
private wsPath = DEFAULT_WS_PATH;
|
||||
@@ -405,6 +417,10 @@ export class ChatWebSocket {
|
||||
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
||||
if (data.type === "onboarding_status")
|
||||
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||
if (data.type === "side_question_token")
|
||||
this.onSideQuestionToken?.(data.content);
|
||||
if (data.type === "side_question_done")
|
||||
this.onSideQuestionDone?.(data.response);
|
||||
if (data.type === "pong") {
|
||||
window.clearTimeout(this.heartbeatTimeout);
|
||||
this.heartbeatTimeout = undefined;
|
||||
@@ -458,6 +474,8 @@ export class ChatWebSocket {
|
||||
onAgentConfigChanged?: () => void;
|
||||
onAgentStateChanged?: () => void;
|
||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
onSideQuestionToken?: (content: string) => void;
|
||||
onSideQuestionDone?: (response: string) => void;
|
||||
},
|
||||
wsPath = DEFAULT_WS_PATH,
|
||||
) {
|
||||
@@ -473,6 +491,8 @@ export class ChatWebSocket {
|
||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||
this.wsPath = wsPath;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
@@ -498,6 +518,19 @@ export class ChatWebSocket {
|
||||
this.send({ type: "chat", messages, config });
|
||||
}
|
||||
|
||||
sendSideQuestion(
|
||||
question: string,
|
||||
contextMessages: Message[],
|
||||
config: ProviderConfig,
|
||||
) {
|
||||
this.send({
|
||||
type: "side_question",
|
||||
question,
|
||||
context_messages: contextMessages,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.send({ type: "cancel" });
|
||||
}
|
||||
|
||||
@@ -312,10 +312,11 @@ describe("Thinking traces hidden from agent stream UI", () => {
|
||||
// AC1: no thinking block
|
||||
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
|
||||
|
||||
// AC2+AC3: output area renders the text
|
||||
// AC2+AC3: output area renders the text but NOT thinking text
|
||||
const outputArea = screen.getByTestId("agent-output-coder-1");
|
||||
expect(outputArea).toBeInTheDocument();
|
||||
expect(outputArea.textContent).toContain("Here is the result.");
|
||||
expect(outputArea.textContent).not.toContain("thinking deeply");
|
||||
});
|
||||
|
||||
// AC3: output-only event stream (no thinking) still works
|
||||
|
||||
@@ -175,6 +175,9 @@ export function AgentPanel({
|
||||
terminalAt: current.terminalAt ?? Date.now(),
|
||||
},
|
||||
};
|
||||
case "thinking":
|
||||
// Thinking traces are internal model state — never display them.
|
||||
return prev;
|
||||
default:
|
||||
return prev;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { AgentPanel } from "./AgentPanel";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import type { ChatInputHandle } from "./ChatInput";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { HelpOverlay } from "./HelpOverlay";
|
||||
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||
import { MessageItem } from "./MessageItem";
|
||||
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||
|
||||
@@ -197,6 +199,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
const [queuedMessages, setQueuedMessages] = useState<
|
||||
{ id: string; text: string }[]
|
||||
>([]);
|
||||
const [sideQuestion, setSideQuestion] = useState<{
|
||||
question: string;
|
||||
response: string;
|
||||
loading: boolean;
|
||||
} | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
// Ref so stale WebSocket callbacks can read the current queued messages
|
||||
const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]);
|
||||
const queueIdCounterRef = useRef(0);
|
||||
@@ -360,6 +368,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
onOnboardingStatus: (onboarding: boolean) => {
|
||||
setNeedsOnboarding(onboarding);
|
||||
},
|
||||
onSideQuestionToken: (content) => {
|
||||
setSideQuestion((prev) =>
|
||||
prev ? { ...prev, response: prev.response + content } : prev,
|
||||
);
|
||||
},
|
||||
onSideQuestionDone: (response) => {
|
||||
setSideQuestion((prev) =>
|
||||
prev ? { ...prev, response, loading: false } : prev,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -372,7 +390,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
const element = scrollContainerRef.current;
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
lastScrollTopRef.current = element.scrollHeight;
|
||||
// Read scrollTop back after assignment: the browser caps it at
|
||||
// (scrollHeight - clientHeight), so storing scrollHeight would
|
||||
// make handleScroll incorrectly interpret the next scroll event
|
||||
// as an upward scroll and disable auto-scrolling.
|
||||
lastScrollTopRef.current = element.scrollTop;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -459,6 +481,34 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
const sendMessage = async (messageText: string) => {
|
||||
if (!messageText.trim()) return;
|
||||
|
||||
// /help — show available slash commands overlay
|
||||
if (/^\/help\s*$/i.test(messageText)) {
|
||||
setShowHelp(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// /btw <question> — answered from context without disrupting main chat
|
||||
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
|
||||
if (btwMatch) {
|
||||
const question = btwMatch[1].trim();
|
||||
setSideQuestion({ question, response: "", loading: true });
|
||||
|
||||
const isClaudeCode = model === "claude-code-pty";
|
||||
const provider = isClaudeCode
|
||||
? "claude-code"
|
||||
: model.startsWith("claude-")
|
||||
? "anthropic"
|
||||
: "ollama";
|
||||
const config: ProviderConfig = {
|
||||
provider,
|
||||
model,
|
||||
base_url: "http://localhost:11434",
|
||||
enable_tools: false,
|
||||
};
|
||||
wsRef.current?.sendSideQuestion(question, messages, config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent is busy — queue the message instead of dropping it
|
||||
if (loading) {
|
||||
const newItem = {
|
||||
@@ -1154,6 +1204,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHelp && <HelpOverlay onDismiss={() => setShowHelp(false)} />}
|
||||
|
||||
{sideQuestion && (
|
||||
<SideQuestionOverlay
|
||||
question={sideQuestion.question}
|
||||
response={sideQuestion.response}
|
||||
loading={sideQuestion.loading}
|
||||
onDismiss={() => setSideQuestion(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
158
frontend/src/components/HelpOverlay.tsx
Normal file
158
frontend/src/components/HelpOverlay.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react";
|
||||
|
||||
const { useEffect, useRef } = React;
|
||||
|
||||
interface SlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
name: "/help",
|
||||
description: "Show this list of available slash commands.",
|
||||
},
|
||||
{
|
||||
name: "/btw <question>",
|
||||
description:
|
||||
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
|
||||
},
|
||||
];
|
||||
|
||||
interface HelpOverlayProps {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissible overlay that lists all available slash commands.
|
||||
* Dismiss with Escape, Enter, or Space.
|
||||
*/
|
||||
export function HelpOverlay({ onDismiss }: HelpOverlayProps) {
|
||||
const dismissRef = useRef(onDismiss);
|
||||
dismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
dismissRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener
|
||||
<div
|
||||
data-testid="help-overlay"
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */}
|
||||
<div
|
||||
data-testid="help-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "#2f2f2f",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "12px",
|
||||
padding: "24px",
|
||||
maxWidth: "560px",
|
||||
width: "90vw",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "#a0d4a0",
|
||||
}}
|
||||
>
|
||||
Slash Commands
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss (Escape, Enter, or Space)"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.1rem",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Command list */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{SLASH_COMMANDS.map((cmd) => (
|
||||
<div
|
||||
key={cmd.name}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "2px" }}
|
||||
>
|
||||
<code
|
||||
style={{
|
||||
fontSize: "0.88rem",
|
||||
color: "#e0e0e0",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{cmd.name}
|
||||
</code>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "#999",
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
>
|
||||
{cmd.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "#555",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Press Escape, Enter, or Space to dismiss
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
frontend/src/components/SideQuestionOverlay.tsx
Normal file
159
frontend/src/components/SideQuestionOverlay.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import * as React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
const { useEffect, useRef } = React;
|
||||
|
||||
interface SideQuestionOverlayProps {
|
||||
question: string;
|
||||
/** Streaming response text. Empty while loading. */
|
||||
response: string;
|
||||
loading: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissible overlay that shows a /btw side question and its streamed response.
|
||||
* The question and response are NOT part of the main conversation history.
|
||||
* Dismiss with Escape, Enter, or Space.
|
||||
*/
|
||||
export function SideQuestionOverlay({
|
||||
question,
|
||||
response,
|
||||
loading,
|
||||
onDismiss,
|
||||
}: SideQuestionOverlayProps) {
|
||||
const dismissRef = useRef(onDismiss);
|
||||
dismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
dismissRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener
|
||||
<div
|
||||
data-testid="side-question-overlay"
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */}
|
||||
<div
|
||||
data-testid="side-question-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "#2f2f2f",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "12px",
|
||||
padding: "24px",
|
||||
maxWidth: "640px",
|
||||
width: "90vw",
|
||||
maxHeight: "60vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "#a0d4a0",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
/btw
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: "#ececec",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss (Escape, Enter, or Space)"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.1rem",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Response area */}
|
||||
<div
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
flex: 1,
|
||||
color: "#ccc",
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{loading && !response && (
|
||||
<span style={{ color: "#666", fontStyle: "italic" }}>
|
||||
Thinking…
|
||||
</span>
|
||||
)}
|
||||
{response && <Markdown>{response}</Markdown>}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
{!loading && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "#555",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Press Escape, Enter, or Space to dismiss
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ vi.mock("../api/agents", () => ({
|
||||
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
|
||||
|
||||
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
||||
|
||||
@@ -482,9 +482,10 @@ export function WorkItemDetailPanel({
|
||||
|
||||
{/* Placeholder sections for future content */}
|
||||
{(
|
||||
[
|
||||
{ id: "coverage", label: "Coverage" },
|
||||
] as { id: string; label: string }[]
|
||||
[{ id: "coverage", label: "Coverage" }] as {
|
||||
id: string;
|
||||
label: string;
|
||||
}[]
|
||||
).map(({ id, label }) => (
|
||||
<div
|
||||
key={id}
|
||||
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "story-kit",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml"
|
||||
|
||||
echo "=== Running frontend unit tests ==="
|
||||
cd "$PROJECT_ROOT/frontend"
|
||||
pnpm test
|
||||
npm test
|
||||
|
||||
echo "=== Running e2e tests ==="
|
||||
pnpm test:e2e
|
||||
npm run test:e2e
|
||||
|
||||
@@ -64,7 +64,7 @@ echo "=== Running frontend tests with coverage ==="
|
||||
FRONTEND_DIR="$PROJECT_ROOT/frontend"
|
||||
FRONTEND_LINE_COV=0
|
||||
if [ -d "$FRONTEND_DIR" ]; then
|
||||
FRONTEND_REPORT=$(cd "$FRONTEND_DIR" && pnpm run test:coverage 2>&1) || true
|
||||
FRONTEND_REPORT=$(cd "$FRONTEND_DIR" && npm run test:coverage 2>&1) || true
|
||||
echo "$FRONTEND_REPORT"
|
||||
|
||||
# Parse "All files" line from vitest coverage text table.
|
||||
|
||||
1
serve
Submodule
1
serve
Submodule
Submodule serve added at 1ec5c08ae7
8
server/.mcp.json
Normal file
8
server/.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"story-kit": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:3001/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ walkdir = { workspace = true }
|
||||
matrix-sdk = { workspace = true }
|
||||
pulldown-cmark = { workspace = true }
|
||||
|
||||
# Force bundled SQLite so static musl builds don't need a system libsqlite3
|
||||
libsqlite3-sys = { version = "0.35.0", features = ["bundled"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
|
||||
@@ -18,7 +18,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-env-changed=PROFILE");
|
||||
println!("cargo:rerun-if-changed=../frontend/package.json");
|
||||
println!("cargo:rerun-if-changed=../frontend/pnpm-lock.yaml");
|
||||
println!("cargo:rerun-if-changed=../frontend/package-lock.json");
|
||||
println!("cargo:rerun-if-changed=../frontend/vite.config.ts");
|
||||
println!("cargo:rerun-if-changed=../frontend/index.html");
|
||||
println!("cargo:rerun-if-changed=../frontend/src");
|
||||
@@ -30,7 +30,7 @@ fn main() {
|
||||
}
|
||||
|
||||
// When cross-compiling (e.g. musl via `cross`), the Docker container
|
||||
// has no Node/pnpm. The release script builds macOS first, so
|
||||
// has no Node/npm. The release script builds macOS first, so
|
||||
// frontend/dist/ already exists. Skip the frontend build in that case.
|
||||
let target = env::var("TARGET").unwrap_or_default();
|
||||
let host = env::var("HOST").unwrap_or_default();
|
||||
@@ -45,6 +45,6 @@ fn main() {
|
||||
let frontend_dir = Path::new("../frontend");
|
||||
|
||||
// Ensure dependencies are installed and build the frontend bundle.
|
||||
run("pnpm", &["install"], frontend_dir);
|
||||
run("pnpm", &["build"], frontend_dir);
|
||||
run("npm", &["install"], frontend_dir);
|
||||
run("npm", &["run", "build"], frontend_dir);
|
||||
}
|
||||
|
||||
393
server/src/agents/gates.rs
Normal file
393
server/src/agents/gates.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Detect whether the base branch in a worktree is `master` or `main`.
|
||||
/// Falls back to `"master"` if neither is found.
|
||||
pub(crate) fn detect_worktree_base_branch(wt_path: &Path) -> String {
|
||||
for branch in &["master", "main"] {
|
||||
let ok = Command::new("git")
|
||||
.args(["rev-parse", "--verify", branch])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
return branch.to_string();
|
||||
}
|
||||
}
|
||||
"master".to_string()
|
||||
}
|
||||
|
||||
/// Return `true` if the git worktree at `wt_path` has commits on its current
|
||||
/// branch that are not present on the base branch (`master` or `main`).
|
||||
///
|
||||
/// Used during server startup reconciliation to detect stories whose agent work
|
||||
/// was committed while the server was offline.
|
||||
pub(crate) fn worktree_has_committed_work(wt_path: &Path) -> bool {
|
||||
let base_branch = detect_worktree_base_branch(wt_path);
|
||||
let output = Command::new("git")
|
||||
.args(["log", &format!("{base_branch}..HEAD"), "--oneline"])
|
||||
.current_dir(wt_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
!String::from_utf8_lossy(&out.stdout).trim().is_empty()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the given directory has any uncommitted git changes.
|
||||
/// Returns `Err` with a descriptive message if there are any.
|
||||
pub(crate) fn check_uncommitted_changes(path: &Path) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.trim().is_empty() {
|
||||
return Err(format!(
|
||||
"Worktree has uncommitted changes. Please commit all work before \
|
||||
the agent exits:\n{stdout}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the project's test suite.
|
||||
///
|
||||
/// Uses `script/test` if present, treating it as the canonical single test entry point.
|
||||
/// Falls back to `cargo nextest run` / `cargo test` when `script/test` is absent.
|
||||
/// Returns `(tests_passed, output)`.
|
||||
pub(crate) fn run_project_tests(path: &Path) -> Result<(bool, String), String> {
|
||||
let script_test = path.join("script").join("test");
|
||||
if script_test.exists() {
|
||||
let mut output = String::from("=== script/test ===\n");
|
||||
let result = Command::new(&script_test)
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run script/test: {e}"))?;
|
||||
let out = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
output.push_str(&out);
|
||||
output.push('\n');
|
||||
return Ok((result.status.success(), output));
|
||||
}
|
||||
|
||||
// Fallback: cargo nextest run / cargo test
|
||||
let mut output = String::from("=== tests ===\n");
|
||||
let (success, test_out) = match Command::new("cargo")
|
||||
.args(["nextest", "run"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&o.stdout),
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
(o.status.success(), combined)
|
||||
}
|
||||
Err(_) => {
|
||||
// nextest not available — fall back to cargo test
|
||||
let o = Command::new("cargo")
|
||||
.args(["test"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run cargo test: {e}"))?;
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&o.stdout),
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
(o.status.success(), combined)
|
||||
}
|
||||
};
|
||||
output.push_str(&test_out);
|
||||
output.push('\n');
|
||||
Ok((success, output))
|
||||
}
|
||||
|
||||
/// Run `cargo clippy` and the project test suite (via `script/test` if present,
|
||||
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
|
||||
/// Returns `(gates_passed, combined_output)`.
|
||||
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
|
||||
let mut all_output = String::new();
|
||||
let mut all_passed = true;
|
||||
|
||||
// ── cargo clippy ──────────────────────────────────────────────
|
||||
let clippy = Command::new("cargo")
|
||||
.args(["clippy", "--all-targets", "--all-features"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run cargo clippy: {e}"))?;
|
||||
|
||||
all_output.push_str("=== cargo clippy ===\n");
|
||||
let clippy_stdout = String::from_utf8_lossy(&clippy.stdout);
|
||||
let clippy_stderr = String::from_utf8_lossy(&clippy.stderr);
|
||||
if !clippy_stdout.is_empty() {
|
||||
all_output.push_str(&clippy_stdout);
|
||||
}
|
||||
if !clippy_stderr.is_empty() {
|
||||
all_output.push_str(&clippy_stderr);
|
||||
}
|
||||
all_output.push('\n');
|
||||
|
||||
if !clippy.status.success() {
|
||||
all_passed = false;
|
||||
}
|
||||
|
||||
// ── tests (script/test if available, else cargo nextest/test) ─
|
||||
let (test_success, test_out) = run_project_tests(path)?;
|
||||
all_output.push_str(&test_out);
|
||||
if !test_success {
|
||||
all_passed = false;
|
||||
}
|
||||
|
||||
Ok((all_passed, all_output))
|
||||
}
|
||||
|
||||
/// Run `script/test_coverage` in the given directory if the script exists.
|
||||
///
|
||||
/// Used as a QA gate before advancing a story from `3_qa/` to `4_merge/`.
|
||||
/// Returns `(passed, output)`. If the script does not exist, returns `(true, …)`.
|
||||
pub(crate) fn run_coverage_gate(path: &Path) -> Result<(bool, String), String> {
|
||||
let script = path.join("script").join("test_coverage");
|
||||
if !script.exists() {
|
||||
return Ok((
|
||||
true,
|
||||
"script/test_coverage not found; coverage gate skipped.\n".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut output = String::from("=== script/test_coverage ===\n");
|
||||
let result = Command::new(&script)
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run script/test_coverage: {e}"))?;
|
||||
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
output.push_str(&combined);
|
||||
output.push('\n');
|
||||
|
||||
Ok((result.status.success(), output))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── run_project_tests tests ───────────────────────────────────
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_project_tests_uses_script_test_when_present_and_passes() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let path = tmp.path();
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
let script_test = script_dir.join("test");
|
||||
fs::write(&script_test, "#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n").unwrap();
|
||||
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script_test, perms).unwrap();
|
||||
|
||||
let (passed, output) = run_project_tests(path).unwrap();
|
||||
assert!(passed, "script/test exiting 0 should pass");
|
||||
assert!(output.contains("script/test"), "output should mention script/test");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_project_tests_reports_failure_when_script_test_exits_nonzero() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let path = tmp.path();
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
let script_test = script_dir.join("test");
|
||||
fs::write(&script_test, "#!/usr/bin/env bash\nexit 1\n").unwrap();
|
||||
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script_test, perms).unwrap();
|
||||
|
||||
let (passed, output) = run_project_tests(path).unwrap();
|
||||
assert!(!passed, "script/test exiting 1 should fail");
|
||||
assert!(output.contains("script/test"), "output should mention script/test");
|
||||
}
|
||||
|
||||
// ── run_coverage_gate tests ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn coverage_gate_passes_when_script_absent() {
|
||||
use tempfile::tempdir;
|
||||
let tmp = tempdir().unwrap();
|
||||
let (passed, output) = run_coverage_gate(tmp.path()).unwrap();
|
||||
assert!(passed, "coverage gate should pass when script is absent");
|
||||
assert!(
|
||||
output.contains("not found"),
|
||||
"output should mention script not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn coverage_gate_passes_when_script_exits_zero() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let path = tmp.path();
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
let script = script_dir.join("test_coverage");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/usr/bin/env bash\necho 'Rust line coverage: 85%'\necho 'PASS: Coverage 85% meets threshold 0%'\nexit 0\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut perms = fs::metadata(&script).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script, perms).unwrap();
|
||||
|
||||
let (passed, output) = run_coverage_gate(path).unwrap();
|
||||
assert!(passed, "coverage gate should pass when script exits 0");
|
||||
assert!(
|
||||
output.contains("script/test_coverage"),
|
||||
"output should mention script/test_coverage"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn coverage_gate_fails_when_script_exits_nonzero() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let path = tmp.path();
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
let script = script_dir.join("test_coverage");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut perms = fs::metadata(&script).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script, perms).unwrap();
|
||||
|
||||
let (passed, output) = run_coverage_gate(path).unwrap();
|
||||
assert!(!passed, "coverage gate should fail when script exits 1");
|
||||
assert!(
|
||||
output.contains("script/test_coverage"),
|
||||
"output should mention script/test_coverage"
|
||||
);
|
||||
}
|
||||
|
||||
// ── worktree_has_committed_work tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn worktree_has_committed_work_false_on_fresh_repo() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
// init_git_repo creates the initial commit on the default branch.
|
||||
// HEAD IS the base branch — no commits ahead.
|
||||
init_git_repo(repo);
|
||||
assert!(!worktree_has_committed_work(repo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_has_committed_work_true_after_commit_on_feature_branch() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project_root = tmp.path().join("project");
|
||||
fs::create_dir_all(&project_root).unwrap();
|
||||
init_git_repo(&project_root);
|
||||
|
||||
// Create a git worktree on a feature branch.
|
||||
let wt_path = tmp.path().join("wt");
|
||||
Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
&wt_path.to_string_lossy(),
|
||||
"-b",
|
||||
"feature/story-99_test",
|
||||
])
|
||||
.current_dir(&project_root)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// No commits on the feature branch yet — same as base branch.
|
||||
assert!(!worktree_has_committed_work(&wt_path));
|
||||
|
||||
// Add a commit to the feature branch in the worktree.
|
||||
fs::write(wt_path.join("work.txt"), "done").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&wt_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.email=test@test.com",
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"commit",
|
||||
"-m",
|
||||
"coder: implement story",
|
||||
])
|
||||
.current_dir(&wt_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Now the feature branch is ahead of the base branch.
|
||||
assert!(worktree_has_committed_work(&wt_path));
|
||||
}
|
||||
}
|
||||
556
server/src/agents/lifecycle.rs
Normal file
556
server/src/agents/lifecycle.rs
Normal file
@@ -0,0 +1,556 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::io::story_metadata::clear_front_matter_field;
|
||||
use crate::slog;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn item_type_from_id(item_id: &str) -> &'static str {
|
||||
// New format: {digits}_{type}_{slug}
|
||||
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||
if after_num.starts_with("_bug_") {
|
||||
"bug"
|
||||
} else if after_num.starts_with("_spike_") {
|
||||
"spike"
|
||||
} else {
|
||||
"story"
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the source directory path for a work item (always work/1_upcoming/).
|
||||
fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf {
|
||||
project_root.join(".story_kit").join("work").join("1_upcoming")
|
||||
}
|
||||
|
||||
/// Return the done directory path for a work item (always work/5_done/).
|
||||
fn item_archive_dir(project_root: &Path, _item_id: &str) -> PathBuf {
|
||||
project_root.join(".story_kit").join("work").join("5_done")
|
||||
}
|
||||
|
||||
/// Move a work item (story, bug, or spike) from `work/1_upcoming/` to `work/2_current/`.
|
||||
///
|
||||
/// Idempotent: if the item is already in `2_current/`, returns Ok without committing.
|
||||
/// If the item is not found in `1_upcoming/`, logs a warning and returns Ok.
|
||||
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||
let sk = project_root.join(".story_kit").join("work");
|
||||
let current_dir = sk.join("2_current");
|
||||
let current_path = current_dir.join(format!("{story_id}.md"));
|
||||
|
||||
if current_path.exists() {
|
||||
// Already in 2_current/ — idempotent, nothing to do.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source_dir = item_source_dir(project_root, story_id);
|
||||
let source_path = source_dir.join(format!("{story_id}.md"));
|
||||
|
||||
if !source_path.exists() {
|
||||
slog!(
|
||||
"[lifecycle] Work item '{story_id}' not found in {}; skipping move to 2_current/",
|
||||
source_dir.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(¤t_dir)
|
||||
.map_err(|e| format!("Failed to create work/2_current/ directory: {e}"))?;
|
||||
|
||||
std::fs::rename(&source_path, ¤t_path)
|
||||
.map_err(|e| format!("Failed to move '{story_id}' to 2_current/: {e}"))?;
|
||||
|
||||
slog!(
|
||||
"[lifecycle] Moved '{story_id}' from {} to work/2_current/",
|
||||
source_dir.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a feature branch `feature/story-{story_id}` exists and has
|
||||
/// commits that are not yet on master. Returns `true` when there is unmerged
|
||||
/// work, `false` when there is no branch or all its commits are already
|
||||
/// reachable from master.
|
||||
pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str) -> bool {
|
||||
let branch = format!("feature/story-{story_id}");
|
||||
|
||||
// Check if the branch exists.
|
||||
let branch_check = Command::new("git")
|
||||
.args(["rev-parse", "--verify", &branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
match branch_check {
|
||||
Ok(out) if out.status.success() => {}
|
||||
_ => return false, // No feature branch → nothing to merge.
|
||||
}
|
||||
|
||||
// Check if the branch has commits not reachable from master.
|
||||
let log = Command::new("git")
|
||||
.args(["log", &format!("master..{branch}"), "--oneline"])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
match log {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
!stdout.trim().is_empty()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a story from `work/2_current/` to `work/5_done/` and auto-commit.
|
||||
///
|
||||
/// * If the story is in `2_current/`, 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 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> {
|
||||
let sk = project_root.join(".story_kit").join("work");
|
||||
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 done_dir = sk.join("5_done");
|
||||
let done_path = done_dir.join(format!("{story_id}.md"));
|
||||
let archived_path = sk.join("6_archived").join(format!("{story_id}.md"));
|
||||
|
||||
if done_path.exists() || archived_path.exists() {
|
||||
// Already in done or archived — idempotent, nothing to do.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check 2_current/ first, then 4_merge/
|
||||
let source_path = if current_path.exists() {
|
||||
current_path.clone()
|
||||
} else if merge_path.exists() {
|
||||
merge_path.clone()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Story '{story_id}' not found in work/2_current/ or work/4_merge/. Cannot accept story."
|
||||
));
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(&done_dir)
|
||||
.map_err(|e| format!("Failed to create work/5_done/ directory: {e}"))?;
|
||||
std::fs::rename(&source_path, &done_path)
|
||||
.map_err(|e| format!("Failed to move story '{story_id}' to 5_done/: {e}"))?;
|
||||
|
||||
// Strip stale merge_failure from front matter now that the story is done.
|
||||
if let Err(e) = clear_front_matter_field(&done_path, "merge_failure") {
|
||||
slog!("[lifecycle] Warning: could not clear merge_failure from '{story_id}': {e}");
|
||||
}
|
||||
|
||||
let from_dir = if source_path == current_path {
|
||||
"work/2_current/"
|
||||
} else {
|
||||
"work/4_merge/"
|
||||
};
|
||||
slog!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_done/");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a story/bug from `work/2_current/` or `work/3_qa/` to `work/4_merge/`.
|
||||
///
|
||||
/// This stages a work item as ready for the mergemaster to pick up and merge into master.
|
||||
/// Idempotent: if already in `4_merge/`, returns Ok without committing.
|
||||
pub fn move_story_to_merge(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||
let sk = project_root.join(".story_kit").join("work");
|
||||
let current_path = sk.join("2_current").join(format!("{story_id}.md"));
|
||||
let qa_path = sk.join("3_qa").join(format!("{story_id}.md"));
|
||||
let merge_dir = sk.join("4_merge");
|
||||
let merge_path = merge_dir.join(format!("{story_id}.md"));
|
||||
|
||||
if merge_path.exists() {
|
||||
// Already in 4_merge/ — idempotent, nothing to do.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Accept from 2_current/ (manual trigger) or 3_qa/ (pipeline advancement from QA stage).
|
||||
let source_path = if current_path.exists() {
|
||||
current_path.clone()
|
||||
} else if qa_path.exists() {
|
||||
qa_path.clone()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Work item '{story_id}' not found in work/2_current/ or work/3_qa/. Cannot move to 4_merge/."
|
||||
));
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(&merge_dir)
|
||||
.map_err(|e| format!("Failed to create work/4_merge/ directory: {e}"))?;
|
||||
std::fs::rename(&source_path, &merge_path)
|
||||
.map_err(|e| format!("Failed to move '{story_id}' to 4_merge/: {e}"))?;
|
||||
|
||||
let from_dir = if source_path == current_path {
|
||||
"work/2_current/"
|
||||
} else {
|
||||
"work/3_qa/"
|
||||
};
|
||||
slog!("[lifecycle] Moved '{story_id}' from {from_dir} to work/4_merge/");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a story/bug from `work/2_current/` to `work/3_qa/` and auto-commit.
|
||||
///
|
||||
/// This stages a work item for QA review before merging to master.
|
||||
/// Idempotent: if already in `3_qa/`, returns Ok without committing.
|
||||
pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||
let sk = project_root.join(".story_kit").join("work");
|
||||
let current_path = sk.join("2_current").join(format!("{story_id}.md"));
|
||||
let qa_dir = sk.join("3_qa");
|
||||
let qa_path = qa_dir.join(format!("{story_id}.md"));
|
||||
|
||||
if qa_path.exists() {
|
||||
// Already in 3_qa/ — idempotent, nothing to do.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !current_path.exists() {
|
||||
return Err(format!(
|
||||
"Work item '{story_id}' not found in work/2_current/. Cannot move to 3_qa/."
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&qa_dir)
|
||||
.map_err(|e| format!("Failed to create work/3_qa/ directory: {e}"))?;
|
||||
std::fs::rename(¤t_path, &qa_path)
|
||||
.map_err(|e| format!("Failed to move '{story_id}' to 3_qa/: {e}"))?;
|
||||
|
||||
slog!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_done/` and auto-commit.
|
||||
///
|
||||
/// * If the bug is in `2_current/`, it is moved to `5_done/` and committed.
|
||||
/// * If the bug is still in `1_upcoming/` (never started), it is moved directly to `5_done/`.
|
||||
/// * If the bug is already in `5_done/`, this is a no-op (idempotent).
|
||||
/// * If the bug is not found anywhere, an error is returned.
|
||||
pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), String> {
|
||||
let sk = project_root.join(".story_kit").join("work");
|
||||
let current_path = sk.join("2_current").join(format!("{bug_id}.md"));
|
||||
let upcoming_path = sk.join("1_upcoming").join(format!("{bug_id}.md"));
|
||||
let archive_dir = item_archive_dir(project_root, bug_id);
|
||||
let archive_path = archive_dir.join(format!("{bug_id}.md"));
|
||||
|
||||
if archive_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source_path = if current_path.exists() {
|
||||
current_path.clone()
|
||||
} else if upcoming_path.exists() {
|
||||
upcoming_path.clone()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Bug '{bug_id}' not found in work/2_current/ or work/1_upcoming/. Cannot close bug."
|
||||
));
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(&archive_dir)
|
||||
.map_err(|e| format!("Failed to create work/5_done/ directory: {e}"))?;
|
||||
std::fs::rename(&source_path, &archive_path)
|
||||
.map_err(|e| format!("Failed to move bug '{bug_id}' to 5_done/: {e}"))?;
|
||||
|
||||
slog!(
|
||||
"[lifecycle] Closed bug '{bug_id}' → work/5_done/"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── move_story_to_current tests ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn move_story_to_current_moves_file() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(upcoming.join("10_story_foo.md"), "test").unwrap();
|
||||
|
||||
move_story_to_current(root, "10_story_foo").unwrap();
|
||||
|
||||
assert!(!upcoming.join("10_story_foo.md").exists());
|
||||
assert!(current.join("10_story_foo.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_current_is_idempotent_when_already_current() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("11_story_foo.md"), "test").unwrap();
|
||||
|
||||
move_story_to_current(root, "11_story_foo").unwrap();
|
||||
assert!(current.join("11_story_foo.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_current_noop_when_not_in_upcoming() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(move_story_to_current(tmp.path(), "99_missing").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_bug_to_current_moves_from_upcoming() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(upcoming.join("1_bug_test.md"), "# Bug 1\n").unwrap();
|
||||
|
||||
move_story_to_current(root, "1_bug_test").unwrap();
|
||||
|
||||
assert!(!upcoming.join("1_bug_test.md").exists());
|
||||
assert!(current.join("1_bug_test.md").exists());
|
||||
}
|
||||
|
||||
// ── close_bug_to_archive tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn close_bug_moves_from_current_to_archive() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("2_bug_test.md"), "# Bug 2\n").unwrap();
|
||||
|
||||
close_bug_to_archive(root, "2_bug_test").unwrap();
|
||||
|
||||
assert!(!current.join("2_bug_test.md").exists());
|
||||
assert!(root.join(".story_kit/work/5_done/2_bug_test.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_bug_moves_from_upcoming_when_not_started() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::write(upcoming.join("3_bug_test.md"), "# Bug 3\n").unwrap();
|
||||
|
||||
close_bug_to_archive(root, "3_bug_test").unwrap();
|
||||
|
||||
assert!(!upcoming.join("3_bug_test.md").exists());
|
||||
assert!(root.join(".story_kit/work/5_done/3_bug_test.md").exists());
|
||||
}
|
||||
|
||||
// ── item_type_from_id tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn item_type_from_id_detects_types() {
|
||||
assert_eq!(item_type_from_id("1_bug_test"), "bug");
|
||||
assert_eq!(item_type_from_id("1_spike_research"), "spike");
|
||||
assert_eq!(item_type_from_id("50_story_my_story"), "story");
|
||||
assert_eq!(item_type_from_id("1_story_simple"), "story");
|
||||
}
|
||||
|
||||
// ── move_story_to_merge tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_moves_file() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("20_story_foo.md"), "test").unwrap();
|
||||
|
||||
move_story_to_merge(root, "20_story_foo").unwrap();
|
||||
|
||||
assert!(!current.join("20_story_foo.md").exists());
|
||||
assert!(root.join(".story_kit/work/4_merge/20_story_foo.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_from_qa_dir() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let qa_dir = root.join(".story_kit/work/3_qa");
|
||||
fs::create_dir_all(&qa_dir).unwrap();
|
||||
fs::write(qa_dir.join("40_story_test.md"), "test").unwrap();
|
||||
|
||||
move_story_to_merge(root, "40_story_test").unwrap();
|
||||
|
||||
assert!(!qa_dir.join("40_story_test.md").exists());
|
||||
assert!(root.join(".story_kit/work/4_merge/40_story_test.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_idempotent_when_already_in_merge() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let merge_dir = root.join(".story_kit/work/4_merge");
|
||||
fs::create_dir_all(&merge_dir).unwrap();
|
||||
fs::write(merge_dir.join("21_story_test.md"), "test").unwrap();
|
||||
|
||||
move_story_to_merge(root, "21_story_test").unwrap();
|
||||
assert!(merge_dir.join("21_story_test.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_errors_when_not_in_current_or_qa() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = move_story_to_merge(tmp.path(), "99_nonexistent");
|
||||
assert!(result.unwrap_err().contains("not found in work/2_current/ or work/3_qa/"));
|
||||
}
|
||||
|
||||
// ── move_story_to_qa tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn move_story_to_qa_moves_file() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("30_story_qa.md"), "test").unwrap();
|
||||
|
||||
move_story_to_qa(root, "30_story_qa").unwrap();
|
||||
|
||||
assert!(!current.join("30_story_qa.md").exists());
|
||||
assert!(root.join(".story_kit/work/3_qa/30_story_qa.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_qa_idempotent_when_already_in_qa() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let qa_dir = root.join(".story_kit/work/3_qa");
|
||||
fs::create_dir_all(&qa_dir).unwrap();
|
||||
fs::write(qa_dir.join("31_story_test.md"), "test").unwrap();
|
||||
|
||||
move_story_to_qa(root, "31_story_test").unwrap();
|
||||
assert!(qa_dir.join("31_story_test.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_qa_errors_when_not_in_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = move_story_to_qa(tmp.path(), "99_nonexistent");
|
||||
assert!(result.unwrap_err().contains("not found in work/2_current/"));
|
||||
}
|
||||
|
||||
// ── move_story_to_archived tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn move_story_to_archived_finds_in_merge_dir() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let merge_dir = root.join(".story_kit/work/4_merge");
|
||||
fs::create_dir_all(&merge_dir).unwrap();
|
||||
fs::write(merge_dir.join("22_story_test.md"), "test").unwrap();
|
||||
|
||||
move_story_to_archived(root, "22_story_test").unwrap();
|
||||
|
||||
assert!(!merge_dir.join("22_story_test.md").exists());
|
||||
assert!(root.join(".story_kit/work/5_done/22_story_test.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_archived_error_when_not_in_current_or_merge() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = move_story_to_archived(tmp.path(), "99_nonexistent");
|
||||
assert!(result.unwrap_err().contains("4_merge"));
|
||||
}
|
||||
|
||||
// ── feature_branch_has_unmerged_changes tests ────────────────────────────
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Bug 226: feature_branch_has_unmerged_changes returns true when the
|
||||
/// feature branch has commits not on master.
|
||||
#[test]
|
||||
fn feature_branch_has_unmerged_changes_detects_unmerged_code() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
// Create a feature branch with a code commit.
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "feature/story-50_story_test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::write(repo.join("feature.rs"), "fn main() {}").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add feature"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["checkout", "master"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
feature_branch_has_unmerged_changes(repo, "50_story_test"),
|
||||
"should detect unmerged changes on feature branch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug 226: feature_branch_has_unmerged_changes returns false when no
|
||||
/// feature branch exists.
|
||||
#[test]
|
||||
fn feature_branch_has_unmerged_changes_false_when_no_branch() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
assert!(
|
||||
!feature_branch_has_unmerged_changes(repo, "99_nonexistent"),
|
||||
"should return false when no feature branch"
|
||||
);
|
||||
}
|
||||
}
|
||||
1638
server/src/agents/merge.rs
Normal file
1638
server/src/agents/merge.rs
Normal file
File diff suppressed because it is too large
Load Diff
181
server/src/agents/mod.rs
Normal file
181
server/src/agents/mod.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
pub mod gates;
|
||||
pub mod lifecycle;
|
||||
pub mod merge;
|
||||
mod pool;
|
||||
mod pty;
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use lifecycle::{
|
||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived,
|
||||
move_story_to_merge, move_story_to_qa,
|
||||
};
|
||||
pub use pool::AgentPool;
|
||||
|
||||
/// Events emitted during server startup reconciliation to broadcast real-time
|
||||
/// progress to connected WebSocket clients.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ReconciliationEvent {
|
||||
/// The story being reconciled, or empty string for the overall "done" event.
|
||||
pub story_id: String,
|
||||
/// Coarse status: "checking", "gates_running", "advanced", "skipped", "failed", "done"
|
||||
pub status: String,
|
||||
/// Human-readable details.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Events streamed from a running agent to SSE clients.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AgentEvent {
|
||||
/// Agent status changed.
|
||||
Status {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
status: String,
|
||||
},
|
||||
/// Raw text output from the agent process.
|
||||
Output {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
text: String,
|
||||
},
|
||||
/// Agent produced a JSON event from `--output-format stream-json`.
|
||||
AgentJson {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
/// Agent finished.
|
||||
Done {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
session_id: Option<String>,
|
||||
},
|
||||
/// Agent errored.
|
||||
Error {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
message: String,
|
||||
},
|
||||
/// Thinking tokens from an extended-thinking block.
|
||||
Thinking {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Pending => write!(f, "pending"),
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Completed => write!(f, "completed"),
|
||||
Self::Failed => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pipeline stages for automatic story advancement.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PipelineStage {
|
||||
/// Coding agents (coder-1, coder-2, etc.)
|
||||
Coder,
|
||||
/// QA review agent
|
||||
Qa,
|
||||
/// Mergemaster agent
|
||||
Mergemaster,
|
||||
/// Supervisors and unknown agents — no automatic advancement.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Determine the pipeline stage from an agent name.
|
||||
pub fn pipeline_stage(agent_name: &str) -> PipelineStage {
|
||||
match agent_name {
|
||||
"qa" => PipelineStage::Qa,
|
||||
"mergemaster" => PipelineStage::Mergemaster,
|
||||
name if name.starts_with("coder") => PipelineStage::Coder,
|
||||
_ => PipelineStage::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the pipeline stage for a configured agent.
|
||||
///
|
||||
/// Prefers the explicit `stage` config field (added in Bug 150) over the
|
||||
/// legacy name-based heuristic so that agents with non-standard names
|
||||
/// (e.g. `qa-2`, `coder-opus`) are assigned to the correct stage.
|
||||
pub(crate) fn agent_config_stage(cfg: &AgentConfig) -> PipelineStage {
|
||||
match cfg.stage.as_deref() {
|
||||
Some("coder") => PipelineStage::Coder,
|
||||
Some("qa") => PipelineStage::Qa,
|
||||
Some("mergemaster") => PipelineStage::Mergemaster,
|
||||
Some(_) => PipelineStage::Other,
|
||||
None => pipeline_stage(&cfg.name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion report produced when acceptance gates are run.
|
||||
///
|
||||
/// Created automatically by the server when an agent process exits normally,
|
||||
/// or via the internal `report_completion` method.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct CompletionReport {
|
||||
pub summary: String,
|
||||
pub gates_passed: bool,
|
||||
pub gate_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct AgentInfo {
|
||||
pub story_id: String,
|
||||
pub agent_name: String,
|
||||
pub status: AgentStatus,
|
||||
pub session_id: Option<String>,
|
||||
pub worktree_path: Option<String>,
|
||||
pub base_branch: Option<String>,
|
||||
pub completion: Option<CompletionReport>,
|
||||
/// UUID identifying the persistent log file for this session.
|
||||
pub log_session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── pipeline_stage tests ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pipeline_stage_detects_coders() {
|
||||
assert_eq!(pipeline_stage("coder-1"), PipelineStage::Coder);
|
||||
assert_eq!(pipeline_stage("coder-2"), PipelineStage::Coder);
|
||||
assert_eq!(pipeline_stage("coder-3"), PipelineStage::Coder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_stage_detects_qa() {
|
||||
assert_eq!(pipeline_stage("qa"), PipelineStage::Qa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_stage_detects_mergemaster() {
|
||||
assert_eq!(pipeline_stage("mergemaster"), PipelineStage::Mergemaster);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_stage_supervisor_is_other() {
|
||||
assert_eq!(pipeline_stage("supervisor"), PipelineStage::Other);
|
||||
assert_eq!(pipeline_stage("default"), PipelineStage::Other);
|
||||
assert_eq!(pipeline_stage("unknown"), PipelineStage::Other);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
490
server/src/agents/pty.rs
Normal file
490
server/src/agents/pty.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use portable_pty::{ChildKiller, CommandBuilder, PtySize, native_pty_system};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::AgentEvent;
|
||||
use crate::agent_log::AgentLogWriter;
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
|
||||
fn composite_key(story_id: &str, agent_name: &str) -> String {
|
||||
format!("{story_id}:{agent_name}")
|
||||
}
|
||||
|
||||
struct ChildKillerGuard {
|
||||
killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl Drop for ChildKillerGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut killers) = self.killers.lock() {
|
||||
killers.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn claude agent in a PTY and stream events through the broadcast channel.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run_agent_pty_streaming(
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
prompt: &str,
|
||||
cwd: &str,
|
||||
tx: &broadcast::Sender<AgentEvent>,
|
||||
event_log: &Arc<Mutex<Vec<AgentEvent>>>,
|
||||
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
|
||||
inactivity_timeout_secs: u64,
|
||||
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let sid = story_id.to_string();
|
||||
let aname = agent_name.to_string();
|
||||
let cmd = command.to_string();
|
||||
let args = args.to_vec();
|
||||
let prompt = prompt.to_string();
|
||||
let cwd = cwd.to_string();
|
||||
let tx = tx.clone();
|
||||
let event_log = event_log.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
run_agent_pty_blocking(
|
||||
&sid,
|
||||
&aname,
|
||||
&cmd,
|
||||
&args,
|
||||
&prompt,
|
||||
&cwd,
|
||||
&tx,
|
||||
&event_log,
|
||||
log_writer.as_deref(),
|
||||
inactivity_timeout_secs,
|
||||
&child_killers,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Agent task panicked: {e}"))?
|
||||
}
|
||||
|
||||
/// Dispatch a `stream_event` from Claude Code's `--include-partial-messages` output.
|
||||
///
|
||||
/// Extracts `thinking_delta` and `text_delta` from `content_block_delta` events
|
||||
/// and routes them as `AgentEvent::Thinking` and `AgentEvent::Output` respectively.
|
||||
/// This ensures thinking traces flow through the dedicated `ThinkingBlock` UI
|
||||
/// component rather than appearing as unbounded regular output.
|
||||
fn handle_agent_stream_event(
|
||||
event: &serde_json::Value,
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
tx: &broadcast::Sender<AgentEvent>,
|
||||
event_log: &Mutex<Vec<AgentEvent>>,
|
||||
log_writer: Option<&Mutex<AgentLogWriter>>,
|
||||
) {
|
||||
let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
if event_type == "content_block_delta"
|
||||
&& let Some(delta) = event.get("delta")
|
||||
{
|
||||
let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match delta_type {
|
||||
"thinking_delta" => {
|
||||
if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) {
|
||||
emit_event(
|
||||
AgentEvent::Thinking {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
text: thinking.to_string(),
|
||||
},
|
||||
tx,
|
||||
event_log,
|
||||
log_writer,
|
||||
);
|
||||
}
|
||||
}
|
||||
"text_delta" => {
|
||||
if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
|
||||
emit_event(
|
||||
AgentEvent::Output {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
text: text.to_string(),
|
||||
},
|
||||
tx,
|
||||
event_log,
|
||||
log_writer,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to send an event to broadcast, event log, and optional persistent log file.
|
||||
pub(super) fn emit_event(
|
||||
event: AgentEvent,
|
||||
tx: &broadcast::Sender<AgentEvent>,
|
||||
event_log: &Mutex<Vec<AgentEvent>>,
|
||||
log_writer: Option<&Mutex<AgentLogWriter>>,
|
||||
) {
|
||||
if let Ok(mut log) = event_log.lock() {
|
||||
log.push(event.clone());
|
||||
}
|
||||
if let Some(writer) = log_writer
|
||||
&& let Ok(mut w) = writer.lock()
|
||||
&& let Err(e) = w.write_event(&event)
|
||||
{
|
||||
eprintln!("[agent_log] Failed to write event to log file: {e}");
|
||||
}
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_agent_pty_blocking(
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
prompt: &str,
|
||||
cwd: &str,
|
||||
tx: &broadcast::Sender<AgentEvent>,
|
||||
event_log: &Mutex<Vec<AgentEvent>>,
|
||||
log_writer: Option<&Mutex<AgentLogWriter>>,
|
||||
inactivity_timeout_secs: u64,
|
||||
child_killers: &Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let pty_system = native_pty_system();
|
||||
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows: 50,
|
||||
cols: 200,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| format!("Failed to open PTY: {e}"))?;
|
||||
|
||||
let mut cmd = CommandBuilder::new(command);
|
||||
|
||||
// -p <prompt> must come first
|
||||
cmd.arg("-p");
|
||||
cmd.arg(prompt);
|
||||
|
||||
// Add configured args (e.g., --directory /path/to/worktree, --model, etc.)
|
||||
for arg in args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
cmd.arg("--output-format");
|
||||
cmd.arg("stream-json");
|
||||
cmd.arg("--verbose");
|
||||
// Enable partial streaming so we receive thinking_delta and text_delta
|
||||
// events in real-time, rather than only complete assistant events.
|
||||
// Without this, thinking traces may not appear in the structured output
|
||||
// and instead leak as unstructured PTY text.
|
||||
cmd.arg("--include-partial-messages");
|
||||
|
||||
// Supervised agents don't need interactive permission prompts
|
||||
cmd.arg("--permission-mode");
|
||||
cmd.arg("bypassPermissions");
|
||||
|
||||
cmd.cwd(cwd);
|
||||
cmd.env("NO_COLOR", "1");
|
||||
|
||||
// Allow spawning Claude Code from within a Claude Code session
|
||||
cmd.env_remove("CLAUDECODE");
|
||||
cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
|
||||
|
||||
slog!("[agent:{story_id}:{agent_name}] Spawning {command} in {cwd} with args: {args:?}");
|
||||
|
||||
let mut child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn agent for {story_id}:{agent_name}: {e}"))?;
|
||||
|
||||
// Register the child killer so that kill_all_children() / stop_agent() can
|
||||
// terminate this process on server shutdown, even if the blocking thread
|
||||
// cannot be interrupted. The ChildKillerGuard deregisters on function exit.
|
||||
let killer_key = composite_key(story_id, agent_name);
|
||||
{
|
||||
let killer = child.clone_killer();
|
||||
if let Ok(mut killers) = child_killers.lock() {
|
||||
killers.insert(killer_key.clone(), killer);
|
||||
}
|
||||
}
|
||||
let _killer_guard = ChildKillerGuard {
|
||||
killers: Arc::clone(child_killers),
|
||||
key: killer_key,
|
||||
};
|
||||
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
||||
|
||||
drop(pair.master);
|
||||
|
||||
// Spawn a reader thread to collect PTY output lines.
|
||||
// We use a channel so the main thread can apply an inactivity deadline
|
||||
// via recv_timeout: if no output arrives within the configured window
|
||||
// the process is killed and the agent is marked Failed.
|
||||
let (line_tx, line_rx) = std::sync::mpsc::channel::<std::io::Result<String>>();
|
||||
std::thread::spawn(move || {
|
||||
let buf_reader = BufReader::new(reader);
|
||||
for line in buf_reader.lines() {
|
||||
if line_tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let timeout_dur = if inactivity_timeout_secs > 0 {
|
||||
Some(std::time::Duration::from_secs(inactivity_timeout_secs))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let recv_result = match timeout_dur {
|
||||
Some(dur) => line_rx.recv_timeout(dur),
|
||||
None => line_rx
|
||||
.recv()
|
||||
.map_err(|_| std::sync::mpsc::RecvTimeoutError::Disconnected),
|
||||
};
|
||||
|
||||
let line = match recv_result {
|
||||
Ok(Ok(l)) => l,
|
||||
Ok(Err(_)) => {
|
||||
// IO error reading from PTY — treat as EOF.
|
||||
break;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Reader thread exited (EOF from PTY).
|
||||
break;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
slog_warn!(
|
||||
"[agent:{story_id}:{agent_name}] Inactivity timeout after \
|
||||
{inactivity_timeout_secs}s with no output. Killing process."
|
||||
);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!(
|
||||
"Agent inactivity timeout: no output received for {inactivity_timeout_secs}s"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
let json: serde_json::Value = match serde_json::from_str(trimmed) {
|
||||
Ok(j) => j,
|
||||
Err(_) => {
|
||||
// Non-JSON output (terminal escapes etc.) — send as raw output
|
||||
emit_event(
|
||||
AgentEvent::Output {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
text: trimmed.to_string(),
|
||||
},
|
||||
tx,
|
||||
event_log,
|
||||
log_writer,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"system" => {
|
||||
session_id = json
|
||||
.get("session_id")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
// With --include-partial-messages, thinking and text arrive
|
||||
// incrementally via stream_event → content_block_delta. Handle
|
||||
// them here for real-time streaming to the frontend.
|
||||
"stream_event" => {
|
||||
if let Some(event) = json.get("event") {
|
||||
handle_agent_stream_event(
|
||||
event,
|
||||
story_id,
|
||||
agent_name,
|
||||
tx,
|
||||
event_log,
|
||||
log_writer,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Complete assistant events are skipped for content extraction
|
||||
// because thinking and text already arrived via stream_event.
|
||||
// The raw JSON is still forwarded as AgentJson below.
|
||||
"assistant" | "user" | "result" => {}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Forward all JSON events
|
||||
emit_event(
|
||||
AgentEvent::AgentJson {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
data: json,
|
||||
},
|
||||
tx,
|
||||
event_log,
|
||||
log_writer,
|
||||
);
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
slog!(
|
||||
"[agent:{story_id}:{agent_name}] Done. Session: {:?}",
|
||||
session_id
|
||||
);
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentEvent;
|
||||
|
||||
#[test]
|
||||
fn test_emit_event_writes_to_log_writer() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let log_writer =
|
||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-emit").unwrap();
|
||||
let log_mutex = Mutex::new(log_writer);
|
||||
|
||||
let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
|
||||
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||
|
||||
let event = AgentEvent::Status {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
};
|
||||
|
||||
emit_event(event, &tx, &event_log, Some(&log_mutex));
|
||||
|
||||
// Verify event was added to in-memory log
|
||||
let mem_events = event_log.lock().unwrap();
|
||||
assert_eq!(mem_events.len(), 1);
|
||||
drop(mem_events);
|
||||
|
||||
// Verify event was written to the log file
|
||||
let log_path =
|
||||
crate::agent_log::log_file_path(root, "42_story_foo", "coder-1", "sess-emit");
|
||||
let entries = crate::agent_log::read_log(&log_path).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].event["type"], "status");
|
||||
assert_eq!(entries[0].event["status"], "running");
|
||||
}
|
||||
|
||||
// ── bug 167: handle_agent_stream_event routes thinking/text correctly ───
|
||||
|
||||
#[test]
|
||||
fn stream_event_thinking_delta_emits_thinking_event() {
|
||||
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
|
||||
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||
|
||||
let event = serde_json::json!({
|
||||
"type": "content_block_delta",
|
||||
"delta": {"type": "thinking_delta", "thinking": "Let me analyze this..."}
|
||||
});
|
||||
|
||||
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
|
||||
|
||||
let received = rx.try_recv().unwrap();
|
||||
match received {
|
||||
AgentEvent::Thinking {
|
||||
story_id,
|
||||
agent_name,
|
||||
text,
|
||||
} => {
|
||||
assert_eq!(story_id, "s1");
|
||||
assert_eq!(agent_name, "coder-1");
|
||||
assert_eq!(text, "Let me analyze this...");
|
||||
}
|
||||
other => panic!("Expected Thinking event, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_event_text_delta_emits_output_event() {
|
||||
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
|
||||
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||
|
||||
let event = serde_json::json!({
|
||||
"type": "content_block_delta",
|
||||
"delta": {"type": "text_delta", "text": "Here is the result."}
|
||||
});
|
||||
|
||||
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
|
||||
|
||||
let received = rx.try_recv().unwrap();
|
||||
match received {
|
||||
AgentEvent::Output {
|
||||
story_id,
|
||||
agent_name,
|
||||
text,
|
||||
} => {
|
||||
assert_eq!(story_id, "s1");
|
||||
assert_eq!(agent_name, "coder-1");
|
||||
assert_eq!(text, "Here is the result.");
|
||||
}
|
||||
other => panic!("Expected Output event, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_event_input_json_delta_ignored() {
|
||||
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
|
||||
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||
|
||||
let event = serde_json::json!({
|
||||
"type": "content_block_delta",
|
||||
"delta": {"type": "input_json_delta", "partial_json": "{\"file\":"}
|
||||
});
|
||||
|
||||
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
|
||||
|
||||
// No event should be emitted for tool argument deltas
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_event_non_delta_type_ignored() {
|
||||
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
|
||||
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||
|
||||
let event = serde_json::json!({
|
||||
"type": "message_start",
|
||||
"message": {"role": "assistant"}
|
||||
});
|
||||
|
||||
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
|
||||
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -632,6 +632,48 @@ name = "coder"
|
||||
assert_eq!(config.watcher, WatcherConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coder_agents_have_root_cause_guidance() {
|
||||
// Load the actual project.toml and verify all coder-stage agents
|
||||
// include root cause investigation guidance for bugs.
|
||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let project_root = manifest_dir.parent().unwrap();
|
||||
let config = ProjectConfig::load(project_root).unwrap();
|
||||
|
||||
let coder_agents: Vec<_> = config
|
||||
.agent
|
||||
.iter()
|
||||
.filter(|a| a.stage.as_deref() == Some("coder"))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!coder_agents.is_empty(),
|
||||
"Expected at least one coder-stage agent in project.toml"
|
||||
);
|
||||
|
||||
for agent in coder_agents {
|
||||
let prompt = &agent.prompt;
|
||||
let system_prompt = agent.system_prompt.as_deref().unwrap_or("");
|
||||
let combined = format!("{prompt} {system_prompt}");
|
||||
|
||||
assert!(
|
||||
combined.contains("root cause"),
|
||||
"Coder agent '{}' must mention 'root cause' in prompt or system_prompt",
|
||||
agent.name
|
||||
);
|
||||
assert!(
|
||||
combined.contains("git bisect") || combined.contains("git log"),
|
||||
"Coder agent '{}' must mention 'git bisect' or 'git log' for bug investigation",
|
||||
agent.name
|
||||
);
|
||||
assert!(
|
||||
combined.to_lowercase().contains("do not") || combined.contains("surgical"),
|
||||
"Coder agent '{}' must discourage adding abstractions/workarounds",
|
||||
agent.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_config_preserved_in_legacy_format() {
|
||||
let toml_str = r#"
|
||||
|
||||
@@ -9,6 +9,9 @@ use std::sync::Arc;
|
||||
///
|
||||
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
|
||||
/// with `data:` prefix and double newline terminator per the SSE spec.
|
||||
///
|
||||
/// `AgentEvent::Thinking` events are intentionally excluded — thinking traces
|
||||
/// are internal model state and must never be displayed in the UI.
|
||||
#[handler]
|
||||
pub async fn agent_stream(
|
||||
Path((story_id, agent_name)): Path<(String, String)>,
|
||||
@@ -27,6 +30,11 @@ pub async fn agent_stream(
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
// Never forward thinking traces to the UI — they are
|
||||
// internal model state and must not be displayed.
|
||||
if matches!(event, crate::agents::AgentEvent::Thinking { .. }) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string(&event) {
|
||||
yield Ok::<_, std::io::Error>(format!("data: {json}\n\n"));
|
||||
}
|
||||
@@ -56,3 +64,145 @@ pub async fn agent_stream(
|
||||
futures::StreamExt::map(stream, |r| r.map(bytes::Bytes::from)),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::{AgentEvent, AgentStatus};
|
||||
use crate::http::context::AppContext;
|
||||
use poem::{EndpointExt, Route, get};
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_app(ctx: Arc<AppContext>) -> impl poem::Endpoint {
|
||||
Route::new()
|
||||
.at(
|
||||
"/agents/:story_id/:agent_name/stream",
|
||||
get(agent_stream),
|
||||
)
|
||||
.data(ctx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thinking_events_are_not_forwarded_via_sse() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
||||
|
||||
// Inject a running agent and get its broadcast sender.
|
||||
let tx = ctx
|
||||
.agents
|
||||
.inject_test_agent("1_story", "coder-1", AgentStatus::Running);
|
||||
|
||||
// Spawn a task that sends events after the SSE connection is established.
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Brief pause so the SSE handler has subscribed before we emit.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
|
||||
// Thinking event — must be filtered out.
|
||||
let _ = tx_clone.send(AgentEvent::Thinking {
|
||||
story_id: "1_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "secret thinking text".to_string(),
|
||||
});
|
||||
|
||||
// Output event — must be forwarded.
|
||||
let _ = tx_clone.send(AgentEvent::Output {
|
||||
story_id: "1_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "visible output".to_string(),
|
||||
});
|
||||
|
||||
// Done event — closes the stream.
|
||||
let _ = tx_clone.send(AgentEvent::Done {
|
||||
story_id: "1_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: None,
|
||||
});
|
||||
});
|
||||
|
||||
let cli = poem::test::TestClient::new(test_app(ctx));
|
||||
let resp = cli
|
||||
.get("/agents/1_story/coder-1/stream")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
|
||||
// Thinking content must not appear anywhere in the SSE output.
|
||||
assert!(
|
||||
!body.contains("secret thinking text"),
|
||||
"Thinking text must not be forwarded via SSE: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("\"type\":\"thinking\""),
|
||||
"Thinking event type must not appear in SSE output: {body}"
|
||||
);
|
||||
|
||||
// Output event must be present.
|
||||
assert!(
|
||||
body.contains("visible output"),
|
||||
"Output event must be forwarded via SSE: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("\"type\":\"output\""),
|
||||
"Output event type must appear in SSE output: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn output_and_done_events_are_forwarded_via_sse() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
||||
|
||||
let tx = ctx
|
||||
.agents
|
||||
.inject_test_agent("2_story", "coder-1", AgentStatus::Running);
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
|
||||
let _ = tx_clone.send(AgentEvent::Output {
|
||||
story_id: "2_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "step 1 output".to_string(),
|
||||
});
|
||||
|
||||
let _ = tx_clone.send(AgentEvent::Done {
|
||||
story_id: "2_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: Some("sess-abc".to_string()),
|
||||
});
|
||||
});
|
||||
|
||||
let cli = poem::test::TestClient::new(test_app(ctx));
|
||||
let resp = cli
|
||||
.get("/agents/2_story/coder-1/stream")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
|
||||
assert!(body.contains("step 1 output"), "Output must be forwarded: {body}");
|
||||
assert!(body.contains("\"type\":\"done\""), "Done event must be forwarded: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_agent_returns_404() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
||||
|
||||
let cli = poem::test::TestClient::new(test_app(ctx));
|
||||
let resp = cli
|
||||
.get("/agents/nonexistent/coder-1/stream")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resp.0.status(),
|
||||
poem::http::StatusCode::NOT_FOUND,
|
||||
"Unknown agent must return 404"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ enum WsRequest {
|
||||
/// Heartbeat ping from the client. The server responds with `Pong` so the
|
||||
/// client can detect stale (half-closed) connections.
|
||||
Ping,
|
||||
/// A quick side question answered from current conversation context.
|
||||
/// The question and response are NOT added to the conversation history
|
||||
/// and no tool calls are made.
|
||||
SideQuestion {
|
||||
question: String,
|
||||
context_messages: Vec<Message>,
|
||||
config: chat::ProviderConfig,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -116,6 +124,14 @@ enum WsResponse {
|
||||
OnboardingStatus {
|
||||
needs_onboarding: bool,
|
||||
},
|
||||
/// Streaming token from a `/btw` side question response.
|
||||
SideQuestionToken {
|
||||
content: String,
|
||||
},
|
||||
/// Final signal that the `/btw` side question has been fully answered.
|
||||
SideQuestionDone {
|
||||
response: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<WatcherEvent> for Option<WsResponse> {
|
||||
@@ -344,6 +360,33 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
||||
Ok(WsRequest::Ping) => {
|
||||
let _ = tx.send(WsResponse::Pong);
|
||||
}
|
||||
Ok(WsRequest::SideQuestion { question, context_messages, config }) => {
|
||||
let tx_side = tx.clone();
|
||||
let store = ctx.store.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = chat::side_question(
|
||||
context_messages,
|
||||
question,
|
||||
config,
|
||||
store.as_ref(),
|
||||
|token| {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionToken {
|
||||
content: token.to_string(),
|
||||
});
|
||||
},
|
||||
).await;
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionDone { response });
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionDone {
|
||||
response: format!("Error: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +413,39 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
||||
Ok(WsRequest::PermissionResponse { .. }) => {
|
||||
// Permission responses outside an active chat are ignored.
|
||||
}
|
||||
Ok(WsRequest::SideQuestion {
|
||||
question,
|
||||
context_messages,
|
||||
config,
|
||||
}) => {
|
||||
let tx_side = tx.clone();
|
||||
let store = ctx.store.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = chat::side_question(
|
||||
context_messages,
|
||||
question,
|
||||
config,
|
||||
store.as_ref(),
|
||||
|token| {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionToken {
|
||||
content: token.to_string(),
|
||||
});
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let _ = tx_side
|
||||
.send(WsResponse::SideQuestionDone { response });
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionDone {
|
||||
response: format!("Error: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(WsResponse::Error {
|
||||
message: format!("Invalid request: {err}"),
|
||||
|
||||
@@ -95,6 +95,52 @@ pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a key from the YAML front matter of a story file on disk.
|
||||
///
|
||||
/// If front matter is present and contains the key, the line is removed.
|
||||
/// If no front matter or key is not found, the file is left unchanged.
|
||||
pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> {
|
||||
let contents =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
let updated = remove_front_matter_field(&contents, key);
|
||||
if updated != contents {
|
||||
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a key: value line from the YAML front matter of a markdown string.
|
||||
///
|
||||
/// If no front matter (opening `---`) is found or the key is absent, returns content unchanged.
|
||||
fn remove_front_matter_field(contents: &str, key: &str) -> String {
|
||||
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
||||
if lines.is_empty() || lines[0].trim() != "---" {
|
||||
return contents.to_string();
|
||||
}
|
||||
|
||||
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
|
||||
Some(i) => i + 1,
|
||||
None => return contents.to_string(),
|
||||
};
|
||||
|
||||
let key_prefix = format!("{key}:");
|
||||
if let Some(idx) = lines[1..close_idx]
|
||||
.iter()
|
||||
.position(|l| l.trim_start().starts_with(&key_prefix))
|
||||
.map(|i| i + 1)
|
||||
{
|
||||
lines.remove(idx);
|
||||
} else {
|
||||
return contents.to_string();
|
||||
}
|
||||
|
||||
let mut result = lines.join("\n");
|
||||
if contents.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Insert or update a key: value pair in the YAML front matter of a markdown string.
|
||||
///
|
||||
/// If no front matter (opening `---`) is found, returns the content unchanged.
|
||||
@@ -219,6 +265,40 @@ workflow: tdd
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_front_matter_field_removes_key() {
|
||||
let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n";
|
||||
let output = remove_front_matter_field(input, "merge_failure");
|
||||
assert!(!output.contains("merge_failure"));
|
||||
assert!(output.contains("name: My Story"));
|
||||
assert!(output.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_front_matter_field_no_op_when_absent() {
|
||||
let input = "---\nname: My Story\n---\n# Body\n";
|
||||
let output = remove_front_matter_field(input, "merge_failure");
|
||||
assert_eq!(output, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_front_matter_field_no_op_without_front_matter() {
|
||||
let input = "# No front matter\n";
|
||||
let output = remove_front_matter_field(input, "merge_failure");
|
||||
assert_eq!(output, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_front_matter_field_updates_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("story.md");
|
||||
std::fs::write(&path, "---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n").unwrap();
|
||||
clear_front_matter_field(&path, "merge_failure").unwrap();
|
||||
let contents = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!contents.contains("merge_failure"));
|
||||
assert!(contents.contains("name: Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unchecked_todos_mixed() {
|
||||
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
|
||||
|
||||
@@ -409,6 +409,83 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// Answer a one-off side question using the existing conversation as context.
|
||||
///
|
||||
/// Unlike `chat`, this function:
|
||||
/// - Does NOT perform tool calls.
|
||||
/// - Does NOT modify the main conversation history.
|
||||
/// - Does NOT touch the shared cancel signal.
|
||||
/// - Performs a single LLM call and returns the response text.
|
||||
pub async fn side_question<U>(
|
||||
context_messages: Vec<Message>,
|
||||
question: String,
|
||||
config: ProviderConfig,
|
||||
store: &dyn StoreOps,
|
||||
mut on_token: U,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
U: FnMut(&str) + Send,
|
||||
{
|
||||
use crate::llm::providers::anthropic::AnthropicProvider;
|
||||
use crate::llm::providers::ollama::OllamaProvider;
|
||||
|
||||
// Use a local cancel channel that is never cancelled, so the side question
|
||||
// runs to completion independently of any main chat cancel signal.
|
||||
// Keep `_cancel_tx` alive for the duration of the function so the channel
|
||||
// stays open and `changed()` inside the providers does not spuriously fire.
|
||||
let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
let mut cancel_rx = cancel_rx;
|
||||
cancel_rx.borrow_and_update();
|
||||
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
|
||||
let is_claude_code = config.provider == "claude-code";
|
||||
let is_claude = !is_claude_code && config.model.starts_with("claude-");
|
||||
|
||||
// Build a minimal history: existing context + the side question.
|
||||
let mut history = context_messages;
|
||||
history.push(Message {
|
||||
role: Role::User,
|
||||
content: question,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
// No tools for side questions.
|
||||
let tools: &[ToolDefinition] = &[];
|
||||
|
||||
let response = if is_claude {
|
||||
let api_key = get_anthropic_api_key_impl(store)?;
|
||||
let provider = AnthropicProvider::new(api_key);
|
||||
provider
|
||||
.chat_stream(
|
||||
&config.model,
|
||||
&history,
|
||||
tools,
|
||||
&mut cancel_rx,
|
||||
|token| on_token(token),
|
||||
|_tool_name| {},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Anthropic Error: {e}"))?
|
||||
} else if is_claude_code {
|
||||
return Err("Claude Code provider does not support side questions".to_string());
|
||||
} else {
|
||||
let provider = OllamaProvider::new(base_url);
|
||||
provider
|
||||
.chat_stream(&config.model, &history, tools, &mut cancel_rx, |token| {
|
||||
on_token(token)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Ollama Error: {e}"))?
|
||||
};
|
||||
|
||||
Ok(response.content.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn execute_tool(call: &ToolCall, state: &SessionState) -> String {
|
||||
use crate::io::{fs, search, shell};
|
||||
|
||||
|
||||
@@ -77,9 +77,6 @@ pub struct BotContext {
|
||||
/// bot so it can continue a conversation thread without requiring an
|
||||
/// explicit `@mention` on every follow-up.
|
||||
pub bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
|
||||
/// When `true`, the bot rejects messages from users whose devices have not
|
||||
/// been verified via cross-signing in encrypted rooms.
|
||||
pub require_verified_devices: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -192,12 +189,9 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str
|
||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
history_size: config.history_size,
|
||||
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||
require_verified_devices: config.require_verified_devices,
|
||||
};
|
||||
|
||||
if config.require_verified_devices {
|
||||
slog!("[matrix-bot] require_verified_devices is ON — messages from unverified devices in encrypted rooms will be rejected");
|
||||
}
|
||||
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
||||
|
||||
// Register event handlers and inject shared context.
|
||||
client.add_event_handler_context(ctx);
|
||||
@@ -475,28 +469,37 @@ async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// When require_verified_devices is enabled and the room is encrypted,
|
||||
// reject messages from users whose devices have not been verified.
|
||||
if ctx.require_verified_devices && room.encryption_state().is_encrypted() {
|
||||
match check_sender_verified(&client, &ev.sender).await {
|
||||
Ok(true) => { /* sender has at least one verified device — proceed */ }
|
||||
Ok(false) => {
|
||||
slog!(
|
||||
"[matrix-bot] WARNING: Rejecting message from {} — \
|
||||
unverified device(s) in encrypted room {}",
|
||||
ev.sender,
|
||||
incoming_room_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
slog!(
|
||||
"[matrix-bot] Error checking verification for {}: {e} — \
|
||||
rejecting message (fail-closed)",
|
||||
ev.sender
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reject commands from unencrypted rooms — E2EE is mandatory.
|
||||
if !room.encryption_state().is_encrypted() {
|
||||
slog!(
|
||||
"[matrix-bot] Rejecting message from {} — room {} is not encrypted. \
|
||||
Commands are only accepted from encrypted rooms.",
|
||||
ev.sender,
|
||||
incoming_room_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always verify that the sender has at least one cross-signing-verified
|
||||
// device. This check is unconditional and cannot be disabled via config.
|
||||
match check_sender_verified(&client, &ev.sender).await {
|
||||
Ok(true) => { /* sender has at least one verified device — proceed */ }
|
||||
Ok(false) => {
|
||||
slog!(
|
||||
"[matrix-bot] Rejecting message from {} — no cross-signing-verified \
|
||||
device found in encrypted room {}",
|
||||
ev.sender,
|
||||
incoming_room_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
slog!(
|
||||
"[matrix-bot] Error checking verification for {}: {e} — \
|
||||
rejecting message (fail-closed)",
|
||||
ev.sender
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -930,7 +933,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_context_require_verified_devices_field() {
|
||||
fn bot_context_has_no_require_verified_devices_field() {
|
||||
// Verification is always on — BotContext no longer has a toggle field.
|
||||
// This test verifies the struct can be constructed and cloned without it.
|
||||
let ctx = BotContext {
|
||||
bot_user_id: make_user_id("@bot:example.com"),
|
||||
target_room_ids: vec![],
|
||||
@@ -939,15 +944,9 @@ mod tests {
|
||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
history_size: 20,
|
||||
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||
require_verified_devices: true,
|
||||
};
|
||||
assert!(ctx.require_verified_devices);
|
||||
|
||||
let ctx_off = BotContext {
|
||||
require_verified_devices: false,
|
||||
..ctx
|
||||
};
|
||||
assert!(!ctx_off.require_verified_devices);
|
||||
// Clone must work (required by Matrix SDK event handler injection).
|
||||
let _cloned = ctx.clone();
|
||||
}
|
||||
|
||||
// -- drain_complete_paragraphs ------------------------------------------
|
||||
|
||||
@@ -35,12 +35,6 @@ pub struct BotConfig {
|
||||
/// dropped. Defaults to 20.
|
||||
#[serde(default = "default_history_size")]
|
||||
pub history_size: usize,
|
||||
/// When `true`, the bot rejects messages from users whose devices have not
|
||||
/// been verified via cross-signing in encrypted rooms. When `false`
|
||||
/// (default), messages are accepted regardless of device verification
|
||||
/// status, preserving existing plaintext-room behaviour.
|
||||
#[serde(default)]
|
||||
pub require_verified_devices: bool,
|
||||
/// Previously used to select an Anthropic model. Now ignored — the bot
|
||||
/// uses Claude Code which manages its own model selection. Kept for
|
||||
/// backwards compatibility so existing bot.toml files still parse.
|
||||
@@ -241,47 +235,6 @@ enabled = true
|
||||
assert_eq!(config.history_size, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_defaults_require_verified_devices_to_false() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert!(!config.require_verified_devices);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_respects_require_verified_devices_true() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
require_verified_devices = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert!(config.require_verified_devices);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_respects_custom_history_size() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -302,4 +255,32 @@ history_size = 50
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.history_size, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_ignores_legacy_require_verified_devices_key() {
|
||||
// Old bot.toml files that still have `require_verified_devices = true`
|
||||
// must parse successfully — the field is simply ignored now that
|
||||
// verification is always enforced unconditionally.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
require_verified_devices = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Should still load successfully despite the unknown field.
|
||||
let config = BotConfig::load(tmp.path());
|
||||
assert!(
|
||||
config.is_some(),
|
||||
"bot.toml with legacy require_verified_devices key must still load"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user