Compare commits

...

22 Commits

Author SHA1 Message Date
Timmy f4a97c1135 Bump version to 0.10.2 2026-04-15 20:07:55 +01:00
dave 0969fb5d51 fix: remove duplicate / route in gateway that causes panic on startup
gateway_index_handler and embedded_index both registered at /. The
embedded React frontend should serve /. Remove the old gateway
index handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:57:35 +00:00
dave 744cc9dca4 huskies: merge 569_story_gateway_ui_cross_project_pipeline_status_view 2026-04-15 18:38:33 +00:00
dave ce37281333 huskies: merge 571_story_expose_agent_remaining_turns_and_budget_via_mcp_tool 2026-04-15 18:30:32 +00:00
dave 149a383447 huskies: merge 568_story_gateway_ui_connected_agents_dashboard 2026-04-15 18:25:17 +00:00
dave d68614e26a huskies: merge 580_story_diff_bot_command_shows_git_diff_from_main_branch_to_worktree_branch 2026-04-15 18:16:26 +00:00
dave a4480fa067 chore: feed CONTEXT and STACK specs to all agents, update STACK with source map
Agents now read specs/00_CONTEXT.md (what the project does) and
specs/tech/STACK.md (tech stack + source map) in addition to the
README. STACK.md rewritten to reflect current state — removes stale
references to biome, tauri-specta, .story_kit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:15:09 +00:00
dave beb84ade9f huskies: merge 567_story_gateway_ui_project_management_add_and_remove_projects 2026-04-15 18:06:43 +00:00
dave d235fd41ac huskies: merge 581_story_freeze_command_to_hold_a_story_at_its_current_stage_without_advancing 2026-04-15 18:02:14 +00:00
dave 2246278845 huskies: merge 582_story_bot_configuration_page 2026-04-15 17:37:52 +00:00
dave d80fc143c2 huskies: merge 577_bug_show_command_reads_story_files_from_filesystem_instead_of_crdt 2026-04-15 17:28:05 +00:00
dave 1fe4ca2b7a Revert "huskies: merge 566_story_gateway_ui_bot_configuration_page"
This reverts commit c28c86dbc6.
2026-04-15 17:13:01 +00:00
dave c28c86dbc6 huskies: merge 566_story_gateway_ui_bot_configuration_page 2026-04-15 14:12:23 +00:00
dave 70fecafd41 huskies: merge 576_bug_overview_command_reads_story_files_from_filesystem_instead_of_crdt 2026-04-15 14:06:31 +00:00
dave c34b119526 huskies: merge 575_bug_unblock_command_reads_story_files_from_filesystem_instead_of_crdt 2026-04-15 13:53:21 +00:00
dave 0bf715d9bb huskies: merge 574_bug_depends_bot_command_broken_after_removing_filesystem_story_files 2026-04-15 13:38:27 +00:00
dave 7fa31c03a3 huskies: merge 573_story_remove_criterion_mcp_tool_to_delete_an_acceptance_criterion 2026-04-15 13:23:18 +00:00
dave 483489cc44 fix: rewrite coder agent prompts — run tests before commit, remove stale instructions
Key changes:
- Tests before commit, not after: "run run_tests, fix failures, then commit"
- Removed polling references (run_tests blocks now)
- Removed "never run script/test" (primes agents to think about it)
- Removed dead "user review" instruction
- Removed "commit and stop" which signalled skip-testing
- Cleaner workflow: implement → check criteria → test → fix → commit → exit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:19:08 +00:00
dave ec40b4771b huskies: merge 572_story_edit_criterion_mcp_tool_to_update_acceptance_criteria_text 2026-04-15 13:03:55 +00:00
dave 52b21c22b1 huskies: merge 566_story_gateway_ui_bot_configuration_page 2026-04-14 18:57:32 +00:00
dave 8936abd8cd docs: add project architecture section to README for agent context
Agents need to know the gateway is a mode of the binary, not a
separate app, and that UI stories are frontend React work, not
Rust backend restructuring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:23:18 +00:00
dave 8482df2f4e huskies: merge 570_bug_merge_agent_work_should_check_if_story_is_already_done_before_attempting_merge 2026-04-14 16:15:29 +00:00
37 changed files with 3162 additions and 688 deletions
+13 -1
View File
@@ -81,7 +81,19 @@ Consult `specs/tech/STACK.md` for project-specific quality gates.
--- ---
## 7. Deployment Modes ## 7. Project Architecture
Huskies is a single Rust binary with an embedded React frontend. Key things to know:
- **Backend:** `server/src/` — Rust, built with Poem (HTTP framework)
- **Frontend:** `frontend/src/` — React + TypeScript, built with Vite
- **Gateway mode:** `huskies --gateway` is a deployment mode of the same binary, NOT a separate application. The gateway backend code lives in `server/src/gateway.rs`. Gateway frontend components live in `frontend/src/` alongside everything else.
- **Stories that say "UI":** These are primarily frontend (TypeScript/React) work. Check what backend endpoints already exist before adding new ones. Keep Rust changes minimal.
- **Stories that say "gateway":** The gateway is just a mode. Don't restructure `gateway.rs` unless the story specifically asks for backend changes.
---
## 8. Deployment Modes
Huskies has three modes, all from the same binary: Huskies has three modes, all from the same binary:
+36 -66
View File
@@ -5,8 +5,8 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet" model = "sonnet"
max_turns = 50 max_turns = 50
max_budget_usd = 5.00 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 when your process exits and advance the pipeline based on the results. To verify before committing, use the run_tests MCP tool (it starts tests in the background — poll get_test_result to check completion) — never run script/test or cargo test directly via Bash.\n\n## Acceptance Criteria Tracking\nAs you complete each acceptance criterion, call the check_criterion MCP tool (story_id, criterion_index) to mark it done. Index 0 is the first unchecked criterion, 1 is the second, etc. Do this as you go — not all at once at the end.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix. Do NOT explore git history, grep the whole codebase, or re-investigate the root cause when the story already tells you what to do.\n2. If the story does NOT specify the exact location, THEN investigate: use targeted grep to find the relevant code.\n3. Fix with a surgical, minimal change. Do NOT add new abstractions or workarounds.\n4. Commit early. If you've made the fix and tests pass, commit and exit. Do not spend turns verifying that master also has the same failures — that wastes budget.\n5. Write commit messages that explain what broke and why." prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Use the run_tests MCP tool to verify your changes pass — it starts tests in the background, then poll get_test_result to check completion. Never run script/test or cargo test directly via Bash. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. 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, trust the story description — if it specifies exact files and functions, go directly there. Do not explore git history or grep the whole codebase when the story already tells you where to look. Make surgical fixes, commit early." system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
[[agent]] [[agent]]
name = "coder-2" name = "coder-2"
@@ -15,8 +15,8 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet" model = "sonnet"
max_turns = 50 max_turns = 50
max_budget_usd = 5.00 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 when your process exits and advance the pipeline based on the results. To verify before committing, use the run_tests MCP tool (it starts tests in the background — poll get_test_result to check completion) — never run script/test or cargo test directly via Bash.\n\n## Acceptance Criteria Tracking\nAs you complete each acceptance criterion, call the check_criterion MCP tool (story_id, criterion_index) to mark it done. Index 0 is the first unchecked criterion, 1 is the second, etc. Do this as you go — not all at once at the end.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix. Do NOT explore git history, grep the whole codebase, or re-investigate the root cause when the story already tells you what to do.\n2. If the story does NOT specify the exact location, THEN investigate: use targeted grep to find the relevant code.\n3. Fix with a surgical, minimal change. Do NOT add new abstractions or workarounds.\n4. Commit early. If you've made the fix and tests pass, commit and exit. Do not spend turns verifying that master also has the same failures — that wastes budget.\n5. Write commit messages that explain what broke and why." prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Use the run_tests MCP tool to verify your changes pass — it starts tests in the background, then poll get_test_result to check completion. Never run script/test or cargo test directly via Bash. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. 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, trust the story description — if it specifies exact files and functions, go directly there. Do not explore git history or grep the whole codebase when the story already tells you where to look. Make surgical fixes, commit early." system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
[[agent]] [[agent]]
name = "coder-3" name = "coder-3"
@@ -25,8 +25,8 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet" model = "sonnet"
max_turns = 50 max_turns = 50
max_budget_usd = 5.00 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 when your process exits and advance the pipeline based on the results. To verify before committing, use the run_tests MCP tool (it starts tests in the background — poll get_test_result to check completion) — never run script/test or cargo test directly via Bash.\n\n## Acceptance Criteria Tracking\nAs you complete each acceptance criterion, call the check_criterion MCP tool (story_id, criterion_index) to mark it done. Index 0 is the first unchecked criterion, 1 is the second, etc. Do this as you go — not all at once at the end.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix. Do NOT explore git history, grep the whole codebase, or re-investigate the root cause when the story already tells you what to do.\n2. If the story does NOT specify the exact location, THEN investigate: use targeted grep to find the relevant code.\n3. Fix with a surgical, minimal change. Do NOT add new abstractions or workarounds.\n4. Commit early. If you've made the fix and tests pass, commit and exit. Do not spend turns verifying that master also has the same failures — that wastes budget.\n5. Write commit messages that explain what broke and why." prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Use the run_tests MCP tool to verify your changes pass — it starts tests in the background, then poll get_test_result to check completion. Never run script/test or cargo test directly via Bash. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. 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, trust the story description — if it specifies exact files and functions, go directly there. Do not explore git history or grep the whole codebase when the story already tells you where to look. Make surgical fixes, commit early." system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
[[agent]] [[agent]]
name = "qa-2" name = "qa-2"
@@ -37,7 +37,7 @@ max_turns = 40
max_budget_usd = 4.00 max_budget_usd = 4.00
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report. prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
## Your Workflow ## Your Workflow
@@ -48,7 +48,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
### 1. Deterministic Gates (Prerequisites) ### 1. Deterministic Gates (Prerequisites)
Run these first — if any fail, reject immediately without proceeding to AC review: Run these first — if any fail, reject immediately without proceeding to AC review:
- Call the `run_tests` MCP tool to start tests, then poll `get_test_result` until complete — all gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable). Do NOT run script/test via Bash. - Call the `run_tests` MCP tool — it blocks until complete. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
### 2. Code Change Review ### 2. Code Change Review
- Run `git diff master...HEAD --stat` to see what files changed - Run `git diff master...HEAD --stat` to see what files changed
@@ -72,7 +72,7 @@ An AC fails if:
- A test exists but doesn't actually assert the behaviour described - A test exists but doesn't actually assert the behaviour described
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS) ### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
- Build: run `script/build` and note success/failure - Build: run `run_build` MCP tool and note success/failure
- If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server` - If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server`
- Generate a testing plan including: - Generate a testing plan including:
- URL to visit in the browser - URL to visit in the browser
@@ -126,8 +126,8 @@ role = "Senior full-stack engineer for complex tasks. Implements features across
model = "opus" model = "opus"
max_turns = 80 max_turns = 80
max_budget_usd = 20.00 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 when your process exits and advance the pipeline based on the results. To verify before committing, use the run_tests MCP tool (it starts tests in the background — poll get_test_result to check completion) — never run script/test or cargo test directly via Bash.\n\n## Acceptance Criteria Tracking\nAs you complete each acceptance criterion, call the check_criterion MCP tool (story_id, criterion_index) to mark it done. Index 0 is the first unchecked criterion, 1 is the second, etc. Do this as you go — not all at once at the end.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix. Do NOT explore git history, grep the whole codebase, or re-investigate the root cause when the story already tells you what to do.\n2. If the story does NOT specify the exact location, THEN investigate: use targeted grep to find the relevant code.\n3. Fix with a surgical, minimal change. Do NOT add new abstractions or workarounds.\n4. Commit early. If you've made the fix and tests pass, commit and exit. Do not spend turns verifying that master also has the same failures — that wastes budget.\n5. Write commit messages that explain what broke and why." prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
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. Use the run_tests MCP tool to verify your changes pass — it starts tests in the background, then poll get_test_result to check completion. Never run script/test or cargo test directly via Bash. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. 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, trust the story description — if it specifies exact files and functions, go directly there. Do not explore git history or grep the whole codebase when the story already tells you where to look. Make surgical fixes, commit early." system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
[[agent]] [[agent]]
name = "qa" name = "qa"
@@ -138,7 +138,7 @@ max_turns = 40
max_budget_usd = 4.00 max_budget_usd = 4.00
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report. prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
## Your Workflow ## Your Workflow
@@ -149,7 +149,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
### 1. Deterministic Gates (Prerequisites) ### 1. Deterministic Gates (Prerequisites)
Run these first — if any fail, reject immediately without proceeding to AC review: Run these first — if any fail, reject immediately without proceeding to AC review:
- Call the `run_tests` MCP tool to start tests, then poll `get_test_result` until complete — all gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable). Do NOT run script/test via Bash. - Call the `run_tests` MCP tool — it blocks until complete. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
### 2. Code Change Review ### 2. Code Change Review
- Run `git diff master...HEAD --stat` to see what files changed - Run `git diff master...HEAD --stat` to see what files changed
@@ -173,7 +173,7 @@ An AC fails if:
- A test exists but doesn't actually assert the behaviour described - A test exists but doesn't actually assert the behaviour described
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS) ### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
- Build: run `script/build` and note success/failure - Build: run `run_build` MCP tool and note success/failure
- If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server` - If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server`
- Generate a testing plan including: - Generate a testing plan including:
- URL to visit in the browser - URL to visit in the browser
@@ -229,62 +229,32 @@ max_turns = 30
max_budget_usd = 5.00 max_budget_usd = 5.00
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master. prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
## Your Workflow ## Your Workflow
1. Call merge_agent_work(story_id='{{story_id}}') — this blocks until the merge completes and returns the result. Do NOT poll get_merge_status. 1. Call merge_agent_work(story_id='{{story_id}}'). It blocks until the merge completes and returns the full result.
2. Review the result: check success, had_conflicts, conflicts_resolved, gates_passed, and gate_output 2. If success and gates passed: you're done. Exit.
3. If merge succeeded and gates passed: report success to the human 3. If gates failed: read the gate_output carefully, fix the issues in the merge workspace at `.huskies/merge_workspace/`, run run_tests MCP tool to verify, recommit, and call merge_agent_work again.
4. If conflicts were auto-resolved (conflicts_resolved=true) and gates passed: report success, noting which conflicts were resolved 4. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and exit.
5. If conflicts could not be auto-resolved: **resolve them yourself** in the merge worktree (see below) 5. After 3 failed fix attempts, call report_merge_failure and exit.
6. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and report to the human
7. If gates failed after merge: attempt to fix the issues yourself in the merge worktree, then re-trigger merge_agent_work. After 3 fix attempts, call report_merge_failure and stop.
## Resolving Complex Conflicts Yourself
When the auto-resolver fails, you have access to the merge worktree at `.story_kit/merge_workspace/`. Go in there and resolve the conflicts manually:
1. Run `git diff --name-only --diff-filter=U` in the merge worktree to list conflicted files
2. **Build context before touching code.** Run `git log --oneline master...HEAD` on the feature branch to see its commits. Then run `git log --oneline --since="$(git log -1 --format=%ci <feature-branch-base-commit>)" master` to see what landed on master since the branch was created. Read the story files in `.story_kit/work/` for any recently merged stories that touch the same files — this tells you WHY master changed and what must be preserved.
3. Read each conflicted file and understand both sides of the conflict
4. **Understand intent, not just syntax.** The feature branch may be behind master — master's version of shared infrastructure is almost always correct. The feature branch's contribution is the NEW functionality it adds. Your job is to integrate the new into master's structure, not pick one side.
5. Resolve by integrating the feature's new functionality into master's code structure
5. Stage resolved files with `git add`
6. Call the `run_tests` MCP tool to start tests, then poll `get_test_result` until complete
7. If it compiles, commit and re-trigger merge_agent_work
### Common conflict patterns:
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in pipeline directories are gitignored and don't need to be committed.
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
**IMPORTANT: After resolving ANY conflict or fixing ANY gate failure in the merge workspace, use the `run_lint` MCP tool to check formatting, then `run_tests` to verify everything passes before recommitting.** The auto-resolver frequently produces code that compiles but fails formatting or linting checks.
## Fixing Gate Failures ## Fixing Gate Failures
If quality gates fail, attempt to fix issues yourself in the merge workspace. Use the run_tests MCP tool to verify before recommitting. The auto-resolver often produces broken code. Common problems:
- Duplicate imports or definitions (kept both sides)
- Formatting issues (import ordering, line breaks)
- Unclosed delimiters from bad conflict resolution
- Type mismatches from incompatible merge of both sides
**Fix yourself (up to 3 attempts total):** To fix:
- Syntax errors 1. Read the broken files in `.huskies/merge_workspace/`
- Duplicate definitions from merge artifacts 2. Fix the issues — prefer master's structure, integrate only the feature's new code
- Unused import warnings 3. Run run_lint MCP tool to check formatting
- Formatting issues that block linting 4. Run run_tests MCP tool to verify everything passes
5. Commit the fix and call merge_agent_work again
**Report to human without attempting a fix:** ## Rules
- Logic errors or incorrect business logic - NEVER manually move story files between pipeline stages
- Missing function implementations - NEVER call accept_story — merge_agent_work handles that
- Architectural changes required - ALWAYS call report_merge_failure if you can't fix the merge"""
system_prompt = "You are the mergemaster agent. Call merge_agent_work to merge. If gates fail, fix the issues in the merge workspace, verify with run_lint and run_tests MCP tools, recommit, and retrigger. After 3 failed attempts, call report_merge_failure and exit. Never move story files or call accept_story."
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
## CRITICAL Rules
- NEVER manually move story files between pipeline stages (e.g. from 4_merge/ to 5_done/)
- NEVER call accept_story — only merge_agent_work can move stories to done after a successful merge
- When merge fails after exhausting your fix attempts, ALWAYS call report_merge_failure
- Report conflict resolution outcomes clearly
- Report gate failures with full output so the human can act if needed
- The server automatically runs acceptance gates when your process exits"""
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge workspace. Common patterns: discard story file rename conflicts (gitignored), remove duplicate definitions/imports. After resolving, verify with run_tests MCP tool before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."
+112 -112
View File
@@ -1,130 +1,130 @@
# Tech Stack & Constraints # Tech Stack
## Overview ## Backend
This project is a standalone Rust **web server binary** that serves a Vite/React frontend and exposes a **WebSocket API**. The built frontend assets are packaged with the binary (in a `frontend` directory) and served as static files. It functions as an **Agentic Code Assistant** capable of safely executing tools on the host system. - **Language:** Rust
- **Framework:** Poem (HTTP + WebSocket + OpenAPI)
- **Database:** SQLite via sqlx + rusqlite
- **State:** BFT CRDT replicated document backed by SQLite
- **Agents:** Claude Code CLI spawned in PTY pseudo-terminals
- **Package manager:** cargo
## Core Stack ## Frontend
* **Backend:** Rust (Web Server) - **Language:** TypeScript + React
* **MSRV:** Stable (latest) - **Build:** Vite
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints. - **Package manager:** npm
* **Frontend:** TypeScript + React - **Testing:** Vitest (unit), Playwright (e2e)
* **Build Tool:** Vite
* **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.
## Agent Architecture ## Deployment
The application follows a **Tool-Use (Function Calling)** architecture: - Single Rust binary with embedded React frontend (rust-embed)
1. **Frontend:** Collects user input and sends it to the LLM. - Three modes: standard server, headless build agent (`--rendezvous`), multi-project gateway (`--gateway`)
2. **LLM:** Decides to generate text OR request a **Tool Call** (e.g., `execute_shell`, `read_file`). - Docker container with OrbStack recommended on macOS
3. **Web Server Backend (The "Hand"):**
* Intercepts Tool Calls.
* Validates the request against the **Safety Policy**.
* Executes the native code (File I/O, Shell Process, Search).
* Returns the output (stdout/stderr/file content) to the LLM.
* **Streaming:** The backend sends real-time updates over WebSocket to keep the UI responsive during long-running Agent tasks.
## LLM Provider Abstraction ## Project Layout
To support both Remote and Local models, the system implements a `ModelProvider` abstraction layer. ```
server/src/ — Rust backend
frontend/src/ — React frontend
crates/bft-json-crdt/ — CRDT library
.huskies/ — Pipeline config, agent config, specs
script/ — test, build, lint scripts
docker/ — Dockerfile and docker-compose
website/ — Static marketing/docs site
```
* **Strategy:** ## Source Map
* Abstract the differences between API formats (OpenAI-compatible vs Anthropic vs Gemini).
* Normalize "Tool Use" definitions, as each provider handles function calling schemas differently.
* **Supported Providers:**
* **Ollama:** Local inference (e.g., Llama 3, DeepSeek Coder) for privacy and offline usage.
* **Anthropic:** Claude 3.5 models (Sonnet, Haiku) via API for coding tasks (Story 12).
* **Provider Selection:**
* Automatic detection based on model name prefix:
* `claude-` → Anthropic API
* Otherwise → Ollama
* Single unified model dropdown with section headers ("Anthropic", "Ollama")
* **API Key Management:**
* Anthropic API key stored server-side and persisted securely
* On first use of Claude model, user prompted to enter API key
* Key persists across sessions (no re-entry needed)
## Tooling Capabilities ### Core
### 1. Filesystem (Native) | File | Description |
* **Scope:** Strictly limited to the user-selected `project_root`. |------|-------------|
* **Operations:** Read, Write, List, Delete. | `server/src/main.rs` | Entry point, CLI argument parsing, and server startup |
* **Constraint:** Modifications to `.git/` are strictly forbidden via file APIs (use Git tools instead). | `server/src/config.rs` | Parses `project.toml` for agents, components, and server settings |
| `server/src/state.rs` | Global mutable session state (project root, cancellation) |
| `server/src/store.rs` | JSON-backed persistent key-value store for settings |
| `server/src/gateway.rs` | Multi-project gateway mode (MCP proxy, project switching, agent registration) |
### 2. Shell Execution ### Agents
* **Library:** `tokio::process` for async execution.
* **Constraint:** We do **not** run an interactive shell (repl). We run discrete, stateless commands.
* **Allowlist:** The agent may only execute specific binaries:
* `git`
* `cargo`, `rustc`, `rustfmt`, `clippy`
* `npm`, `node`, `yarn`, `pnpm`, `bun`
* `ls`, `find`, `grep` (if not using internal search)
* `mkdir`, `rm`, `touch`, `mv`, `cp`
### 3. Search & Navigation | File | Description |
* **Library:** `ignore` (by BurntSushi) + `grep` logic. |------|-------------|
* **Behavior:** | `server/src/agents/mod.rs` | Types, configuration, and orchestration for coding agents |
* Must respect `.gitignore` files automatically. | `server/src/agents/gates.rs` | Runs test suites and validation scripts in agent worktrees |
* Must be performant (parallel traversal). | `server/src/agents/lifecycle.rs` | File creation, archival, and stage transitions for pipeline items |
| `server/src/agents/merge.rs` | Rebases agent work onto master and runs post-merge validation |
| `server/src/agents/pty.rs` | Spawns agent processes in pseudo-terminals and streams output |
| `server/src/agents/token_usage.rs` | Persists per-agent token consumption records to disk |
| `server/src/agent_log.rs` | Reads and writes JSONL agent event logs to disk |
| `server/src/agent_mode.rs` | Headless build-agent mode for distributed story processing |
## Coding Standards ### Agent Pool
### Rust | File | Description |
* **Style:** `rustfmt` standard. |------|-------------|
* **Linter:** `clippy` - Must pass with 0 warnings before merging. | `server/src/agents/pool/mod.rs` | Manages the set of active agents across all pipeline stages |
* **Error Handling:** Custom `AppError` type deriving `thiserror`. All Commands return `Result<T, AppError>`. | `server/src/agents/pool/start.rs` | Spawns a new agent process in a worktree for a story |
* **Concurrency:** Heavy tools (Search, Shell) must run on `tokio` threads to avoid blocking the UI. | `server/src/agents/pool/stop.rs` | Terminates a running agent while preserving its worktree |
* **Quality Gates:** | `server/src/agents/pool/pipeline/advance.rs` | Moves stories forward through pipeline stages |
* `cargo clippy --all-targets --all-features` must show 0 errors, 0 warnings | `server/src/agents/pool/pipeline/completion.rs` | Processes exit results and triggers pipeline advancement |
* `cargo check` must succeed | `server/src/agents/pool/pipeline/merge.rs` | Orchestrates the merge-to-master flow for completed stories |
* `cargo nextest run` must pass all tests | `server/src/agents/pool/auto_assign/auto_assign.rs` | Scans pipeline stages and dispatches agents to unassigned stories |
* **Test Coverage:**
* Generate JSON report: `cargo llvm-cov nextest --no-clean --json --output-path .story_kit/coverage/server.json`
* Generate lcov report: `cargo llvm-cov report --lcov --output-path .story_kit/coverage/server.lcov`
* Reports are written to `.story_kit/coverage/` (excluded from git)
### TypeScript / React ### CRDT & Database
* **Style:** Biome formatter (replaces Prettier/ESLint).
* **Linter:** Biome - Must pass with 0 errors, 0 warnings before merging.
* **Types:** Shared types with Rust (via `tauri-specta` or manual interface matching) are preferred to ensure type safety across the bridge.
* **Testing:** Vitest for unit/component tests; Playwright for end-to-end tests.
* **Quality Gates:**
* `npx @biomejs/biome check src/` must show 0 errors, 0 warnings
* `npm run build` must succeed
* `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
## Libraries (Approved) | File | Description |
* **Rust:** |------|-------------|
* `serde`, `serde_json`: Serialization. | `server/src/crdt_state.rs` | Pipeline state as a conflict-free replicated document backed by SQLite |
* `ignore`: Fast recursive directory iteration respecting gitignore. | `server/src/crdt_sync.rs` | WebSocket-based replication of pipeline state between nodes |
* `walkdir`: Simple directory traversal. | `server/src/pipeline_state.rs` | Typed pipeline state machine |
* `tokio`: Async runtime. | `server/src/db/mod.rs` | Content store, shadow writes, and CRDT op persistence |
* `reqwest`: For LLM API calls (Anthropic, Ollama).
* `eventsource-stream`: For Server-Sent Events (Anthropic streaming).
* `uuid`: For unique message IDs.
* `chrono`: For timestamps.
* `poem`: HTTP server framework.
* `poem-openapi`: OpenAPI (Swagger) for non-streaming HTTP APIs.
* **JavaScript:**
* `react-markdown`: For rendering chat responses.
* `vitest`: Unit/component testing.
* `playwright`: End-to-end testing.
## Running the App (Worktrees & Ports) ### HTTP — MCP Tools (the tools agents call)
Multiple instances can run simultaneously in different worktrees. To avoid port conflicts: | File | Description |
|------|-------------|
| `server/src/http/mcp/mod.rs` | MCP endpoint dispatching tool calls |
| `server/src/http/mcp/agent_tools.rs` | Start, stop, wait, list, and inspect agents |
| `server/src/http/mcp/git_tools.rs` | Status, diff, add, commit, and log on agent worktrees |
| `server/src/http/mcp/merge_tools.rs` | Merge agent work to master and report failures |
| `server/src/http/mcp/shell_tools.rs` | Run commands, execute tests, and stream output |
| `server/src/http/mcp/story_tools.rs` | Create, update, move, and manage stories/bugs/refactors |
| `server/src/http/mcp/diagnostics.rs` | Server logs, CRDT dump, version, and story movement helpers |
- **Backend:** Set `HUSKIES_PORT` to a unique port (default is 3001). Example: `HUSKIES_PORT=3002 cargo run` ### Chat — Bot Commands
- **Frontend:** Run `npm run dev` from `frontend/`. It auto-selects the next unused port. It reads `HUSKIES_PORT` to know which backend to talk to, so export it before running: `export HUSKIES_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. | File | Description |
|------|-------------|
| `server/src/chat/commands/mod.rs` | Bot-level command registry shared by all transports |
| `server/src/chat/commands/status.rs` | `status` command and pipeline status helpers |
| `server/src/chat/commands/backlog.rs` | `backlog` command — shows only backlog-stage items |
| `server/src/chat/commands/run_tests.rs` | `run_tests` command — run the project's test suite |
## Safety & Sandbox ### Chat — Transports
1. **Project Scope:** The application must strictly enforce that it does not read/write outside the `project_root` selected by the user.
2. **Human in the Loop:** | File | Description |
* Shell commands that modify state (non-readonly) should ideally require a UI confirmation (configurable). |------|-------------|
* File writes must be confirmed or revertible. | `server/src/chat/transport/matrix/` | Matrix bot integration |
| `server/src/chat/transport/slack/` | Slack bot integration |
| `server/src/chat/transport/whatsapp/` | WhatsApp Business API integration |
| `server/src/chat/transport/discord/` | Discord bot integration |
### Frontend
| Directory | Description |
|-----------|-------------|
| `frontend/src/components/` | React UI components |
| `frontend/src/api/` | API client code (gateway, agents, etc.) |
### Utilities
| File | Description |
|------|-------------|
| `server/src/rebuild.rs` | Server rebuild and restart logic |
| `server/src/worktree.rs` | Creates, lists, and removes git worktrees for agent isolation |
| `server/src/io/watcher.rs` | Filesystem watcher for `.huskies/work/` and `project.toml` |
## Quality Gates
All enforced by `script/test`:
1. Frontend build (`npm run build`)
2. Rust formatting (`cargo fmt --all --check`)
3. Rust linting (`cargo clippy -- -D warnings`)
4. Rust tests (`cargo test`)
5. Frontend tests (`npm test`)
Generated
+5 -5
View File
@@ -2288,7 +2288,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "huskies" name = "huskies"
version = "0.10.1" version = "0.10.2"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
@@ -5996,9 +5996,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.51.1" version = "1.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -6333,9 +6333,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "typewit" name = "typewit"
version = "1.15.1" version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc19094686c694eb41b3b99dcc2f2975d4b078512fa22ae6c63f7ca318bdcff7" checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"
dependencies = [ dependencies = [
"typewit_proc_macros", "typewit_proc_macros",
] ]
+1 -278
View File
@@ -220,283 +220,6 @@ Both return a JSON document with:
## Source Map ## Source Map
### Core See `.huskies/specs/tech/STACK.md` for the full source map.
| File | Description |
|------|-------------|
| `server/src/main.rs` | Entry point, CLI argument parsing, and server startup |
| `server/src/config.rs` | Parses `project.toml` for agents, components, and server settings |
| `server/src/state.rs` | Global mutable session state (project root, cancellation) |
| `server/src/store.rs` | JSON-backed persistent key-value store for settings |
### Agents
| File | Description |
|------|-------------|
| `server/src/agents/mod.rs` | Types, configuration, and orchestration for coding agents |
| `server/src/agents/gates.rs` | Runs test suites and validation scripts in agent worktrees |
| `server/src/agents/lifecycle.rs` | File creation, archival, and stage transitions for pipeline items |
| `server/src/agents/merge.rs` | Rebases agent work onto master and runs post-merge validation |
| `server/src/agents/pty.rs` | Spawns agent processes in pseudo-terminals and streams output |
| `server/src/agents/token_usage.rs` | Persists per-agent token consumption records to disk |
| `server/src/agent_log.rs` | Reads and writes JSONL agent event logs to disk |
| `server/src/agent_mode.rs` | Headless build-agent mode for distributed story processing |
### Agent Pool
| File | Description |
|------|-------------|
| `server/src/agents/pool/mod.rs` | Manages the set of active agents across all pipeline stages |
| `server/src/agents/pool/types.rs` | `AgentPool`, `StoryAgent`, and related data structures |
| `server/src/agents/pool/start.rs` | Spawns a new agent process in a worktree for a story |
| `server/src/agents/pool/stop.rs` | Terminates a running agent while preserving its worktree |
| `server/src/agents/pool/wait.rs` | Blocks until an agent reaches a terminal state |
| `server/src/agents/pool/query.rs` | Lists available/active agents and info lookups |
| `server/src/agents/pool/process.rs` | Kills orphaned PTY child processes on shutdown |
| `server/src/agents/pool/worktree.rs` | Creates and configures git worktrees for agents |
| `server/src/agents/pool/test_helpers.rs` | In-memory pool construction and test assertions |
### Agent Pool — Auto-assign
| File | Description |
|------|-------------|
| `server/src/agents/pool/auto_assign/mod.rs` | Wires sub-files and re-exports public items |
| `server/src/agents/pool/auto_assign/auto_assign.rs` | Scans pipeline stages and dispatches agents to unassigned stories |
| `server/src/agents/pool/auto_assign/reconcile.rs` | Startup reconciliation: detects committed work and advances pipeline |
| `server/src/agents/pool/auto_assign/scan.rs` | Scans pipeline stages for work items and queries pool state |
| `server/src/agents/pool/auto_assign/story_checks.rs` | Front-matter checks: review holds, blocked state, merge failures |
| `server/src/agents/pool/auto_assign/watchdog.rs` | Detects orphaned agents and triggers auto-assign |
### Agent Pool — Pipeline
| File | Description |
|------|-------------|
| `server/src/agents/pool/pipeline/mod.rs` | Stage advancement, completion handling, and merge orchestration |
| `server/src/agents/pool/pipeline/advance.rs` | Moves stories forward through pipeline stages |
| `server/src/agents/pool/pipeline/completion.rs` | Processes exit results and triggers pipeline advancement |
| `server/src/agents/pool/pipeline/merge.rs` | Orchestrates the merge-to-master flow for completed stories |
### Agent Runtimes
| File | Description |
|------|-------------|
| `server/src/agents/runtime/mod.rs` | Pluggable backends (Claude Code, Gemini, OpenAI) for running agents |
| `server/src/agents/runtime/claude_code.rs` | Launches Claude Code CLI sessions as agent backends |
| `server/src/agents/runtime/gemini.rs` | Drives Google Gemini API sessions as agent backends |
| `server/src/agents/runtime/openai.rs` | Drives OpenAI API sessions as agent backends |
### CRDT
| File | Description |
|------|-------------|
| `server/src/crdt_state.rs` | Pipeline state as a conflict-free replicated document backed by SQLite |
| `server/src/crdt_sync.rs` | WebSocket-based replication of pipeline state between nodes |
| `server/src/crdt_wire.rs` | Serialization format for `SignedOp` sync messages |
| `server/src/pipeline_state.rs` | Typed pipeline state machine |
### Database
| File | Description |
|------|-------------|
| `server/src/db/mod.rs` | Content store, shadow writes, and CRDT op persistence |
### HTTP Server
| File | Description |
|------|-------------|
| `server/src/http/mod.rs` | Module declarations for all REST, MCP, WebSocket, and SSE endpoints |
| `server/src/http/context.rs` | Shared `AppContext` threaded through all HTTP handlers |
| `server/src/http/agents.rs` | REST API for listing, starting, stopping, and inspecting agents |
| `server/src/http/agents_sse.rs` | Server-Sent Events endpoint for real-time agent output |
| `server/src/http/anthropic.rs` | Proxy for model listing and key-validation to Anthropic |
| `server/src/http/assets.rs` | Serves the embedded React frontend via `rust-embed` |
| `server/src/http/bot_command.rs` | Bot command HTTP endpoint |
| `server/src/http/chat.rs` | REST API for the LLM-powered chat interface |
| `server/src/http/health.rs` | Returns a static "ok" response |
| `server/src/http/io.rs` | REST API for file and directory operations |
| `server/src/http/model.rs` | REST API for model selection and LLM provider management |
| `server/src/http/oauth.rs` | Anthropic OAuth callback and token exchange flow |
| `server/src/http/project.rs` | REST API for project initialization and context management |
| `server/src/http/settings.rs` | REST API for user preferences and editor configuration |
| `server/src/http/wizard.rs` | REST API for the project setup wizard |
| `server/src/http/ws.rs` | Real-time pipeline updates, chat, and permission prompts |
| `server/src/http/test_helpers.rs` | Shared test utilities for HTTP handler tests |
### HTTP — MCP Tools
| File | Description |
|------|-------------|
| `server/src/http/mcp/mod.rs` | Model Context Protocol endpoint dispatching tool calls |
| `server/src/http/mcp/agent_tools.rs` | Start, stop, wait, list, and inspect agents via MCP |
| `server/src/http/mcp/diagnostics.rs` | Server logs, CRDT dump, and story movement helpers |
| `server/src/http/mcp/git_tools.rs` | Status, diff, add, commit, and log on agent worktrees |
| `server/src/http/mcp/merge_tools.rs` | Merge agent work to master and report failures |
| `server/src/http/mcp/qa_tools.rs` | Request, approve, and reject QA reviews |
| `server/src/http/mcp/shell_tools.rs` | Run commands, execute tests, and stream output |
| `server/src/http/mcp/status_tools.rs` | Pipeline status, story triage, and AC inspection |
| `server/src/http/mcp/story_tools.rs` | Create, update, move, and manage stories/bugs/refactors |
| `server/src/http/mcp/wizard_tools.rs` | Interactive setup wizard tool implementations |
### HTTP — Workflow
| File | Description |
|------|-------------|
| `server/src/http/workflow/mod.rs` | Shared story/bug file operations for HTTP and MCP handlers |
| `server/src/http/workflow/bug_ops.rs` | Creates bug, refactor, and spike files in the pipeline |
| `server/src/http/workflow/story_ops.rs` | Creates, updates, and manages acceptance criteria in stories |
| `server/src/http/workflow/test_results.rs` | Writes structured test results into story markdown |
### I/O
| File | Description |
|------|-------------|
| `server/src/io/mod.rs` | Filesystem, shell, search, onboarding, and story metadata operations |
| `server/src/io/fs/mod.rs` | Module declarations and re-exports for file operations |
| `server/src/io/fs/files.rs` | Read, write, list, and create files and directories |
| `server/src/io/fs/paths.rs` | Resolves CLI and session-relative paths to absolute paths |
| `server/src/io/fs/preferences.rs` | Reads and writes model selection and user settings |
| `server/src/io/fs/project.rs` | Tracks known projects and resolves the active project root |
| `server/src/io/fs/scaffold.rs` | Creates the `.huskies/` directory structure and default files |
| `server/src/io/onboarding.rs` | Checks whether scaffold templates have been customized |
| `server/src/io/search.rs` | Full-text search across project files |
| `server/src/io/shell.rs` | Runs commands in the project directory and captures output |
| `server/src/io/story_metadata.rs` | Parses and modifies YAML front matter in story markdown |
| `server/src/io/watcher.rs` | Filesystem watcher for `.huskies/work/` and `project.toml` |
| `server/src/io/wizard.rs` | Multi-step project onboarding flow with per-step status |
| `server/src/io/test_helpers.rs` | Shared test utilities for I/O module tests |
### Chat
| File | Description |
|------|-------------|
| `server/src/chat/mod.rs` | Transport abstraction for chat platforms |
| `server/src/chat/lookup.rs` | Shared story-lookup helper for chat commands |
| `server/src/chat/timer.rs` | Deferred agent start via one-shot timers |
| `server/src/chat/util.rs` | Shared text utilities used by all transports |
| `server/src/chat/test_helpers.rs` | Shared test utilities for chat handler tests |
### Chat — Commands
| File | Description |
|------|-------------|
| `server/src/chat/commands/mod.rs` | Bot-level command registry shared by all transports |
| `server/src/chat/commands/ambient.rs` | `ambient` command handler |
| `server/src/chat/commands/assign.rs` | `assign` command handler |
| `server/src/chat/commands/backlog.rs` | `backlog` command — shows only backlog-stage items |
| `server/src/chat/commands/cost.rs` | `cost` command handler |
| `server/src/chat/commands/coverage.rs` | `coverage` command — show or refresh test coverage |
| `server/src/chat/commands/depends.rs` | `depends` command handler |
| `server/src/chat/commands/git.rs` | `git` command handler |
| `server/src/chat/commands/help.rs` | `help` command handler |
| `server/src/chat/commands/loc.rs` | `loc` command — top source files by line count |
| `server/src/chat/commands/move_story.rs` | `move` command handler |
| `server/src/chat/commands/overview.rs` | `overview` command handler |
| `server/src/chat/commands/run_tests.rs` | `test` command — run the project's test suite |
| `server/src/chat/commands/setup.rs` | `setup` command handler |
| `server/src/chat/commands/show.rs` | `show` command handler |
| `server/src/chat/commands/status.rs` | `status` command and pipeline status helpers |
| `server/src/chat/commands/timer.rs` | `timer` command handler |
| `server/src/chat/commands/triage.rs` | Story triage dump subcommand of `status` |
| `server/src/chat/commands/unblock.rs` | `unblock` command handler |
| `server/src/chat/commands/unreleased.rs` | `unreleased` command handler |
### Chat — Matrix Transport
| File | Description |
|------|-------------|
| `server/src/chat/transport/matrix/mod.rs` | Matrix bot integration |
| `server/src/chat/transport/matrix/config.rs` | Deserialization of `bot.toml` Matrix settings |
| `server/src/chat/transport/matrix/commands.rs` | Re-exports from `crate::chat::commands` |
| `server/src/chat/transport/matrix/transport_impl.rs` | Matrix `ChatTransport` implementation |
| `server/src/chat/transport/matrix/assign.rs` | Assign/re-assign a coder model to a story |
| `server/src/chat/transport/matrix/delete.rs` | Delete a story/bug/spike from the pipeline |
| `server/src/chat/transport/matrix/htop.rs` | Live-updating system and agent process dashboard |
| `server/src/chat/transport/matrix/notifications.rs` | Stage transition notifications for Matrix rooms |
| `server/src/chat/transport/matrix/rebuild.rs` | Trigger a server rebuild and restart |
| `server/src/chat/transport/matrix/reset.rs` | Clear the current Claude Code session for a room |
| `server/src/chat/transport/matrix/rmtree.rs` | Delete the worktree for a story |
| `server/src/chat/transport/matrix/start.rs` | Start a coder agent on a story |
### Chat — Matrix Bot
| File | Description |
|------|-------------|
| `server/src/chat/transport/matrix/bot/mod.rs` | Sub-modules for the Matrix chat bot |
| `server/src/chat/transport/matrix/bot/context.rs` | Shared state (rooms, history, permissions) |
| `server/src/chat/transport/matrix/bot/format.rs` | Markdown-to-HTML conversion and startup announcements |
| `server/src/chat/transport/matrix/bot/history.rs` | Per-room message history for LLM context |
| `server/src/chat/transport/matrix/bot/mentions.rs` | Checks whether a message mentions the bot |
| `server/src/chat/transport/matrix/bot/messages.rs` | Processes incoming messages and dispatches commands |
| `server/src/chat/transport/matrix/bot/run.rs` | Connects to homeserver and processes sync events |
| `server/src/chat/transport/matrix/bot/verification.rs` | Interactive emoji verification flow for E2EE |
### Chat — Slack Transport
| File | Description |
|------|-------------|
| `server/src/chat/transport/slack/mod.rs` | Slack Bot API integration |
| `server/src/chat/transport/slack/commands.rs` | Incoming message dispatch and slash command handling |
| `server/src/chat/transport/slack/format.rs` | Markdown to Slack mrkdwn conversion |
| `server/src/chat/transport/slack/history.rs` | Conversation history persistence |
| `server/src/chat/transport/slack/meta.rs` | `ChatTransport` implementation for Slack |
| `server/src/chat/transport/slack/verify.rs` | Request signature verification |
### Chat — Discord Transport
| File | Description |
|------|-------------|
| `server/src/chat/transport/discord/mod.rs` | Discord Bot integration |
| `server/src/chat/transport/discord/commands.rs` | Incoming message dispatch and command handling |
| `server/src/chat/transport/discord/format.rs` | Markdown to Discord format conversion |
| `server/src/chat/transport/discord/gateway.rs` | Minimal Discord Gateway WebSocket client |
| `server/src/chat/transport/discord/history.rs` | Conversation history persistence |
| `server/src/chat/transport/discord/meta.rs` | `ChatTransport` implementation for Discord |
### Chat — WhatsApp Transport
| File | Description |
|------|-------------|
| `server/src/chat/transport/whatsapp/mod.rs` | WhatsApp Business API integration |
| `server/src/chat/transport/whatsapp/commands.rs` | Processes incoming messages as bot commands |
| `server/src/chat/transport/whatsapp/format.rs` | Markdown-to-WhatsApp conversion and message chunking |
| `server/src/chat/transport/whatsapp/history.rs` | Per-number history and messaging window tracking |
| `server/src/chat/transport/whatsapp/meta.rs` | Meta Cloud API transport via Graph API |
| `server/src/chat/transport/whatsapp/twilio.rs` | Twilio transport for sending/receiving messages |
### Chat — Transport Abstraction
| File | Description |
|------|-------------|
| `server/src/chat/transport/mod.rs` | Pluggable backends (Matrix, Slack, WhatsApp, Discord) |
### LLM
| File | Description |
|------|-------------|
| `server/src/llm/mod.rs` | Chat orchestration, prompts, OAuth, and provider integrations |
| `server/src/llm/chat.rs` | Multi-turn conversations with tool-calling LLM providers |
| `server/src/llm/oauth.rs` | Token refresh and credential management for Claude API |
| `server/src/llm/prompts.rs` | Static prompt templates for chat and onboarding |
| `server/src/llm/types.rs` | `Message`, `Role`, `ToolCall`, `ModelProvider` types |
### LLM — Providers
| File | Description |
|------|-------------|
| `server/src/llm/providers/mod.rs` | Module declarations for Anthropic, Claude Code, and Ollama |
| `server/src/llm/providers/anthropic.rs` | Streaming completion client for Claude Messages API |
| `server/src/llm/providers/claude_code.rs` | Runs Claude Code CLI in a PTY and parses output |
| `server/src/llm/providers/ollama.rs` | Streaming completion client for Ollama models |
### Utilities
| File | Description |
|------|-------------|
| `server/src/log_buffer.rs` | Bounded in-memory ring buffer for server log output |
| `server/src/rebuild.rs` | Server rebuild and restart logic |
| `server/src/workflow.rs` | Test result tracking and acceptance evaluation |
| `server/src/worktree.rs` | Creates, lists, and removes git worktrees for agent isolation |
## License
GPL-3.0. See [LICENSE](LICENSE). GPL-3.0. See [LICENSE](LICENSE).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "huskies", "name": "huskies",
"version": "0.10.1", "version": "0.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "huskies", "name": "huskies",
"version": "0.10.1", "version": "0.10.2",
"dependencies": { "dependencies": {
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0", "react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "huskies", "name": "huskies",
"private": true, "private": true,
"version": "0.10.1", "version": "0.10.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+43
View File
@@ -0,0 +1,43 @@
export interface BotConfig {
transport: string | null;
enabled: boolean | null;
homeserver: string | null;
username: string | null;
password: string | null;
room_ids: string[] | null;
slack_bot_token: string | null;
slack_signing_secret: string | null;
slack_channel_ids: string[] | null;
}
const DEFAULT_API_BASE = "/api";
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(`${baseUrl}${path}`, {
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const botConfigApi = {
getConfig(baseUrl?: string): Promise<BotConfig> {
return requestJson<BotConfig>("/bot/config", {}, baseUrl);
},
saveConfig(config: BotConfig, baseUrl?: string): Promise<BotConfig> {
return requestJson<BotConfig>(
"/bot/config",
{ method: "PUT", body: JSON.stringify(config) },
baseUrl,
);
},
};
+60
View File
@@ -8,6 +8,8 @@ export interface JoinedAgent {
label: string; label: string;
address: string; address: string;
registered_at: number; registered_at: number;
/// Unix timestamp of the last heartbeat from this agent.
last_seen: number;
/// Project this agent is assigned to, if any. /// Project this agent is assigned to, if any.
assigned_project?: string; assigned_project?: string;
} }
@@ -22,6 +24,28 @@ export interface GatewayInfo {
projects: GatewayProject[]; projects: GatewayProject[];
} }
export interface PipelineItem {
story_id: string;
name: string;
stage: string;
agent?: { agent_name: string; model: string; status: string } | null;
blocked?: boolean;
retry_count?: number;
merge_failure?: string;
}
export interface ProjectPipelineStatus {
active: PipelineItem[];
backlog: { story_id: string; name: string }[];
backlog_count: number;
error?: string;
}
export interface AllProjectsPipeline {
active: string;
projects: Record<string, ProjectPipelineStatus>;
}
export interface GenerateTokenResponse { export interface GenerateTokenResponse {
token: string; token: string;
} }
@@ -86,4 +110,40 @@ export const gatewayApi = {
getGatewayInfo(): Promise<GatewayInfo> { getGatewayInfo(): Promise<GatewayInfo> {
return gatewayRequest<GatewayInfo>("/api/gateway"); return gatewayRequest<GatewayInfo>("/api/gateway");
}, },
/// Add a new project to the gateway config.
addProject(name: string, url: string): Promise<GatewayProject> {
return gatewayRequest<GatewayProject>("/api/gateway/projects", {
method: "POST",
body: JSON.stringify({ name, url }),
});
},
/// Remove a project from the gateway config.
removeProject(name: string): Promise<void> {
return gatewayRequest<void>(
`/api/gateway/projects/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
},
/// Send a heartbeat for an agent to update its last-seen timestamp.
heartbeat(id: string): Promise<void> {
return gatewayRequest<void>(`/gateway/agents/${id}/heartbeat`, {
method: "POST",
});
},
/// Fetch pipeline status from all registered projects.
getAllProjectsPipeline(): Promise<AllProjectsPipeline> {
return gatewayRequest<AllProjectsPipeline>("/api/gateway/pipeline");
},
/// Switch the active project.
switchProject(project: string): Promise<{ ok: boolean; error?: string }> {
return gatewayRequest<{ ok: boolean; error?: string }>(
"/api/gateway/switch",
{ method: "POST", body: JSON.stringify({ project }) },
);
},
}; };
+344
View File
@@ -0,0 +1,344 @@
import * as React from "react";
import type { BotConfig } from "../api/bot_config";
import { botConfigApi } from "../api/bot_config";
const { useState, useEffect } = React;
interface BotConfigPageProps {
onBack: () => void;
}
const fieldStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: "4px",
};
const labelStyle: React.CSSProperties = {
fontSize: "0.8em",
color: "#aaa",
fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
borderRadius: "6px",
border: "1px solid #333",
background: "#1e1e1e",
color: "#ececec",
fontSize: "0.9em",
fontFamily: "monospace",
outline: "none",
};
const sectionStyle: React.CSSProperties = {
background: "#1e1e1e",
border: "1px solid #333",
borderRadius: "8px",
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "14px",
};
const sectionTitleStyle: React.CSSProperties = {
fontSize: "0.85em",
fontWeight: 600,
color: "#aaa",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "2px",
};
function Field({
label,
value,
onChange,
placeholder,
type = "text",
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<div style={fieldStyle}>
<label style={labelStyle}>{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={inputStyle}
autoComplete="off"
/>
</div>
);
}
function ListField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string[];
onChange: (v: string[]) => void;
placeholder?: string;
}) {
return (
<div style={fieldStyle}>
<label style={labelStyle}>{label} (one per line)</label>
<textarea
value={value.join("\n")}
onChange={(e) =>
onChange(e.target.value.split("\n").filter((s) => s.trim()))
}
placeholder={placeholder}
rows={3}
style={{ ...inputStyle, resize: "vertical" }}
/>
</div>
);
}
/// Bot configuration page — form for Matrix and Slack credentials.
export function BotConfigPage({ onBack }: BotConfigPageProps) {
const [transport, setTransport] = useState<"matrix" | "slack">("matrix");
const [enabled, setEnabled] = useState(false);
const [homeserver, setHomeserver] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [roomIds, setRoomIds] = useState<string[]>([]);
const [slackBotToken, setSlackBotToken] = useState("");
const [slackSigningSecret, setSlackSigningSecret] = useState("");
const [slackChannelIds, setSlackChannelIds] = useState<string[]>([]);
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">(
"idle",
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
useEffect(() => {
botConfigApi
.getConfig()
.then((cfg) => {
if (cfg.transport === "slack") setTransport("slack");
setEnabled(cfg.enabled ?? false);
setHomeserver(cfg.homeserver ?? "");
setUsername(cfg.username ?? "");
setPassword(cfg.password ?? "");
setRoomIds(cfg.room_ids ?? []);
setSlackBotToken(cfg.slack_bot_token ?? "");
setSlackSigningSecret(cfg.slack_signing_secret ?? "");
setSlackChannelIds(cfg.slack_channel_ids ?? []);
})
.catch(() => {});
}, []);
function buildConfig(): BotConfig {
return {
transport,
enabled,
homeserver: homeserver || null,
username: username || null,
password: password || null,
room_ids: roomIds.length > 0 ? roomIds : null,
slack_bot_token: slackBotToken || null,
slack_signing_secret: slackSigningSecret || null,
slack_channel_ids: slackChannelIds.length > 0 ? slackChannelIds : null,
};
}
async function handleSave() {
setStatus("saving");
setErrorMsg(null);
try {
await botConfigApi.saveConfig(buildConfig());
setStatus("saved");
setTimeout(() => setStatus("idle"), 2000);
} catch (e) {
setStatus("error");
setErrorMsg(e instanceof Error ? e.message : "Save failed");
}
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
backgroundColor: "#171717",
color: "#ececec",
overflow: "auto",
}}
>
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #333",
display: "flex",
alignItems: "center",
gap: "16px",
background: "#171717",
flexShrink: 0,
}}
>
<button
type="button"
onClick={onBack}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "#888",
fontSize: "0.9em",
padding: "4px 8px",
borderRadius: "4px",
}}
>
Back
</button>
<span style={{ fontWeight: 700, fontSize: "1em" }}>
Bot Configuration
</span>
</div>
<div
style={{
flex: 1,
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "20px",
maxWidth: "600px",
}}
>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>General</div>
<div style={fieldStyle}>
<label style={labelStyle}>Transport</label>
<select
value={transport}
onChange={(e) =>
setTransport(e.target.value as "matrix" | "slack")
}
style={{ ...inputStyle, cursor: "pointer" }}
>
<option value="matrix">Matrix</option>
<option value="slack">Slack</option>
</select>
</div>
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
fontSize: "0.9em",
color: "#ccc",
}}
>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Enabled
</label>
</div>
{transport === "matrix" && (
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Matrix Credentials</div>
<Field
label="Homeserver"
value={homeserver}
onChange={setHomeserver}
placeholder="https://matrix.example.com"
/>
<Field
label="Username"
value={username}
onChange={setUsername}
placeholder="@botname:example.com"
/>
<Field
label="Password"
value={password}
onChange={setPassword}
placeholder="bot password"
type="password"
/>
<ListField
label="Room IDs"
value={roomIds}
onChange={setRoomIds}
placeholder="!roomid:example.com"
/>
</div>
)}
{transport === "slack" && (
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Slack Credentials</div>
<Field
label="Bot Token"
value={slackBotToken}
onChange={setSlackBotToken}
placeholder="xoxb-..."
/>
<Field
label="Signing Secret"
value={slackSigningSecret}
onChange={setSlackSigningSecret}
placeholder="signing secret"
type="password"
/>
<ListField
label="Channel IDs"
value={slackChannelIds}
onChange={setSlackChannelIds}
placeholder="C01ABCDEF"
/>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<button
type="button"
onClick={handleSave}
disabled={status === "saving"}
style={{
padding: "8px 24px",
borderRadius: "6px",
border: "none",
background: status === "saved" ? "#1a5c2a" : "#2563eb",
color: "#fff",
cursor: status === "saving" ? "not-allowed" : "pointer",
fontSize: "0.9em",
fontWeight: 600,
opacity: status === "saving" ? 0.7 : 1,
}}
>
{status === "saving"
? "Saving..."
: status === "saved"
? "Saved!"
: "Save"}
</button>
{status === "error" && errorMsg && (
<span style={{ color: "#f08080", fontSize: "0.85em" }}>
{errorMsg}
</span>
)}
</div>
</div>
</div>
);
}
+8 -1
View File
@@ -8,6 +8,7 @@ import { useChatSend } from "../hooks/useChatSend";
import { useChatWebSocket } from "../hooks/useChatWebSocket"; import { useChatWebSocket } from "../hooks/useChatWebSocket";
import { estimateTokens, getContextWindowSize } from "../utils/chatUtils"; import { estimateTokens, getContextWindowSize } from "../utils/chatUtils";
import { ApiKeyDialog } from "./ApiKeyDialog"; import { ApiKeyDialog } from "./ApiKeyDialog";
import { BotConfigPage } from "./BotConfigPage";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import type { ChatInputHandle } from "./ChatInput"; import type { ChatInputHandle } from "./ChatInput";
import { ChatInput } from "./ChatInput"; import { ChatInput } from "./ChatInput";
@@ -61,6 +62,7 @@ export function Chat({
null, null,
); );
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [view, setView] = useState<"chat" | "bot-config">("chat");
const [queuedMessages, setQueuedMessages] = useState< const [queuedMessages, setQueuedMessages] = useState<
{ id: string; text: string }[] { id: string; text: string }[]
>([]); >([]);
@@ -373,12 +375,17 @@ export function Chat({
onToggleTools={setEnableTools} onToggleTools={setEnableTools}
wsConnected={wsConnected} wsConnected={wsConnected}
oauthStatus={oauthStatus} oauthStatus={oauthStatus}
onShowBotConfig={() => setView("bot-config")}
/> />
{view === "bot-config" && (
<BotConfigPage onBack={() => setView("chat")} />
)}
<div <div
data-testid="chat-content-area" data-testid="chat-content-area"
style={{ style={{
display: "flex", display: view === "bot-config" ? "none" : "flex",
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
flexDirection: isNarrowScreen ? "column" : "row", flexDirection: isNarrowScreen ? "column" : "row",
+39
View File
@@ -34,6 +34,7 @@ interface ChatHeaderProps {
onToggleTools: (enabled: boolean) => void; onToggleTools: (enabled: boolean) => void;
wsConnected: boolean; wsConnected: boolean;
oauthStatus?: OAuthStatus | null; oauthStatus?: OAuthStatus | null;
onShowBotConfig?: () => void;
} }
const getContextEmoji = (percentage: number): string => { const getContextEmoji = (percentage: number): string => {
@@ -58,6 +59,7 @@ export function ChatHeader({
onToggleTools, onToggleTools,
wsConnected, wsConnected,
oauthStatus = null, oauthStatus = null,
onShowBotConfig,
}: ChatHeaderProps) { }: ChatHeaderProps) {
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0; const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
@@ -513,6 +515,43 @@ export function ChatHeader({
🔄 New Session 🔄 New Session
</button> </button>
{onShowBotConfig && (
<button
type="button"
onClick={onShowBotConfig}
title="Configure bot credentials"
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor: "#2f2f2f",
color: "#888",
cursor: "pointer",
outline: "none",
transition: "all 0.2s",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
>
Bot
</button>
)}
{hasModelOptions ? ( {hasModelOptions ? (
<select <select
value={model} value={model}
+427 -7
View File
@@ -1,14 +1,173 @@
/// Gateway management panel shown when huskies runs in `--gateway` mode. /// Gateway management panel shown when huskies runs in `--gateway` mode.
/// ///
/// Provides: /// Provides:
/// - A cross-project pipeline status view showing active stories per project.
/// - Clicking a project card switches to it.
/// - An "Add Agent" button that generates a one-time join token. /// - An "Add Agent" button that generates a one-time join token.
/// - Instructions for running a build agent with the token. /// - Instructions for running a build agent with the token.
/// - A list of connected agents with per-agent project assignment and "Remove" buttons. /// - A list of connected agents with per-agent status, project assignment, and "Remove" buttons.
/// - Auto-refresh every 5 seconds so new agents and disconnections appear without a page reload.
import * as React from "react"; import * as React from "react";
import { gatewayApi, type JoinedAgent, type GatewayProject } from "../api/gateway"; import {
gatewayApi,
type JoinedAgent,
type GatewayProject,
type AllProjectsPipeline,
type PipelineItem,
} from "../api/gateway";
const { useCallback, useEffect, useState } = React; const { useCallback, useEffect, useRef, useState } = React;
/// Seconds of silence before an agent is considered disconnected.
const DISCONNECT_THRESHOLD_SECS = 60;
/// Poll the agent list this often (milliseconds).
const POLL_INTERVAL_MS = 5_000;
type AgentStatus = "idle" | "working" | "disconnected";
/// Derive an agent's display status from its last-seen timestamp and project assignment.
function agentStatus(agent: JoinedAgent): AgentStatus {
const nowSecs = Date.now() / 1000;
if (nowSecs - agent.last_seen > DISCONNECT_THRESHOLD_SECS) {
return "disconnected";
}
return agent.assigned_project ? "working" : "idle";
}
const STATUS_COLORS: Record<AgentStatus, string> = {
idle: "#6e7681",
working: "#3fb950",
disconnected: "#f85149",
};
const STATUS_LABELS: Record<AgentStatus, string> = {
idle: "Idle",
working: "Working",
disconnected: "Disconnected",
};
const STAGE_COLORS: Record<string, string> = {
current: "#3fb950",
qa: "#d2a679",
merge: "#79c0ff",
done: "#6e7681",
};
const STAGE_LABELS: Record<string, string> = {
current: "In Progress",
qa: "QA",
merge: "Merging",
done: "Done",
};
/// A single story row inside a project pipeline card.
function StoryRow({ item }: { item: PipelineItem }) {
const color = STAGE_COLORS[item.stage] ?? "#8b949e";
const label = STAGE_LABELS[item.stage] ?? item.stage;
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "4px 0",
fontSize: "0.82em",
}}
>
<span
style={{
padding: "1px 6px",
borderRadius: "10px",
background: `${color}22`,
color,
border: `1px solid ${color}44`,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{label}
</span>
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{item.name}
</span>
</div>
);
}
/// Pipeline status card for a single project.
function ProjectPipelineCard({
name,
pipeline,
isActive,
onSwitch,
}: {
name: string;
pipeline: AllProjectsPipeline["projects"][string];
isActive: boolean;
onSwitch: (name: string) => void;
}) {
const activeItems = pipeline.active ?? [];
const backlogCount = pipeline.backlog_count ?? 0;
const hasError = Boolean(pipeline.error);
return (
<div
data-testid={`pipeline-card-${name}`}
onClick={() => onSwitch(name)}
style={{
padding: "12px 16px",
background: "#161b22",
border: `1px solid ${isActive ? "#238636" : "#30363d"}`,
borderRadius: "8px",
marginBottom: "8px",
cursor: "pointer",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: activeItems.length > 0 ? "8px" : 0,
}}
>
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{name}</span>
{isActive && (
<span
style={{
fontSize: "0.7em",
padding: "1px 6px",
borderRadius: "10px",
background: "#23863622",
color: "#3fb950",
border: "1px solid #23863644",
}}
>
active
</span>
)}
<span style={{ marginLeft: "auto", fontSize: "0.75em", color: "#6e7681" }}>
{backlogCount > 0 ? `${backlogCount} in backlog` : ""}
</span>
</div>
{hasError ? (
<div style={{ fontSize: "0.8em", color: "#f85149" }}>{pipeline.error}</div>
) : activeItems.length === 0 ? (
<div style={{ fontSize: "0.8em", color: "#6e7681" }}>No active stories</div>
) : (
<div>
{activeItems.map((item) => (
<StoryRow key={item.story_id} item={item} />
))}
</div>
)}
</div>
);
}
function TokenDisplay({ token }: { token: string }) { function TokenDisplay({ token }: { token: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -100,7 +259,9 @@ function AgentRow({
onAssign: (id: string, project: string | null) => void; onAssign: (id: string, project: string | null) => void;
}) { }) {
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString(); const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
const isAssigned = Boolean(agent.assigned_project); const status = agentStatus(agent);
const statusColor = STATUS_COLORS[status];
const statusLabel = STATUS_LABELS[status];
return ( return (
<div <div
@@ -121,18 +282,38 @@ function AgentRow({
width: "8px", width: "8px",
height: "8px", height: "8px",
borderRadius: "50%", borderRadius: "50%",
background: isAssigned ? "#3fb950" : "#6e7681", background: statusColor,
flexShrink: 0, flexShrink: 0,
}} }}
title={isAssigned ? "Assigned" : "Idle (unassigned)"} title={statusLabel}
/> />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</div> <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</span>
<span
data-testid={`agent-status-${agent.id}`}
style={{
fontSize: "0.75em",
padding: "1px 6px",
borderRadius: "10px",
background: `${statusColor}22`,
color: statusColor,
border: `1px solid ${statusColor}44`,
}}
>
{statusLabel}
</span>
</div>
<div style={{ fontSize: "0.8em", color: "#8b949e" }}> <div style={{ fontSize: "0.8em", color: "#8b949e" }}>
{agent.address} {agent.address}
</div> </div>
<div style={{ fontSize: "0.75em", color: "#6e7681" }}> <div style={{ fontSize: "0.75em", color: "#6e7681" }}>
Registered {registeredAt} Registered {registeredAt}
{agent.assigned_project && (
<span style={{ marginLeft: "8px", color: "#8b949e" }}>
· Project: {agent.assigned_project}
</span>
)}
</div> </div>
</div> </div>
<select <select
@@ -185,8 +366,21 @@ export function GatewayPanel() {
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pipeline, setPipeline] = useState<AllProjectsPipeline | null>(null);
// Add-project form state
const [newProjectName, setNewProjectName] = useState("");
const [newProjectUrl, setNewProjectUrl] = useState("");
const [addingProject, setAddingProject] = useState(false);
// Keep stable refs so polling intervals don't recreate on state changes.
const setAgentsRef = useRef(setAgents);
setAgentsRef.current = setAgents;
const setPipelineRef = useRef(setPipeline);
setPipelineRef.current = setPipeline;
useEffect(() => { useEffect(() => {
// Initial load.
gatewayApi gatewayApi
.listAgents() .listAgents()
.then(setAgents) .then(setAgents)
@@ -195,6 +389,25 @@ export function GatewayPanel() {
.getGatewayInfo() .getGatewayInfo()
.then((info) => setProjects(info.projects)) .then((info) => setProjects(info.projects))
.catch(() => setProjects([])); .catch(() => setProjects([]));
gatewayApi
.getAllProjectsPipeline()
.then(setPipeline)
.catch(() => setPipeline(null));
// Poll so the dashboard auto-updates as agents connect/disconnect and
// stories move through pipelines.
const timer = setInterval(() => {
gatewayApi
.listAgents()
.then((updated) => setAgentsRef.current(updated))
.catch(() => {});
gatewayApi
.getAllProjectsPipeline()
.then((updated) => setPipelineRef.current(updated))
.catch(() => {});
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, []); }, []);
const handleAddAgent = useCallback(async () => { const handleAddAgent = useCallback(async () => {
@@ -234,6 +447,53 @@ export function GatewayPanel() {
[], [],
); );
const handleAddProject = useCallback(async () => {
const name = newProjectName.trim();
const url = newProjectUrl.trim();
if (!name || !url) return;
setAddingProject(true);
setError(null);
try {
const created = await gatewayApi.addProject(name, url);
setProjects((prev) => [...prev, created]);
setNewProjectName("");
setNewProjectUrl("");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setAddingProject(false);
}
}, [newProjectName, newProjectUrl]);
const handleSwitchProject = useCallback(async (name: string) => {
setError(null);
try {
const result = await gatewayApi.switchProject(name);
if (!result.ok) {
setError(result.error ?? "Failed to switch project");
return;
}
// Refresh pipeline to reflect new active project.
const updated = await gatewayApi.getAllProjectsPipeline();
setPipeline(updated);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
const handleRemoveProject = useCallback(async (name: string) => {
if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) {
return;
}
setError(null);
try {
await gatewayApi.removeProject(name);
setProjects((prev) => prev.filter((p) => p.name !== name));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
return ( return (
<div <div
style={{ style={{
@@ -252,6 +512,34 @@ export function GatewayPanel() {
Manage build agents connected to this gateway. Manage build agents connected to this gateway.
</p> </p>
{/* Cross-project pipeline status */}
<section style={{ marginBottom: "32px" }}>
<h2
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
}}
>
Pipeline Status
</h2>
{pipeline ? (
Object.entries(pipeline.projects).map(([name, status]) => (
<ProjectPipelineCard
key={name}
name={name}
pipeline={status}
isActive={name === pipeline.active}
onSwitch={handleSwitchProject}
/>
))
) : (
<p style={{ color: "#6e7681" }}>Loading pipeline status</p>
)}
</section>
{/* Add Agent */} {/* Add Agent */}
<section style={{ marginBottom: "32px" }}> <section style={{ marginBottom: "32px" }}>
<h2 <h2
@@ -330,6 +618,138 @@ export function GatewayPanel() {
)} )}
</section> </section>
{/* Project management */}
<section style={{ marginTop: "32px" }}>
<h2
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
}}
>
Projects{" "}
{projects.length > 0 && (
<span style={{ fontSize: "0.8em", color: "#8b949e", fontWeight: 400 }}>
({projects.length})
</span>
)}
</h2>
{/* Existing projects list */}
{projects.map((p) => (
<div
key={p.name}
data-testid={`project-row-${p.name}`}
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 14px",
background: "#161b22",
border: "1px solid #30363d",
borderRadius: "8px",
marginBottom: "8px",
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{p.name}</div>
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>{p.url}</div>
</div>
<button
type="button"
data-testid={`remove-project-${p.name}`}
onClick={() => handleRemoveProject(p.name)}
style={{
fontSize: "0.8em",
padding: "4px 10px",
borderRadius: "4px",
border: "1px solid #f85149",
background: "none",
color: "#f85149",
cursor: "pointer",
}}
>
Remove
</button>
</div>
))}
{/* Add project form */}
<div
style={{
marginTop: "12px",
display: "flex",
gap: "8px",
alignItems: "flex-end",
flexWrap: "wrap",
}}
>
<div style={{ flex: "1 1 140px" }}>
<div style={{ fontSize: "0.75em", color: "#8b949e", marginBottom: "4px" }}>
Name
</div>
<input
data-testid="new-project-name"
type="text"
placeholder="my-project"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
style={{
width: "100%",
padding: "6px 10px",
borderRadius: "4px",
border: "1px solid #30363d",
background: "#0d1117",
color: "#e6edf3",
fontSize: "0.85em",
}}
/>
</div>
<div style={{ flex: "2 1 200px" }}>
<div style={{ fontSize: "0.75em", color: "#8b949e", marginBottom: "4px" }}>
Container URL
</div>
<input
data-testid="new-project-url"
type="text"
placeholder="http://localhost:3001"
value={newProjectUrl}
onChange={(e) => setNewProjectUrl(e.target.value)}
style={{
width: "100%",
padding: "6px 10px",
borderRadius: "4px",
border: "1px solid #30363d",
background: "#0d1117",
color: "#e6edf3",
fontSize: "0.85em",
}}
/>
</div>
<button
type="button"
data-testid="add-project-button"
onClick={handleAddProject}
disabled={addingProject || !newProjectName.trim() || !newProjectUrl.trim()}
style={{
padding: "6px 14px",
borderRadius: "4px",
border: "1px solid #238636",
background: addingProject ? "#1a2f1a" : "#238636",
color: "#fff",
cursor: addingProject ? "not-allowed" : "pointer",
fontWeight: 600,
fontSize: "0.85em",
whiteSpace: "nowrap",
}}
>
{addingProject ? "Adding…" : "Add Project"}
</button>
</div>
</section>
{error && ( {error && (
<div <div
style={{ style={{
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "huskies" name = "huskies"
version = "0.10.1" version = "0.10.2"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
@@ -15,7 +15,7 @@ use super::scan::{
}; };
use super::story_checks::{ use super::story_checks::{
check_archived_dependencies, has_merge_failure, has_review_hold, has_unmet_dependencies, check_archived_dependencies, has_merge_failure, has_review_hold, has_unmet_dependencies,
is_story_blocked, read_story_front_matter_agent, is_story_blocked, is_story_frozen, read_story_front_matter_agent,
}; };
impl AgentPool { impl AgentPool {
@@ -103,6 +103,12 @@ impl AgentPool {
continue; continue;
} }
// Skip frozen stories — pipeline advancement is suspended.
if is_story_frozen(project_root, stage_dir, story_id) {
slog!("[auto-assign] Story '{story_id}' is frozen; skipping until unfrozen.");
continue;
}
// Skip blocked stories (retry limit exceeded). // Skip blocked stories (retry limit exceeded).
if is_story_blocked(project_root, stage_dir, story_id) { if is_story_blocked(project_root, stage_dir, story_id) {
continue; continue;
@@ -93,6 +93,19 @@ pub(super) fn check_archived_dependencies(
crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id) crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id)
} }
/// Return `true` if the story file has `frozen: true` in its front matter.
pub(super) fn is_story_frozen(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
use crate::io::story_metadata::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
.ok()
.and_then(|m| m.frozen)
.unwrap_or(false)
}
/// Return `true` if the story file has a `merge_failure` field in its front matter. /// Return `true` if the story file has a `merge_failure` field in its front matter.
pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool { pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
use crate::io::story_metadata::parse_front_matter; use crate::io::story_metadata::parse_front_matter;
@@ -40,6 +40,13 @@ impl AgentPool {
.map(agent_config_stage) .map(agent_config_stage)
.unwrap_or_else(|| pipeline_stage(agent_name)); .unwrap_or_else(|| pipeline_stage(agent_name));
// If the story is frozen, do not advance the pipeline. The agent's work
// is done but the story stays at its current stage.
if crate::io::story_metadata::is_story_frozen_in_store(story_id) {
slog!("[pipeline] Story '{story_id}' is frozen; pipeline advancement suppressed.");
return;
}
match stage { match stage {
PipelineStage::Other => { PipelineStage::Other => {
// Supervisors and unknown agents do not advance the pipeline. // Supervisors and unknown agents do not advance the pipeline.
+47 -22
View File
@@ -7,7 +7,9 @@
//! Passing no dependency numbers clears the field entirely. //! Passing no dependency numbers clears the field entirely.
use super::CommandContext; use super::CommandContext;
use crate::io::story_metadata::{parse_front_matter, write_depends_on}; use crate::io::story_metadata::{
parse_front_matter, write_depends_on, write_depends_on_in_content,
};
/// Handle the `depends` command. /// Handle the `depends` command.
/// ///
@@ -51,7 +53,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
} }
// Find the story by numeric prefix: CRDT → content store → filesystem. // Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, _stage_dir, path, content) = let (story_id, stage_dir, path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) { match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
Some(found) => found, Some(found) => found,
None => { None => {
@@ -62,11 +64,35 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
}; };
let story_name = content let story_name = content
.or_else(|| std::fs::read_to_string(&path).ok()) .as_deref()
.and_then(|c| parse_front_matter(&c).ok()) .and_then(|c| parse_front_matter(c).ok())
.and_then(|m| m.name) .and_then(|m| m.name)
.unwrap_or_else(|| story_id.clone()); .unwrap_or_else(|| story_id.clone());
// Prefer the CRDT content store; fall back to filesystem only when the
// story has not been loaded into the DB (e.g. very early startup or tests
// that haven't called write_item_with_content).
if let Some(existing) = crate::db::read_content(&story_id) {
let updated = write_depends_on_in_content(&existing, &deps);
crate::db::write_content(&story_id, &updated);
let stage = crate::pipeline_state::read_typed(&story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| stage_dir.clone());
crate::db::write_item_with_content(&story_id, &stage, &updated);
if deps.is_empty() {
Some(format!(
"Cleared all dependencies for **{story_name}** ({story_id})."
))
} else {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
Some(format!(
"Set depends_on: [{}] for **{story_name}** ({story_id}).",
nums.join(", ")
))
}
} else {
match write_depends_on(&path, &deps) { match write_depends_on(&path, &deps) {
Ok(()) if deps.is_empty() => Some(format!( Ok(()) if deps.is_empty() => Some(format!(
"Cleared all dependencies for **{story_name}** ({story_id})." "Cleared all dependencies for **{story_name}** ({story_id})."
@@ -81,6 +107,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
Err(e) => Some(format!("Failed to update dependencies for {story_id}: {e}")), Err(e) => Some(format!("Failed to update dependencies for {story_id}: {e}")),
} }
} }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
@@ -170,10 +197,10 @@ mod tests {
write_story_file( write_story_file(
tmp.path(), tmp.path(),
"1_backlog", "1_backlog",
"42_story_foo.md", "9912_story_foo.md",
"---\nname: Foo\n---\n", "---\nname: Foo\n---\n",
); );
let output = depends_cmd_with_root(tmp.path(), "42 abc").unwrap(); let output = depends_cmd_with_root(tmp.path(), "9912 abc").unwrap();
assert!( assert!(
output.contains("Invalid dependency number"), output.contains("Invalid dependency number"),
"non-numeric dep should error: {output}" "non-numeric dep should error: {output}"
@@ -181,25 +208,24 @@ mod tests {
} }
#[test] #[test]
fn depends_sets_deps_and_writes_to_file() { fn depends_sets_deps_and_writes_to_content_store() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
write_story_file( write_story_file(
tmp.path(), tmp.path(),
"1_backlog", "1_backlog",
"42_story_foo.md", "9910_story_foo.md",
"---\nname: Foo\n---\n", "---\nname: Foo\n---\n",
); );
let output = depends_cmd_with_root(tmp.path(), "42 477 478").unwrap(); let output = depends_cmd_with_root(tmp.path(), "9910 477 478").unwrap();
assert!( assert!(
output.contains("477") && output.contains("478"), output.contains("477") && output.contains("478"),
"response should mention dep numbers: {output}" "response should mention dep numbers: {output}"
); );
let contents = let contents = crate::db::read_content("9910_story_foo")
std::fs::read_to_string(tmp.path().join(".huskies/work/1_backlog/42_story_foo.md")) .expect("content store should have updated story");
.unwrap();
assert!( assert!(
contents.contains("depends_on: [477, 478]"), contents.contains("depends_on: [477, 478]"),
"file should have depends_on set: {contents}" "content store should have depends_on set: {contents}"
); );
} }
@@ -209,20 +235,19 @@ mod tests {
write_story_file( write_story_file(
tmp.path(), tmp.path(),
"2_current", "2_current",
"10_story_bar.md", "9911_story_bar.md",
"---\nname: Bar\ndepends_on: [477]\n---\n", "---\nname: Bar\ndepends_on: [477]\n---\n",
); );
let output = depends_cmd_with_root(tmp.path(), "10").unwrap(); let output = depends_cmd_with_root(tmp.path(), "9911").unwrap();
assert!( assert!(
output.contains("Cleared"), output.contains("Cleared"),
"should confirm clearing deps: {output}" "should confirm clearing deps: {output}"
); );
let contents = let contents = crate::db::read_content("9911_story_bar")
std::fs::read_to_string(tmp.path().join(".huskies/work/2_current/10_story_bar.md")) .expect("content store should have updated story");
.unwrap();
assert!( assert!(
!contents.contains("depends_on"), !contents.contains("depends_on"),
"file should have depends_on cleared: {contents}" "content store should have depends_on cleared: {contents}"
); );
} }
@@ -232,12 +257,12 @@ mod tests {
write_story_file( write_story_file(
tmp.path(), tmp.path(),
"3_qa", "3_qa",
"55_story_inqa.md", "9913_story_inqa.md",
"---\nname: In QA\n---\n", "---\nname: In QA\n---\n",
); );
let output = depends_cmd_with_root(tmp.path(), "55 100").unwrap(); let output = depends_cmd_with_root(tmp.path(), "9913 100").unwrap();
assert!( assert!(
output.contains("In QA") || output.contains("55_story_inqa"), output.contains("In QA") || output.contains("9913_story_inqa"),
"should find story in qa stage: {output}" "should find story in qa stage: {output}"
); );
assert!(output.contains("100"), "should mention dep 100: {output}"); assert!(output.contains("100"), "should mention dep 100: {output}");
+259
View File
@@ -0,0 +1,259 @@
//! Handler for the `diff` command.
//!
//! Shows the git diff from the configured main branch to the story's worktree
//! HEAD, formatted for readability in chat.
use super::CommandContext;
use std::path::Path;
use std::process::Command;
/// Display the git diff from the configured main branch to a story's worktree HEAD.
///
/// Usage: `diff <number>`
pub(super) fn handle_diff(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} diff <number>`\n\nShows the git diff from the main branch to the story's worktree HEAD.",
ctx.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} diff <number>`",
ctx.bot_name
));
}
let story_id = match find_story_id(num_str) {
Some(id) => id,
None => {
return Some(format!(
"No story with number **{num_str}** found in the pipeline."
));
}
};
let wt_path = crate::worktree::worktree_path(ctx.project_root, &story_id);
if !wt_path.is_dir() {
return Some(format!(
"Story **{num_str}** has no worktree. The diff is only available once a coder has started working on it."
));
}
let base_branch = resolve_base_branch(ctx.project_root);
let range = format!("{base_branch}...HEAD");
let stat = run_git(&wt_path, &["diff", "--stat", &range]);
let diff = run_git(&wt_path, &["diff", &range]);
let mut out = format!("## Diff — story {num_str} vs `{base_branch}`\n\n");
if stat.is_empty() && diff.is_empty() {
out.push_str("*(no changes relative to main branch)*\n");
return Some(out);
}
if !stat.is_empty() {
out.push_str("**Changed files:**\n```\n");
out.push_str(&stat);
out.push_str("\n```\n\n");
}
if !diff.is_empty() {
const MAX_DIFF_BYTES: usize = 8_000;
if diff.len() > MAX_DIFF_BYTES {
let truncated = truncate_at_char_boundary(&diff, MAX_DIFF_BYTES);
out.push_str("**Diff** *(truncated — showing first 8 KB)*:\n```diff\n");
out.push_str(truncated);
out.push_str("\n... (truncated)\n```\n");
} else {
out.push_str("**Diff:**\n```diff\n");
out.push_str(&diff);
out.push_str("\n```\n");
}
}
Some(out)
}
/// Find the story_id in the pipeline whose numeric prefix matches `num_str`.
fn find_story_id(num_str: &str) -> Option<String> {
let items = crate::pipeline_state::read_all_typed();
items.into_iter().find_map(|item| {
let file_num = item
.story_id
.0
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
Some(item.story_id.0.clone())
} else {
None
}
})
}
/// Return the configured base branch, or auto-detect it from the project root HEAD.
fn resolve_base_branch(project_root: &Path) -> String {
crate::config::ProjectConfig::load(project_root)
.ok()
.and_then(|c| c.base_branch)
.unwrap_or_else(|| {
Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "master".to_string())
})
}
/// Run a git command in `dir`, returning trimmed stdout (empty string on failure).
fn run_git(dir: &Path, args: &[&str]) -> String {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
/// Truncate `s` to at most `max_bytes` bytes without splitting a UTF-8 character.
fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut boundary = max_bytes;
while !s.is_char_boundary(boundary) {
boundary -= 1;
}
&s[..boundary]
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn diff_cmd(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy diff {args}"))
}
#[test]
fn diff_command_is_registered() {
let found = super::super::commands().iter().any(|c| c.name == "diff");
assert!(found, "diff command must be in the registry");
}
#[test]
fn diff_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("diff"),
"help should list diff command: {output}"
);
}
#[test]
fn diff_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = diff_cmd(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage: {output}"
);
}
#[test]
fn diff_command_non_numeric_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = diff_cmd(tmp.path(), "abc").unwrap();
assert!(
output.contains("Invalid"),
"non-numeric arg should return error: {output}"
);
}
#[test]
fn diff_command_story_not_found_returns_friendly_message() {
crate::db::ensure_content_store();
let tmp = tempfile::TempDir::new().unwrap();
let output = diff_cmd(tmp.path(), "99993").unwrap();
assert!(
output.contains("99993"),
"message should include story number: {output}"
);
assert!(
output.contains("found") || output.contains("pipeline"),
"message should explain not found: {output}"
);
}
#[test]
fn diff_command_no_worktree_returns_clear_error() {
use crate::chat::test_helpers::write_story_file;
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"55551_story_no_worktree.md",
"---\nname: No Worktree\n---\n",
);
let output = diff_cmd(tmp.path(), "55551").unwrap();
assert!(
output.contains("worktree")
|| output.contains("no worktree")
|| output.contains("Worktree"),
"should report missing worktree: {output}"
);
}
#[test]
fn truncate_at_char_boundary_short_string() {
let s = "hello";
assert_eq!(truncate_at_char_boundary(s, 100), "hello");
}
#[test]
fn truncate_at_char_boundary_exact_limit() {
let s = "hello";
assert_eq!(truncate_at_char_boundary(s, 5), "hello");
}
#[test]
fn truncate_at_char_boundary_over_limit() {
let s = "hello world";
assert_eq!(truncate_at_char_boundary(s, 5), "hello");
}
}
+300
View File
@@ -0,0 +1,300 @@
//! Handler for the `freeze` and `unfreeze` commands.
//!
//! `freeze <number>` sets `frozen: true` on the story, halting pipeline
//! advancement and auto-assign until `unfreeze <number>` clears the flag.
use super::CommandContext;
use crate::io::story_metadata::{
clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field,
};
use std::path::Path;
/// Handle the `freeze` command.
///
/// Parses `<number>` from `ctx.args`, locates the work item, and sets
/// `frozen: true` in its front matter.
pub(super) fn handle_freeze(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Usage: `{} freeze <number>` (e.g. `freeze 42`)",
ctx.bot_name
));
}
Some(freeze_by_number(ctx.project_root, num_str))
}
/// Core freeze logic: find story by numeric prefix and set `frozen: true`.
///
/// Returns a Markdown-formatted response string suitable for all transports.
pub(crate) fn freeze_by_number(project_root: &Path, story_number: &str) -> String {
let (story_id, _, _, _) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
freeze_by_story_id(&story_id)
}
fn freeze_by_story_id(story_id: &str) -> String {
let contents = match crate::db::read_content(story_id) {
Some(c) => c,
None => return format!("Failed to read story content for **{story_id}**"),
};
let meta = match parse_front_matter(&contents) {
Ok(m) => m,
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
};
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
if meta.frozen == Some(true) {
return format!("**{story_name}** ({story_id}) is already frozen.");
}
let updated = set_front_matter_field(&contents, "frozen", "true");
crate::db::write_content(story_id, &updated);
let stage = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(story_id, &stage, &updated);
format!(
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
)
}
/// Handle the `unfreeze` command.
///
/// Parses `<number>` from `ctx.args`, locates the work item, and clears the
/// `frozen` flag to resume normal pipeline behaviour.
pub(super) fn handle_unfreeze(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Usage: `{} unfreeze <number>` (e.g. `unfreeze 42`)",
ctx.bot_name
));
}
Some(unfreeze_by_number(ctx.project_root, num_str))
}
/// Core unfreeze logic: find story by numeric prefix and clear `frozen` flag.
pub(crate) fn unfreeze_by_number(project_root: &Path, story_number: &str) -> String {
let (story_id, _, _, _) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
unfreeze_by_story_id(&story_id)
}
fn unfreeze_by_story_id(story_id: &str) -> String {
let contents = match crate::db::read_content(story_id) {
Some(c) => c,
None => return format!("Failed to read story content for **{story_id}**"),
};
let meta = match parse_front_matter(&contents) {
Ok(m) => m,
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
};
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
if meta.frozen != Some(true) {
return format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze.");
}
let updated = clear_front_matter_field_in_content(&contents, "frozen");
crate::db::write_content(story_id, &updated);
let stage = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(story_id, &stage, &updated);
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use crate::chat::test_helpers::write_story_file;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn freeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy freeze {args}"))
}
fn unfreeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy unfreeze {args}"))
}
#[test]
fn freeze_command_is_registered() {
use super::super::commands;
assert!(
commands().iter().any(|c| c.name == "freeze"),
"freeze command must be in the registry"
);
}
#[test]
fn unfreeze_command_is_registered() {
use super::super::commands;
assert!(
commands().iter().any(|c| c.name == "unfreeze"),
"unfreeze command must be in the registry"
);
}
#[test]
fn freeze_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = freeze_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage: {output}"
);
}
#[test]
fn unfreeze_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = unfreeze_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage: {output}"
);
}
#[test]
fn freeze_command_not_found_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = freeze_cmd_with_root(tmp.path(), "9988").unwrap();
assert!(
output.contains("9988") && output.contains("found"),
"not-found message should include number and 'found': {output}"
);
}
#[test]
fn freeze_command_sets_frozen_flag() {
let tmp = tempfile::TempDir::new().unwrap();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
"9940_story_freezeme.md",
"---\nname: Freeze Me\n---\n# Story\n",
);
let output = freeze_cmd_with_root(tmp.path(), "9940").unwrap();
assert!(
output.contains("Frozen") && output.contains("Freeze Me"),
"should confirm freeze with story name: {output}"
);
let contents = crate::db::read_content("9940_story_freezeme")
.expect("story content should be readable after freeze");
assert!(
contents.contains("frozen: true"),
"frozen flag should be set: {contents}"
);
}
#[test]
fn unfreeze_command_clears_frozen_flag() {
let tmp = tempfile::TempDir::new().unwrap();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
"9941_story_frozen.md",
"---\nname: Frozen Story\nfrozen: true\n---\n# Story\n",
);
let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap();
assert!(
output.contains("Unfrozen") && output.contains("Frozen Story"),
"should confirm unfreeze with story name: {output}"
);
let contents = crate::db::read_content("9941_story_frozen")
.expect("story content should be readable after unfreeze");
assert!(
!contents.contains("frozen:"),
"frozen flag should be removed: {contents}"
);
}
#[test]
fn unfreeze_command_not_frozen_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"9942_story_notfrozen.md",
"---\nname: Not Frozen\n---\n# Story\n",
);
let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap();
assert!(
output.contains("not frozen"),
"should return not-frozen error: {output}"
);
}
#[test]
fn freeze_command_already_frozen_returns_message() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"9943_story_alreadyfrozen.md",
"---\nname: Already Frozen\nfrozen: true\n---\n# Story\n",
);
let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap();
assert!(
output.contains("already frozen"),
"should say already frozen: {output}"
);
}
}
+17
View File
@@ -11,6 +11,8 @@ mod backlog;
mod cost; mod cost;
mod coverage; mod coverage;
mod depends; mod depends;
mod diff;
mod freeze;
mod git; mod git;
mod help; mod help;
pub(crate) mod loc; pub(crate) mod loc;
@@ -163,6 +165,11 @@ pub fn commands() -> &'static [BotCommand] {
description: "Display the full text of a work item: `show <number>`", description: "Display the full text of a work item: `show <number>`",
handler: show::handle_show, handler: show::handle_show,
}, },
BotCommand {
name: "diff",
description: "Show git diff from main branch to story worktree HEAD: `diff <number>`",
handler: diff::handle_diff,
},
BotCommand { BotCommand {
name: "overview", name: "overview",
description: "Show implementation summary for a merged story: `overview <number>`", description: "Show implementation summary for a merged story: `overview <number>`",
@@ -203,6 +210,16 @@ pub fn commands() -> &'static [BotCommand] {
description: "Reset a blocked story: `unblock <number>` (clears blocked flag and resets retry count)", description: "Reset a blocked story: `unblock <number>` (clears blocked flag and resets retry count)",
handler: unblock::handle_unblock, handler: unblock::handle_unblock,
}, },
BotCommand {
name: "freeze",
description: "Freeze a story at its current stage: `freeze <number>` (suppresses pipeline advancement and auto-assign)",
handler: freeze::handle_freeze,
},
BotCommand {
name: "unfreeze",
description: "Unfreeze a story: `unfreeze <number>` (resumes normal pipeline behaviour)",
handler: freeze::handle_unfreeze,
},
BotCommand { BotCommand {
name: "unreleased", name: "unreleased",
description: "Show stories merged to master since the last release tag", description: "Show stories merged to master since the last release tag",
+4 -49
View File
@@ -105,58 +105,13 @@ fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<Stri
if hash.is_empty() { None } else { Some(hash) } if hash.is_empty() { None } else { Some(hash) }
} }
/// Find the human-readable name of a story by searching content store then filesystem. /// Find the human-readable name of a story by searching CRDT then content store.
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> { fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
// Try content store first. let (_, _, _, content) = crate::chat::lookup::find_story_by_number(root, num_str)?;
for id in crate::db::all_content_ids() { let content = content?;
let file_num = id.split('_').next().unwrap_or(""); crate::io::story_metadata::parse_front_matter(&content)
if file_num == num_str
&& let Some(c) = crate::db::read_content(&id)
{
return crate::io::story_metadata::parse_front_matter(&c)
.ok()
.and_then(|m| m.name);
}
}
// Fallback: filesystem scan.
let stages = [
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in &stages {
let dir = root.join(".huskies").join("work").join(stage);
if !dir.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
return std::fs::read_to_string(&path).ok().and_then(|c| {
crate::io::story_metadata::parse_front_matter(&c)
.ok() .ok()
.and_then(|m| m.name) .and_then(|m| m.name)
});
}
}
}
}
}
None
} }
/// Return the `git show --stat` output for a commit. /// Return the `git show --stat` output for a commit.
+6 -11
View File
@@ -21,8 +21,8 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
)); ));
} }
// Find the story by numeric prefix: CRDT → content store → filesystem. // Find the story by numeric prefix: CRDT → content store.
let (story_id, _stage_dir, path, content) = let (story_id, _stage_dir, _path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) { match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
Some(found) => found, Some(found) => found,
None => { None => {
@@ -32,16 +32,11 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
} }
}; };
// `content` is populated from the content store (CRDT/DB path) or read // `content` comes from the CRDT / content store. If unavailable, report
// from disk during the filesystem fallback. If it is None (story found in // it rather than silently reading a stale on-disk copy.
// CRDT but no content-store entry yet), attempt a direct disk read. Some(content.unwrap_or_else(|| {
Some(
content
.or_else(|| std::fs::read_to_string(&path).ok())
.unwrap_or_else(|| {
format!("Story {story_id} found in pipeline but its content is unavailable.") format!("Story {story_id} found in pipeline but its content is unavailable.")
}), }))
)
} }
#[cfg(test)] #[cfg(test)]
+7 -1
View File
@@ -228,7 +228,13 @@ fn render_item_line(
} else { } else {
Some(item.name.as_str()) Some(item.name.as_str())
}; };
let display = story_short_label(story_id, name_opt); let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id);
let base_label = story_short_label(story_id, name_opt);
let display = if frozen {
format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
} else {
base_label
};
let cost_suffix = cost_by_story let cost_suffix = cost_by_story
.get(story_id) .get(story_id)
.filter(|&&c| c > 0.0) .filter(|&&c| c > 0.0)
+3 -70
View File
@@ -6,8 +6,7 @@
use super::CommandContext; use super::CommandContext;
use crate::io::story_metadata::{ use crate::io::story_metadata::{
clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field,
set_front_matter_field,
}; };
use std::path::Path; use std::path::Path;
@@ -34,9 +33,9 @@ pub(super) fn handle_unblock(ctx: &CommandContext) -> Option<String> {
/// Returns a Markdown-formatted response string suitable for all transports. /// Returns a Markdown-formatted response string suitable for all transports.
/// Also used by the MCP `unblock` tool. /// Also used by the MCP `unblock` tool.
/// ///
/// Lookup priority: CRDT → content store → filesystem (Story 512). /// Lookup priority: CRDT → content store.
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String { pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
let (story_id, _stage_dir, path, _content) = let (story_id, _, _, _) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) { match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found, Some(found) => found,
None => { None => {
@@ -44,15 +43,7 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
} }
}; };
// Prefer DB-backed unblock when the story is in the content store.
// Note: `content` may have come from the filesystem fallback in
// `find_story_by_number`, so we must re-check the DB rather than
// relying on `content.is_some()` alone.
if crate::db::read_content(&story_id).is_some() {
unblock_by_story_id(&story_id) unblock_by_story_id(&story_id)
} else {
unblock_by_path(&path, &story_id)
}
} }
/// Unblock a story using the content store (DB-backed). /// Unblock a story using the content store (DB-backed).
@@ -105,64 +96,6 @@ fn unblock_by_story_id(story_id: &str) -> String {
) )
} }
/// Core unblock logic: reset blocked state for a known story file path.
///
/// Reads front matter, verifies the story is blocked, clears the `blocked`
/// flag, and resets `retry_count` to 0. Also used by the MCP `unblock` tool
/// when the caller has already resolved the story path from a full `story_id`.
pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return format!("Failed to read story file: {e}"),
};
let meta = match parse_front_matter(&contents) {
Ok(m) => m,
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
};
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
let has_blocked = meta.blocked == Some(true);
let has_merge_failure = meta.merge_failure.is_some();
if !has_blocked && !has_merge_failure {
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
}
// Clear the blocked flag if present.
if has_blocked && let Err(e) = clear_front_matter_field(path, "blocked") {
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
}
// Clear merge_failure if present.
if has_merge_failure && let Err(e) = clear_front_matter_field(path, "merge_failure") {
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
}
// Reset retry_count to 0 (re-read the updated file, modify, write).
let updated_contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return format!("Failed to re-read story file after unblocking: {e}"),
};
let with_retry_reset = set_front_matter_field(&updated_contents, "retry_count", "0");
if let Err(e) = std::fs::write(path, &with_retry_reset) {
return format!("Failed to reset retry_count on **{story_id}**: {e}");
}
let mut cleared = Vec::new();
if has_blocked {
cleared.push("blocked");
}
if has_merge_failure {
cleared.push("merge_failure");
}
format!(
"Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.",
cleared.join(", ")
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+9 -4
View File
@@ -58,6 +58,10 @@ use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
/// announce the shutdown to all configured rooms before the process exits. /// announce the shutdown to all configured rooms before the process exits.
/// ///
/// Must be called from within a Tokio runtime context (e.g., from `main`). /// Must be called from within a Tokio runtime context (e.g., from `main`).
///
/// Returns an [`tokio::task::AbortHandle`] if the bot was actually spawned (Matrix/Discord
/// transports), or `None` if the config is absent, disabled, or uses a webhook-based
/// transport (Slack/WhatsApp) that does not require a persistent background task.
pub fn spawn_bot( pub fn spawn_bot(
project_root: &Path, project_root: &Path,
watcher_tx: broadcast::Sender<WatcherEvent>, watcher_tx: broadcast::Sender<WatcherEvent>,
@@ -66,12 +70,12 @@ pub fn spawn_bot(
shutdown_rx: watch::Receiver<Option<ShutdownReason>>, shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
gateway_active_project: Option<Arc<RwLock<String>>>, gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<String>, gateway_projects: Vec<String>,
) { ) -> Option<tokio::task::AbortHandle> {
let config = match BotConfig::load(project_root) { let config = match BotConfig::load(project_root) {
Some(c) => c, Some(c) => c,
None => { None => {
crate::slog!("[matrix-bot] bot.toml absent or disabled; Matrix integration skipped"); crate::slog!("[matrix-bot] bot.toml absent or disabled; Matrix integration skipped");
return; return None;
} }
}; };
@@ -81,7 +85,7 @@ pub fn spawn_bot(
"[bot] transport={} — skipping Matrix bot; webhooks handle this transport", "[bot] transport={} — skipping Matrix bot; webhooks handle this transport",
config.transport config.transport
); );
return; return None;
} }
crate::slog!( crate::slog!(
@@ -93,7 +97,7 @@ pub fn spawn_bot(
let root = project_root.to_path_buf(); let root = project_root.to_path_buf();
let watcher_rx = watcher_tx.subscribe(); let watcher_rx = watcher_tx.subscribe();
let watcher_rx_auto = watcher_tx.subscribe(); let watcher_rx_auto = watcher_tx.subscribe();
tokio::spawn(async move { let handle = tokio::spawn(async move {
if let Err(e) = bot::run_bot( if let Err(e) = bot::run_bot(
config, config,
root, root,
@@ -110,4 +114,5 @@ pub fn spawn_bot(
crate::slog!("[matrix-bot] Fatal error: {e}"); crate::slog!("[matrix-bot] Fatal error: {e}");
} }
}); });
Some(handle.abort_handle())
} }
+680 -31
View File
@@ -19,6 +19,7 @@ use std::collections::BTreeMap;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use uuid::Uuid; use uuid::Uuid;
@@ -64,6 +65,10 @@ pub struct JoinedAgent {
pub address: String, pub address: String,
/// Unix timestamp when the agent registered. /// Unix timestamp when the agent registered.
pub registered_at: f64, pub registered_at: f64,
/// Unix timestamp of the last heartbeat from this agent. Defaults to `registered_at`
/// for agents loaded from persisted state that predate the heartbeat feature.
#[serde(default)]
pub last_seen: f64,
/// Project this agent is assigned to, if any. /// Project this agent is assigned to, if any.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned_project: Option<String>, pub assigned_project: Option<String>,
@@ -96,8 +101,8 @@ struct AssignAgentRequest {
/// Shared gateway state threaded through HTTP handlers. /// Shared gateway state threaded through HTTP handlers.
#[derive(Clone)] #[derive(Clone)]
pub struct GatewayState { pub struct GatewayState {
/// The parsed gateway config with all registered projects. /// The live set of registered projects (initially loaded from `projects.toml`).
pub config: GatewayConfig, pub projects: Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
/// The currently active project name. /// The currently active project name.
pub active_project: Arc<RwLock<String>>, pub active_project: Arc<RwLock<String>>,
/// HTTP client for proxying requests to project containers. /// HTTP client for proxying requests to project containers.
@@ -106,8 +111,13 @@ pub struct GatewayState {
pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>, pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>,
/// One-time join tokens that have been issued but not yet consumed. /// One-time join tokens that have been issued but not yet consumed.
pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>, pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
/// Directory containing `projects.toml`, used for persisting agent data. /// Directory containing `projects.toml` and the `.huskies/` subfolder.
pub config_dir: PathBuf, pub config_dir: PathBuf,
/// HTTP port the gateway is listening on.
pub port: u16,
/// Abort handle for the running Matrix bot task (if any).
/// Stored so the bot can be restarted when credentials change.
pub bot_handle: Arc<TokioMutex<Option<tokio::task::AbortHandle>>>,
} }
/// Load persisted agents from `<config_dir>/gateway_agents.json`. /// Load persisted agents from `<config_dir>/gateway_agents.json`.
@@ -120,6 +130,21 @@ fn load_agents(config_dir: &Path) -> Vec<JoinedAgent> {
} }
} }
/// Persist the current projects map to `<config_dir>/projects.toml`.
/// Silently ignores write errors or skips when `config_dir` is empty.
async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir: &Path) {
if config_dir.as_os_str().is_empty() {
return;
}
let path = config_dir.join("projects.toml");
let config = GatewayConfig {
projects: projects.clone(),
};
if let Ok(data) = toml::to_string_pretty(&config) {
let _ = tokio::fs::write(&path, data).await;
}
}
/// Persist the current agent list to `<config_dir>/gateway_agents.json`. /// Persist the current agent list to `<config_dir>/gateway_agents.json`.
/// Silently ignores write errors (e.g. read-only filesystem or empty path). /// Silently ignores write errors (e.g. read-only filesystem or empty path).
async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) { async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) {
@@ -138,27 +163,30 @@ impl GatewayState {
/// The first project in the config becomes the active project by default. /// The first project in the config becomes the active project by default.
/// Previously registered agents are loaded from `gateway_agents.json` in /// Previously registered agents are loaded from `gateway_agents.json` in
/// `config_dir` if the file exists. /// `config_dir` if the file exists.
pub fn new(config: GatewayConfig, config_dir: PathBuf) -> Result<Self, String> { pub fn new(config: GatewayConfig, config_dir: PathBuf, port: u16) -> Result<Self, String> {
if config.projects.is_empty() { if config.projects.is_empty() {
return Err("projects.toml must define at least one project".to_string()); return Err("projects.toml must define at least one project".to_string());
} }
let first = config.projects.keys().next().unwrap().clone(); let first = config.projects.keys().next().unwrap().clone();
let agents = load_agents(&config_dir); let agents = load_agents(&config_dir);
Ok(Self { Ok(Self {
config, projects: Arc::new(RwLock::new(config.projects)),
active_project: Arc::new(RwLock::new(first)), active_project: Arc::new(RwLock::new(first)),
client: Client::new(), client: Client::new(),
joined_agents: Arc::new(RwLock::new(agents)), joined_agents: Arc::new(RwLock::new(agents)),
pending_tokens: Arc::new(RwLock::new(HashMap::new())), pending_tokens: Arc::new(RwLock::new(HashMap::new())),
config_dir, config_dir,
port,
bot_handle: Arc::new(TokioMutex::new(None)),
}) })
} }
/// Get the URL of the currently active project. /// Get the URL of the currently active project.
async fn active_url(&self) -> Result<String, String> { async fn active_url(&self) -> Result<String, String> {
let name = self.active_project.read().await.clone(); let name = self.active_project.read().await.clone();
self.config self.projects
.projects .read()
.await
.get(&name) .get(&name)
.map(|p| p.url.clone()) .map(|p| p.url.clone())
.ok_or_else(|| format!("active project '{name}' not found in config")) .ok_or_else(|| format!("active project '{name}' not found in config"))
@@ -477,8 +505,10 @@ async fn handle_switch_project(params: &Value, state: &GatewayState) -> JsonRpcR
return JsonRpcResponse::error(None, -32602, "missing required parameter: project".into()); return JsonRpcResponse::error(None, -32602, "missing required parameter: project".into());
} }
if !state.config.projects.contains_key(project) { let url = {
let available: Vec<&str> = state.config.projects.keys().map(|s| s.as_str()).collect(); let projects = state.projects.read().await;
if !projects.contains_key(project) {
let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect();
return JsonRpcResponse::error( return JsonRpcResponse::error(
None, None,
-32602, -32602,
@@ -488,16 +518,17 @@ async fn handle_switch_project(params: &Value, state: &GatewayState) -> JsonRpcR
), ),
); );
} }
projects[project].url.clone()
};
*state.active_project.write().await = project.to_string(); *state.active_project.write().await = project.to_string();
let url = &state.config.projects[project].url;
JsonRpcResponse::success( JsonRpcResponse::success(
None, None,
json!({ json!({
"content": [{ "content": [{
"type": "text", "type": "text",
"text": format!("Switched to project '{project}' ({})", url) "text": format!("Switched to project '{project}' ({url})")
}] }]
}), }),
) )
@@ -554,8 +585,15 @@ async fn handle_gateway_status(state: &GatewayState) -> JsonRpcResponse {
async fn handle_gateway_health(state: &GatewayState) -> JsonRpcResponse { async fn handle_gateway_health(state: &GatewayState) -> JsonRpcResponse {
let mut results = BTreeMap::new(); let mut results = BTreeMap::new();
for (name, entry) in &state.config.projects { let project_entries: Vec<(String, String)> = state
let health_url = format!("{}/health", entry.url.trim_end_matches('/')); .projects
.read()
.await
.iter()
.map(|(n, e)| (n.clone(), e.url.clone()))
.collect();
for (name, url) in &project_entries {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let status = match state.client.get(&health_url).send().await { let status = match state.client.get(&health_url).send().await {
Ok(resp) => { Ok(resp) => {
if resp.status().is_success() { if resp.status().is_success() {
@@ -657,11 +695,13 @@ pub async fn gateway_register_agent_handler(
tokens.remove(&req.token); tokens.remove(&req.token);
drop(tokens); drop(tokens);
let now = chrono::Utc::now().timestamp() as f64;
let agent = JoinedAgent { let agent = JoinedAgent {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
label: req.label, label: req.label,
address: req.address, address: req.address,
registered_at: chrono::Utc::now().timestamp() as f64, registered_at: now,
last_seen: now,
assigned_project: None, assigned_project: None,
}; };
@@ -741,7 +781,7 @@ pub async fn gateway_assign_agent_handler(
.and_then(|p| if p.is_empty() { None } else { Some(p) }); .and_then(|p| if p.is_empty() { None } else { Some(p) });
if let Some(ref p) = project if let Some(ref p) = project
&& !state.config.projects.contains_key(p.as_str()) && !state.projects.read().await.contains_key(p.as_str())
{ {
return Response::builder() return Response::builder()
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
@@ -781,6 +821,38 @@ pub async fn gateway_assign_agent_handler(
} }
} }
/// `POST /gateway/agents/:id/heartbeat` — update an agent's last-seen timestamp.
///
/// Build agents should call this periodically (e.g. every 30 s) so the gateway
/// can distinguish live agents from disconnected ones. Returns 204 No Content on
/// success or 404 if the agent ID is not found.
#[handler]
pub async fn gateway_heartbeat_handler(
PoemPath(id): PoemPath<String>,
state: Data<&Arc<GatewayState>>,
) -> Response {
let found = {
let mut agents = state.joined_agents.write().await;
match agents.iter_mut().find(|a| a.id == id) {
None => false,
Some(a) => {
a.last_seen = chrono::Utc::now().timestamp() as f64;
true
}
}
};
if found {
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
} else {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("agent not found"))
}
}
// ── Health aggregation endpoint ────────────────────────────────────── // ── Health aggregation endpoint ──────────────────────────────────────
/// HTTP GET `/health` handler for the gateway — aggregates health from all projects. /// HTTP GET `/health` handler for the gateway — aggregates health from all projects.
@@ -789,8 +861,15 @@ pub async fn gateway_health_handler(state: Data<&Arc<GatewayState>>) -> Response
let mut all_healthy = true; let mut all_healthy = true;
let mut statuses = BTreeMap::new(); let mut statuses = BTreeMap::new();
for (name, entry) in &state.config.projects { let project_entries: Vec<(String, String)> = state
let health_url = format!("{}/health", entry.url.trim_end_matches('/')); .projects
.read()
.await
.iter()
.map(|(n, e)| (n.clone(), e.url.clone()))
.collect();
for (name, url) in &project_entries {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let healthy = match state.client.get(&health_url).send().await { let healthy = match state.client.get(&health_url).send().await {
Ok(resp) => resp.status().is_success(), Ok(resp) => resp.status().is_success(),
Err(_) => false, Err(_) => false,
@@ -898,6 +977,9 @@ const GATEWAY_UI_HTML: &str = r#"<!DOCTYPE html>
.status { margin-top: 1rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; } .status { margin-top: 1rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
.status.ok { color: #4ade80; } .status.ok { color: #4ade80; }
.status.err { color: #f87171; } .status.err { color: #f87171; }
.nav { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid #334155; display: flex; gap: 1rem; }
.nav a { font-size: 0.8rem; color: #64748b; text-decoration: none; }
.nav a:hover { color: #94a3b8; }
</style> </style>
</head> </head>
<body> <body>
@@ -918,6 +1000,9 @@ const GATEWAY_UI_HTML: &str = r#"<!DOCTYPE html>
<span id="active-name"></span> <span id="active-name"></span>
</div> </div>
<div id="status" class="status"></div> <div id="status" class="status"></div>
<nav class="nav">
<a href="/bot-config">🤖 Bot Configuration</a>
</nav>
</div> </div>
<script> <script>
async function loadState() { async function loadState() {
@@ -986,8 +1071,9 @@ pub async fn gateway_index_handler() -> Response {
pub async fn gateway_api_handler(state: Data<&Arc<GatewayState>>) -> Response { pub async fn gateway_api_handler(state: Data<&Arc<GatewayState>>) -> Response {
let active = state.active_project.read().await.clone(); let active = state.active_project.read().await.clone();
let projects: Vec<Value> = state let projects: Vec<Value> = state
.config
.projects .projects
.read()
.await
.iter() .iter()
.map(|(name, entry)| { .map(|(name, entry)| {
json!({ json!({
@@ -1051,6 +1137,501 @@ pub async fn gateway_switch_handler(
)) ))
} }
// ── Project management API ───────────────────────────────────────────
/// Request body for adding a new project.
#[derive(Deserialize)]
struct AddProjectRequest {
name: String,
url: String,
}
/// `POST /api/gateway/projects` — add a new project to the gateway config.
///
/// Expects JSON `{ "name": "...", "url": "..." }`. Returns the created project
/// or 409 Conflict if a project with the same name already exists.
#[handler]
pub async fn gateway_add_project_handler(
state: Data<&Arc<GatewayState>>,
body: Json<AddProjectRequest>,
) -> Response {
let name = body.0.name.trim().to_string();
let url = body.0.url.trim().to_string();
if name.is_empty() {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("project name must not be empty"));
}
if url.is_empty() {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("project url must not be empty"));
}
{
let mut projects = state.projects.write().await;
if projects.contains_key(&name) {
return Response::builder()
.status(StatusCode::CONFLICT)
.body(Body::from(format!("project '{name}' already exists")));
}
projects.insert(name.clone(), ProjectEntry { url: url.clone() });
}
let snapshot = state.projects.read().await.clone();
save_config(&snapshot, &state.config_dir).await;
crate::slog!("[gateway] Added project '{name}' ({url})");
let body_val = json!({ "name": name, "url": url });
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(
serde_json::to_vec(&body_val).unwrap_or_default(),
))
}
/// `DELETE /api/gateway/projects/:name` — remove a project from the gateway config.
///
/// Returns 204 No Content on success. Returns 400 if this is the last project
/// (the gateway requires at least one project to remain configured).
#[handler]
pub async fn gateway_remove_project_handler(
PoemPath(name): PoemPath<String>,
state: Data<&Arc<GatewayState>>,
) -> Response {
let active = state.active_project.read().await.clone();
{
let mut projects = state.projects.write().await;
if !projects.contains_key(&name) {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from(format!("project '{name}' not found")));
}
if projects.len() == 1 {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("cannot remove the last project"));
}
projects.remove(&name);
}
let snapshot = state.projects.read().await.clone();
save_config(&snapshot, &state.config_dir).await;
// If the removed project was active, switch to the first remaining.
if active == name {
let first = state.projects.read().await.keys().next().cloned();
if let Some(new_active) = first {
*state.active_project.write().await = new_active;
}
}
crate::slog!("[gateway] Removed project '{name}'");
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
}
// ── Bot configuration API ────────────────────────────────────────────
/// Request/response body for the bot configuration API.
#[derive(Deserialize, Serialize, Default)]
struct BotConfigPayload {
/// Chat transport: `"matrix"` or `"slack"`.
transport: String,
// Matrix fields
homeserver: Option<String>,
username: Option<String>,
password: Option<String>,
// Slack fields
slack_bot_token: Option<String>,
slack_signing_secret: Option<String>,
}
/// Read the current raw bot.toml (without validation) as key/value pairs for
/// the configuration UI. Returns an empty payload if the file does not exist.
fn read_bot_config_raw(config_dir: &Path) -> BotConfigPayload {
let path = config_dir.join(".huskies").join("bot.toml");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return BotConfigPayload::default(),
};
let table: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(_) => return BotConfigPayload::default(),
};
let s = |key: &str| -> Option<String> {
table
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
};
BotConfigPayload {
transport: s("transport").unwrap_or_else(|| "matrix".to_string()),
homeserver: s("homeserver"),
username: s("username"),
password: s("password"),
slack_bot_token: s("slack_bot_token"),
slack_signing_secret: s("slack_signing_secret"),
}
}
/// Write a `bot.toml` from the given payload.
fn write_bot_config(config_dir: &Path, payload: &BotConfigPayload) -> Result<(), String> {
let huskies_dir = config_dir.join(".huskies");
std::fs::create_dir_all(&huskies_dir)
.map_err(|e| format!("cannot create .huskies dir: {e}"))?;
let path = huskies_dir.join("bot.toml");
let content = match payload.transport.as_str() {
"slack" => {
format!(
"enabled = true\ntransport = \"slack\"\n\nslack_bot_token = {}\nslack_signing_secret = {}\nslack_channel_ids = []\n",
toml_string(payload.slack_bot_token.as_deref().unwrap_or("")),
toml_string(payload.slack_signing_secret.as_deref().unwrap_or("")),
)
}
_ => {
// Default to matrix
format!(
"enabled = true\ntransport = \"matrix\"\n\nhomeserver = {}\nusername = {}\npassword = {}\nroom_ids = []\nallowed_users = []\n",
toml_string(payload.homeserver.as_deref().unwrap_or("")),
toml_string(payload.username.as_deref().unwrap_or("")),
toml_string(payload.password.as_deref().unwrap_or("")),
)
}
};
std::fs::write(&path, content).map_err(|e| format!("cannot write bot.toml: {e}"))
}
/// Escape a string as a TOML quoted string.
fn toml_string(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// `GET /api/gateway/pipeline` — fetch pipeline status from all registered projects.
///
/// Returns `{ "active": "<project>", "projects": { "<name>": { "active": [...], "backlog": [...], "backlog_count": N } | { "error": "..." } } }`.
#[handler]
pub async fn gateway_all_pipeline_handler(state: Data<&Arc<GatewayState>>) -> Response {
let project_entries: Vec<(String, String)> = state
.projects
.read()
.await
.iter()
.map(|(n, e)| (n.clone(), e.url.clone()))
.collect();
let mut results: BTreeMap<String, Value> = BTreeMap::new();
for (name, url) in &project_entries {
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
let rpc_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_pipeline_status",
"arguments": {}
}
});
let status = match state.client.post(&mcp_url).json(&rpc_body).send().await {
Ok(resp) => match resp.json::<Value>().await {
Ok(upstream) => {
// The tool result is a JSON string embedded in content[0].text.
if let Some(text) = upstream
.get("result")
.and_then(|r| r.get("content"))
.and_then(|c| c.get(0))
.and_then(|c| c.get("text"))
.and_then(|t| t.as_str())
{
serde_json::from_str(text)
.unwrap_or_else(|_| json!({ "error": "invalid pipeline json" }))
} else {
json!({ "error": "unexpected response shape" })
}
}
Err(e) => json!({ "error": format!("invalid response: {e}") }),
},
Err(e) => json!({ "error": format!("unreachable: {e}") }),
};
results.insert(name.clone(), status);
}
let active = state.active_project.read().await.clone();
let body = json!({ "active": active, "projects": results });
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
}
/// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON.
#[handler]
pub async fn gateway_bot_config_get_handler(state: Data<&Arc<GatewayState>>) -> Response {
let payload = read_bot_config_raw(&state.config_dir);
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap_or_default()))
}
/// `POST /api/gateway/bot-config` — write new bot.toml and restart the bot.
#[handler]
pub async fn gateway_bot_config_save_handler(
state: Data<&Arc<GatewayState>>,
body: Json<BotConfigPayload>,
) -> Response {
if let Err(e) = write_bot_config(&state.config_dir, &body) {
let err = json!({ "ok": false, "error": e });
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&err).unwrap_or_default()));
}
// Abort the existing bot task (if any) and spawn a fresh one with the new config.
{
let mut handle = state.bot_handle.lock().await;
if let Some(h) = handle.take() {
h.abort();
}
let gateway_projects: Vec<String> = state.projects.read().await.keys().cloned().collect();
let new_handle = spawn_gateway_bot(
&state.config_dir,
Arc::clone(&state.active_project),
gateway_projects,
state.port,
);
*handle = new_handle;
}
crate::slog!("[gateway] Bot configuration saved; bot restarted");
let ok = json!({ "ok": true });
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&ok).unwrap_or_default()))
}
/// Self-contained HTML page for bot configuration.
const GATEWAY_BOT_CONFIG_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bot Configuration Huskies Gateway</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 520px;
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.back {
color: #64748b;
text-decoration: none;
font-size: 0.85rem;
margin-right: auto;
}
.back:hover { color: #94a3b8; }
.logo { font-size: 1.5rem; }
h1 { font-size: 1.2rem; font-weight: 600; color: #f8fafc; }
.field { margin-bottom: 1rem; }
label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
input, select {
width: 100%;
padding: 0.625rem 0.875rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #f1f5f9;
font-size: 0.9rem;
}
input:focus, select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.25); }
select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.875rem center;
padding-right: 2.5rem;
}
.section { margin-top: 1rem; }
.divider {
border: none;
border-top: 1px solid #334155;
margin: 1.25rem 0;
}
button {
width: 100%;
padding: 0.75rem;
background: #6366f1;
border: none;
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
margin-top: 1.25rem;
}
button:hover { background: #4f46e5; }
button:disabled { background: #334155; color: #64748b; cursor: not-allowed; }
.status { margin-top: 0.875rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
.status.ok { color: #4ade80; }
.status.err { color: #f87171; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<a href="/" class="back"> Gateway</a>
<span class="logo">🤖</span>
<h1>Bot Configuration</h1>
</div>
<div class="field">
<label for="transport">Transport</label>
<select id="transport" onchange="onTransportChange(this.value)">
<option value="matrix">Matrix</option>
<option value="slack">Slack</option>
</select>
</div>
<hr class="divider">
<div id="matrix-fields" class="section">
<div class="field">
<label for="homeserver">Homeserver URL</label>
<input type="text" id="homeserver" placeholder="https://matrix.example.com">
</div>
<div class="field">
<label for="username">Bot Username</label>
<input type="text" id="username" placeholder="@bot:example.com">
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" placeholder="••••••••">
</div>
</div>
<div id="slack-fields" class="section" style="display:none">
<div class="field">
<label for="slack-bot-token">Bot Token</label>
<input type="password" id="slack-bot-token" placeholder="xoxb-…">
</div>
<div class="field">
<label for="slack-signing-secret">App / Signing Secret</label>
<input type="password" id="slack-signing-secret" placeholder="Your signing secret">
</div>
</div>
<button id="save-btn" onclick="save()">Save &amp; Restart Bot</button>
<div id="status" class="status"></div>
</div>
<script>
function onTransportChange(v) {
document.getElementById('matrix-fields').style.display = v === 'matrix' ? '' : 'none';
document.getElementById('slack-fields').style.display = v === 'slack' ? '' : 'none';
}
async function loadConfig() {
try {
const r = await fetch('/api/gateway/bot-config');
const d = await r.json();
document.getElementById('transport').value = d.transport || 'matrix';
onTransportChange(d.transport || 'matrix');
document.getElementById('homeserver').value = d.homeserver || '';
document.getElementById('username').value = d.username || '';
document.getElementById('password').value = d.password || '';
document.getElementById('slack-bot-token').value = d.slack_bot_token || '';
document.getElementById('slack-signing-secret').value = d.slack_signing_secret || '';
} catch(e) {
document.getElementById('status').textContent = 'Failed to load config: ' + e;
document.getElementById('status').className = 'status err';
}
}
async function save() {
const btn = document.getElementById('save-btn');
const statusEl = document.getElementById('status');
btn.disabled = true;
btn.textContent = 'Saving';
statusEl.className = 'status';
statusEl.textContent = '';
const transport = document.getElementById('transport').value;
const payload = { transport };
if (transport === 'matrix') {
payload.homeserver = document.getElementById('homeserver').value;
payload.username = document.getElementById('username').value;
payload.password = document.getElementById('password').value;
} else {
payload.slack_bot_token = document.getElementById('slack-bot-token').value;
payload.slack_signing_secret = document.getElementById('slack-signing-secret').value;
}
try {
const r = await fetch('/api/gateway/bot-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const d = await r.json();
if (d.ok) {
statusEl.className = 'status ok';
statusEl.textContent = 'Saved bot restarted with new credentials.';
} else {
statusEl.className = 'status err';
statusEl.textContent = d.error || 'Save failed';
}
} catch(e) {
statusEl.className = 'status err';
statusEl.textContent = 'Error: ' + e;
}
btn.disabled = false;
btn.textContent = 'Save & Restart Bot';
}
loadConfig();
</script>
</body>
</html>
"#;
/// Serve the bot configuration HTML page at `GET /bot-config`.
#[handler]
pub async fn gateway_bot_config_page_handler() -> Response {
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(GATEWAY_BOT_CONFIG_HTML))
}
// ── Gateway server startup ─────────────────────────────────────────── // ── Gateway server startup ───────────────────────────────────────────
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used. /// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
@@ -1062,7 +1643,8 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
.to_path_buf(); .to_path_buf();
let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?; let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?;
let state = GatewayState::new(config, config_dir.clone()).map_err(std::io::Error::other)?; let state =
GatewayState::new(config, config_dir.clone(), port).map_err(std::io::Error::other)?;
let state_arc = Arc::new(state); let state_arc = Arc::new(state);
let active = state_arc.active_project.read().await.clone(); let active = state_arc.active_project.read().await.clone();
@@ -1070,8 +1652,9 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
crate::slog!( crate::slog!(
"[gateway] Registered projects: {}", "[gateway] Registered projects: {}",
state_arc state_arc
.config
.projects .projects
.read()
.await
.keys() .keys()
.cloned() .cloned()
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -1085,18 +1668,35 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
} }
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory. // Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect(); let gateway_projects: Vec<String> = state_arc.projects.read().await.keys().cloned().collect();
spawn_gateway_bot( let bot_abort = spawn_gateway_bot(
&config_dir, &config_dir,
Arc::clone(&state_arc.active_project), Arc::clone(&state_arc.active_project),
gateway_projects, gateway_projects,
port, port,
); );
*state_arc.bot_handle.lock().await = bot_abort;
let route = poem::Route::new() let route = poem::Route::new()
.at("/", poem::get(gateway_index_handler)) .at("/bot-config", poem::get(gateway_bot_config_page_handler))
.at("/api/gateway", poem::get(gateway_api_handler)) .at("/api/gateway", poem::get(gateway_api_handler))
.at("/api/gateway/switch", poem::post(gateway_switch_handler)) .at("/api/gateway/switch", poem::post(gateway_switch_handler))
.at(
"/api/gateway/pipeline",
poem::get(gateway_all_pipeline_handler),
)
.at(
"/api/gateway/projects",
poem::post(gateway_add_project_handler),
)
.at(
"/api/gateway/projects/:name",
poem::delete(gateway_remove_project_handler),
)
.at(
"/api/gateway/bot-config",
poem::get(gateway_bot_config_get_handler).post(gateway_bot_config_save_handler),
)
.at( .at(
"/mcp", "/mcp",
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler), poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
@@ -1121,6 +1721,10 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
"/gateway/agents/:id/assign", "/gateway/agents/:id/assign",
poem::post(gateway_assign_agent_handler), poem::post(gateway_assign_agent_handler),
) )
.at(
"/gateway/agents/:id/heartbeat",
poem::post(gateway_heartbeat_handler),
)
// Serve the embedded React frontend so the gateway has a UI. // Serve the embedded React frontend so the gateway has a UI.
.at( .at(
"/assets/*path", "/assets/*path",
@@ -1167,12 +1771,14 @@ fn write_gateway_mcp_json(config_dir: &Path, port: u16) -> Result<(), std::io::E
/// returns immediately without spawning anything. When the bot is enabled it /// returns immediately without spawning anything. When the bot is enabled it
/// receives a shared reference to the gateway's active-project `RwLock` so the /// receives a shared reference to the gateway's active-project `RwLock` so the
/// `switch` command can change the active project without going through HTTP. /// `switch` command can change the active project without going through HTTP.
///
/// Returns an [`tokio::task::AbortHandle`] if the bot task was spawned, `None` otherwise.
fn spawn_gateway_bot( fn spawn_gateway_bot(
config_dir: &Path, config_dir: &Path,
active_project: ActiveProject, active_project: ActiveProject,
gateway_projects: Vec<String>, gateway_projects: Vec<String>,
port: u16, port: u16,
) { ) -> Option<tokio::task::AbortHandle> {
use crate::agents::AgentPool; use crate::agents::AgentPool;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
@@ -1202,7 +1808,7 @@ fn spawn_gateway_bot(
shutdown_rx, shutdown_rx,
Some(active_project), Some(active_project),
gateway_projects, gateway_projects,
); )
} }
// ── Tests ──────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────
@@ -1238,7 +1844,7 @@ url = "http://localhost:3002"
let config = GatewayConfig { let config = GatewayConfig {
projects: BTreeMap::new(), projects: BTreeMap::new(),
}; };
assert!(GatewayState::new(config, PathBuf::new()).is_err()); assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err());
} }
#[test] #[test]
@@ -1257,7 +1863,7 @@ url = "http://localhost:3002"
}, },
); );
let config = GatewayConfig { projects }; let config = GatewayConfig { projects };
let state = GatewayState::new(config, PathBuf::new()).unwrap(); let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
let active = state.active_project.blocking_read().clone(); let active = state.active_project.blocking_read().clone();
assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically. assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically.
} }
@@ -1290,7 +1896,7 @@ url = "http://localhost:3002"
}, },
); );
let config = GatewayConfig { projects }; let config = GatewayConfig { projects };
let state = GatewayState::new(config, PathBuf::new()).unwrap(); let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
let params = json!({ "arguments": { "project": "beta" } }); let params = json!({ "arguments": { "project": "beta" } });
let resp = handle_switch_project(&params, &state).await; let resp = handle_switch_project(&params, &state).await;
@@ -1310,7 +1916,7 @@ url = "http://localhost:3002"
}, },
); );
let config = GatewayConfig { projects }; let config = GatewayConfig { projects };
let state = GatewayState::new(config, PathBuf::new()).unwrap(); let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
let params = json!({ "arguments": { "project": "nonexistent" } }); let params = json!({ "arguments": { "project": "nonexistent" } });
let resp = handle_switch_project(&params, &state).await; let resp = handle_switch_project(&params, &state).await;
@@ -1327,7 +1933,7 @@ url = "http://localhost:3002"
}, },
); );
let config = GatewayConfig { projects }; let config = GatewayConfig { projects };
let state = GatewayState::new(config, PathBuf::new()).unwrap(); let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
let url = state.active_url().await.unwrap(); let url = state.active_url().await.unwrap();
assert_eq!(url, "http://my:3001"); assert_eq!(url, "http://my:3001");
@@ -1463,7 +2069,7 @@ enabled = false
}, },
); );
let config = GatewayConfig { projects }; let config = GatewayConfig { projects };
Arc::new(GatewayState::new(config, PathBuf::new()).unwrap()) Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap())
} }
#[tokio::test] #[tokio::test]
@@ -1563,6 +2169,7 @@ enabled = false
label: "agent-1".into(), label: "agent-1".into(),
address: "ws://a:3001/crdt-sync".into(), address: "ws://a:3001/crdt-sync".into(),
registered_at: 0.0, registered_at: 0.0,
last_seen: 0.0,
assigned_project: None, assigned_project: None,
}); });
let app = poem::Route::new() let app = poem::Route::new()
@@ -1584,6 +2191,7 @@ enabled = false
label: "to-delete".into(), label: "to-delete".into(),
address: "ws://x:3001/crdt-sync".into(), address: "ws://x:3001/crdt-sync".into(),
registered_at: 0.0, registered_at: 0.0,
last_seen: 0.0,
assigned_project: None, assigned_project: None,
}); });
let app = poem::Route::new() let app = poem::Route::new()
@@ -1611,4 +2219,45 @@ enabled = false
let resp = cli.delete("/gateway/agents/no-such-id").send().await; let resp = cli.delete("/gateway/agents/no-such-id").send().await;
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND); assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
} }
#[tokio::test]
async fn heartbeat_updates_last_seen() {
let state = make_test_state();
state.joined_agents.write().await.push(JoinedAgent {
id: "hb-id".into(),
label: "hb-agent".into(),
address: "ws://hb:3001/crdt-sync".into(),
registered_at: 0.0,
last_seen: 0.0,
assigned_project: None,
});
let app = poem::Route::new()
.at(
"/gateway/agents/:id/heartbeat",
poem::post(gateway_heartbeat_handler),
)
.data(state.clone());
let cli = poem::test::TestClient::new(app);
let resp = cli.post("/gateway/agents/hb-id/heartbeat").send().await;
assert_eq!(resp.0.status(), StatusCode::NO_CONTENT);
let agents = state.joined_agents.read().await;
assert!(agents[0].last_seen > 0.0);
}
#[tokio::test]
async fn heartbeat_unknown_id_returns_not_found() {
let state = make_test_state();
let app = poem::Route::new()
.at(
"/gateway/agents/:id/heartbeat",
poem::post(gateway_heartbeat_handler),
)
.data(state.clone());
let cli = poem::test::TestClient::new(app);
let resp = cli
.post("/gateway/agents/no-such-id/heartbeat")
.send()
.await;
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
}
} }
+55
View File
@@ -0,0 +1,55 @@
//! Bot configuration endpoints — GET/PUT for .huskies/bot.toml credentials.
use crate::http::context::{AppContext, OpenApiResult, bad_request};
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Tags)]
enum BotConfigTags {
BotConfig,
}
#[derive(Object, Serialize, Deserialize, Default)]
struct BotConfigPayload {
pub transport: Option<String>,
pub enabled: Option<bool>,
pub homeserver: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub room_ids: Option<Vec<String>>,
pub slack_bot_token: Option<String>,
pub slack_signing_secret: Option<String>,
pub slack_channel_ids: Option<Vec<String>>,
}
pub struct BotConfigApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "BotConfigTags::BotConfig")]
impl BotConfigApi {
/// Read current bot credentials from .huskies/bot.toml.
#[oai(path = "/bot/config", method = "get")]
async fn get_config(&self) -> OpenApiResult<Json<BotConfigPayload>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let path = root.join(".huskies").join("bot.toml");
let config: BotConfigPayload = std::fs::read_to_string(&path)
.ok()
.and_then(|s| toml::from_str(&s).ok())
.unwrap_or_default();
Ok(Json(config))
}
/// Persist bot credentials to .huskies/bot.toml.
#[oai(path = "/bot/config", method = "put")]
async fn put_config(
&self,
payload: Json<BotConfigPayload>,
) -> OpenApiResult<Json<BotConfigPayload>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let path = root.join(".huskies").join("bot.toml");
let content = toml::to_string(&payload.0).map_err(|e| bad_request(e.to_string()))?;
std::fs::write(&path, content).map_err(|e| bad_request(e.to_string()))?;
Ok(payload)
}
}
+194
View File
@@ -230,6 +230,92 @@ pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result<String, String>
.map_err(|e| format!("Serialization error: {e}")) .map_err(|e| format!("Serialization error: {e}"))
} }
/// Get remaining turns and budget for a running agent.
///
/// Returns turns used, max turns, remaining turns, budget used, max budget,
/// and remaining budget for the named agent. Fails if the agent is not
/// currently running or pending.
pub(super) fn tool_get_agent_remaining_turns_and_budget(
args: &Value,
ctx: &AppContext,
) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let agent_name = args
.get("agent_name")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: agent_name")?;
// Verify the agent exists and is running/pending.
let agents = ctx.agents.list_agents()?;
let agent_info = agents
.iter()
.find(|a| a.story_id == story_id && a.agent_name == agent_name)
.ok_or_else(|| format!("No agent '{agent_name}' found for story '{story_id}'"))?;
if !matches!(
agent_info.status,
crate::agents::AgentStatus::Running | crate::agents::AgentStatus::Pending
) {
return Err(format!(
"Agent '{agent_name}' for story '{story_id}' is not running (status: {})",
agent_info.status
));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let config = ProjectConfig::load(&project_root)?;
// Find the agent config (max_turns, max_budget_usd).
let agent_config = config.agent.iter().find(|a| a.name == agent_name);
let max_turns = agent_config.and_then(|a| a.max_turns);
let max_budget_usd = agent_config.and_then(|a| a.max_budget_usd);
// Count turns by reading log files and counting assistant events.
let log_files =
crate::agent_log::list_story_log_files(&project_root, story_id, Some(agent_name));
let mut turns_used: u64 = 0;
for path in &log_files {
if let Ok(entries) = crate::agent_log::read_log(path) {
for entry in &entries {
if entry.event.get("type").and_then(|v| v.as_str()) == Some("agent_json")
&& let Some(data) = entry.event.get("data")
&& data.get("type").and_then(|v| v.as_str()) == Some("assistant")
{
turns_used += 1;
}
}
}
}
// Compute budget used from completed-session token usage records.
let all_records = crate::agents::token_usage::read_all(&project_root).unwrap_or_default();
let budget_used_usd: f64 = all_records
.iter()
.filter(|r| r.story_id == story_id && r.agent_name == agent_name)
.map(|r| r.usage.total_cost_usd)
.sum();
let remaining_turns = max_turns.map(|max| (max as i64) - (turns_used as i64));
let remaining_budget_usd = max_budget_usd.map(|max| max - budget_used_usd);
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"agent_name": agent_name,
"status": agent_info.status.to_string(),
"turns_used": turns_used,
"max_turns": max_turns,
"remaining_turns": remaining_turns,
"budget_used_usd": budget_used_usd,
"max_budget_usd": max_budget_usd,
"remaining_budget_usd": remaining_budget_usd,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result<String, String> { pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args let story_id = args
.get("story_id") .get("story_id")
@@ -840,4 +926,112 @@ stage = "coder"
let pct = read_coverage_percent_from_json(tmp.path()); let pct = read_coverage_percent_from_json(tmp.path());
assert!(pct.is_none()); assert!(pct.is_none());
} }
// ── get_agent_remaining_turns_and_budget tests ──────────────────────────
#[test]
fn tool_get_agent_remaining_turns_and_budget_missing_story_id() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_get_agent_remaining_turns_and_budget(&json!({"agent_name": "coder-1"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("story_id"));
}
#[test]
fn tool_get_agent_remaining_turns_and_budget_missing_agent_name() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_get_agent_remaining_turns_and_budget(&json!({"story_id": "1_test"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("agent_name"));
}
#[test]
fn tool_get_agent_remaining_turns_and_budget_no_agent_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_get_agent_remaining_turns_and_budget(
&json!({"story_id": "99_nope", "agent_name": "coder-1"}),
&ctx,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("No agent"),
"expected 'No agent' error, got: {err}"
);
}
#[test]
fn tool_get_agent_remaining_turns_and_budget_completed_agent_returns_error() {
use crate::agents::AgentStatus;
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
ctx.agents
.inject_test_agent("42_story", "coder-1", AgentStatus::Completed);
let result = tool_get_agent_remaining_turns_and_budget(
&json!({"story_id": "42_story", "agent_name": "coder-1"}),
&ctx,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("not running"),
"expected 'not running' error, got: {err}"
);
}
#[test]
fn tool_get_agent_remaining_turns_and_budget_running_agent_returns_data() {
use crate::agents::AgentStatus;
use crate::store::StoreOps;
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
ctx.store
.set("project_root", json!(tmp.path().to_string_lossy().as_ref()));
ctx.agents
.inject_test_agent("42_story", "coder-1", AgentStatus::Running);
let result = tool_get_agent_remaining_turns_and_budget(
&json!({"story_id": "42_story", "agent_name": "coder-1"}),
&ctx,
)
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["story_id"], "42_story");
assert_eq!(parsed["agent_name"], "coder-1");
assert_eq!(parsed["status"], "running");
assert!(parsed.get("turns_used").is_some());
assert!(parsed.get("budget_used_usd").is_some());
// max_turns and max_budget_usd may be null if not configured
assert!(parsed.get("max_turns").is_some());
assert!(parsed.get("remaining_turns").is_some());
assert!(parsed.get("max_budget_usd").is_some());
assert!(parsed.get("remaining_budget_usd").is_some());
}
#[test]
fn tool_get_agent_remaining_turns_and_budget_in_tools_list() {
use super::super::handle_tools_list;
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let tool = tools
.iter()
.find(|t| t["name"] == "get_agent_remaining_turns_and_budget");
assert!(
tool.is_some(),
"get_agent_remaining_turns_and_budget missing from tools list"
);
let t = tool.unwrap();
let required = t["inputSchema"]["required"].as_array().unwrap();
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(req_names.contains(&"story_id"));
assert!(req_names.contains(&"agent_name"));
}
} }
+71
View File
@@ -15,6 +15,23 @@ pub(super) async fn tool_merge_agent_work(
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?; .ok_or("Missing required argument: story_id")?;
// Check CRDT stage before attempting merge — if already done or archived,
// return success immediately to avoid spurious error notifications.
if let Some(item) = crate::crdt_state::read_item(story_id)
&& (item.stage == "5_done" || item.stage == "6_archived")
{
return serde_json::to_string_pretty(&json!({
"story_id": story_id,
"status": "completed",
"success": true,
"message": format!(
"Story '{}' is already in '{}' — no merge needed.",
story_id, item.stage
),
}))
.map_err(|e| format!("Serialization error: {e}"));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?; let project_root = ctx.agents.get_project_root(&ctx.state)?;
ctx.agents.start_merge_agent_work(&project_root, story_id)?; ctx.agents.start_merge_agent_work(&project_root, story_id)?;
@@ -258,6 +275,60 @@ mod tests {
assert!(result.unwrap_err().contains("story_id")); assert!(result.unwrap_err().contains("story_id"));
} }
#[tokio::test]
async fn tool_merge_agent_work_already_done_returns_success() {
crate::crdt_state::init_for_test();
crate::crdt_state::write_item(
"99_story_already_done",
"5_done",
Some("Already done story"),
None,
None,
None,
None,
None,
None,
None,
);
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_merge_agent_work(&json!({"story_id": "99_story_already_done"}), &ctx).await;
assert!(result.is_ok(), "expected Ok, got: {result:?}");
let body = result.unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["status"], "completed");
assert_eq!(v["success"], true);
assert!(v["message"].as_str().unwrap().contains("5_done"));
}
#[tokio::test]
async fn tool_merge_agent_work_already_archived_returns_success() {
crate::crdt_state::init_for_test();
crate::crdt_state::write_item(
"98_story_already_archived",
"6_archived",
Some("Already archived story"),
None,
None,
None,
None,
None,
None,
None,
);
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_merge_agent_work(&json!({"story_id": "98_story_already_archived"}), &ctx).await;
assert!(result.is_ok(), "expected Ok, got: {result:?}");
let body = result.unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["status"], "completed");
assert_eq!(v["success"], true);
assert!(v["message"].as_str().unwrap().contains("6_archived"));
}
#[tokio::test] #[tokio::test]
async fn tool_move_story_to_merge_missing_story_id() { async fn tool_move_story_to_merge_missing_story_id() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+66 -1
View File
@@ -431,6 +431,24 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": ["story_id", "agent_name"] "required": ["story_id", "agent_name"]
} }
}, },
{
"name": "get_agent_remaining_turns_and_budget",
"description": "Get remaining turns and budget for a running agent. Returns turns used, max turns, remaining turns, budget used (from completed sessions), max budget, and remaining budget. Only works for agents in running or pending state.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (e.g. '42_story_my_feature')"
},
"agent_name": {
"type": "string",
"description": "Agent name (e.g. 'coder-1', 'mergemaster', 'qa')"
}
},
"required": ["story_id", "agent_name"]
}
},
{ {
"name": "create_worktree", "name": "create_worktree",
"description": "Create a git worktree for a story under .huskies/worktrees/{story_id} with deterministic naming. Writes .mcp.json and runs component setup. Returns the worktree path.", "description": "Create a git worktree for a story under .huskies/worktrees/{story_id} with deterministic naming. Writes .mcp.json and runs component setup. Returns the worktree path.",
@@ -513,6 +531,28 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": ["story_id", "criterion_index"] "required": ["story_id", "criterion_index"]
} }
}, },
{
"name": "edit_criterion",
"description": "Update the text of an existing acceptance criterion in place, preserving its checked/unchecked state. Uses a 0-based index counting all criteria (both checked and unchecked).",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (filename stem, e.g. '28_my_story')"
},
"criterion_index": {
"type": "integer",
"description": "0-based index of the criterion to edit (counts all criteria)"
},
"new_text": {
"type": "string",
"description": "New text for the criterion (without the '- [ ] ' or '- [x] ' prefix)"
}
},
"required": ["story_id", "criterion_index", "new_text"]
}
},
{ {
"name": "add_criterion", "name": "add_criterion",
"description": "Add an acceptance criterion to an existing story file. Appends '- [ ] {criterion}' after the last existing criterion in the '## Acceptance Criteria' section. Auto-commits via the filesystem watcher.", "description": "Add an acceptance criterion to an existing story file. Appends '- [ ] {criterion}' after the last existing criterion in the '## Acceptance Criteria' section. Auto-commits via the filesystem watcher.",
@@ -531,6 +571,24 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": ["story_id", "criterion"] "required": ["story_id", "criterion"]
} }
}, },
{
"name": "remove_criterion",
"description": "Remove an acceptance criterion from a story by its 0-based index (counting all criteria, both checked and unchecked). Returns an error if the index is out of range. Auto-commits via the filesystem watcher.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (filename stem, e.g. '28_my_story')"
},
"criterion_index": {
"type": "integer",
"description": "0-based index of the criterion to remove (counts all criteria)"
}
},
"required": ["story_id", "criterion_index"]
}
},
{ {
"name": "update_story", "name": "update_story",
"description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, qa). Auto-commits via the filesystem watcher.", "description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, qa). Auto-commits via the filesystem watcher.",
@@ -1232,6 +1290,9 @@ async fn handle_tools_call(id: Option<Value>, params: &Value, ctx: &AppContext)
"reload_agent_config" => agent_tools::tool_get_agent_config(ctx), "reload_agent_config" => agent_tools::tool_get_agent_config(ctx),
"get_agent_output" => agent_tools::tool_get_agent_output(&args, ctx).await, "get_agent_output" => agent_tools::tool_get_agent_output(&args, ctx).await,
"wait_for_agent" => agent_tools::tool_wait_for_agent(&args, ctx).await, "wait_for_agent" => agent_tools::tool_wait_for_agent(&args, ctx).await,
"get_agent_remaining_turns_and_budget" => {
agent_tools::tool_get_agent_remaining_turns_and_budget(&args, ctx)
}
// Worktree tools // Worktree tools
"create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await, "create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await,
"list_worktrees" => agent_tools::tool_list_worktrees(ctx), "list_worktrees" => agent_tools::tool_list_worktrees(ctx),
@@ -1242,7 +1303,9 @@ async fn handle_tools_call(id: Option<Value>, params: &Value, ctx: &AppContext)
"accept_story" => story_tools::tool_accept_story(&args, ctx), "accept_story" => story_tools::tool_accept_story(&args, ctx),
// Story mutation tools (auto-commit to master) // Story mutation tools (auto-commit to master)
"check_criterion" => story_tools::tool_check_criterion(&args, ctx), "check_criterion" => story_tools::tool_check_criterion(&args, ctx),
"edit_criterion" => story_tools::tool_edit_criterion(&args, ctx),
"add_criterion" => story_tools::tool_add_criterion(&args, ctx), "add_criterion" => story_tools::tool_add_criterion(&args, ctx),
"remove_criterion" => story_tools::tool_remove_criterion(&args, ctx),
"update_story" => story_tools::tool_update_story(&args, ctx), "update_story" => story_tools::tool_update_story(&args, ctx),
// Spike lifecycle tools // Spike lifecycle tools
"create_spike" => story_tools::tool_create_spike(&args, ctx), "create_spike" => story_tools::tool_create_spike(&args, ctx),
@@ -1381,6 +1444,7 @@ mod tests {
assert!(names.contains(&"reload_agent_config")); assert!(names.contains(&"reload_agent_config"));
assert!(names.contains(&"get_agent_output")); assert!(names.contains(&"get_agent_output"));
assert!(names.contains(&"wait_for_agent")); assert!(names.contains(&"wait_for_agent"));
assert!(names.contains(&"get_agent_remaining_turns_and_budget"));
assert!(names.contains(&"create_worktree")); assert!(names.contains(&"create_worktree"));
assert!(names.contains(&"list_worktrees")); assert!(names.contains(&"list_worktrees"));
assert!(names.contains(&"remove_worktree")); assert!(names.contains(&"remove_worktree"));
@@ -1426,7 +1490,8 @@ mod tests {
assert!(names.contains(&"loc_file")); assert!(names.contains(&"loc_file"));
assert!(names.contains(&"dump_crdt")); assert!(names.contains(&"dump_crdt"));
assert!(names.contains(&"get_version")); assert!(names.contains(&"get_version"));
assert_eq!(tools.len(), 63); assert!(names.contains(&"remove_criterion"));
assert_eq!(tools.len(), 66);
} }
#[test] #[test]
+103 -2
View File
@@ -5,8 +5,9 @@ use crate::agents::{
use crate::http::context::AppContext; use crate::http::context::AppContext;
use crate::http::workflow::{ use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file, add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
create_spike_file, create_story_file, list_bug_files, list_refactor_files, load_pipeline_state, create_spike_file, create_story_file, edit_criterion_in_file, list_bug_files,
load_upcoming_stories, update_story_in_file, validate_story_dirs, list_refactor_files, load_pipeline_state, load_upcoming_stories, remove_criterion_from_file,
update_story_in_file, validate_story_dirs,
}; };
use crate::io::story_metadata::{ use crate::io::story_metadata::{
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos, check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
@@ -331,6 +332,28 @@ pub(super) fn tool_check_criterion(args: &Value, ctx: &AppContext) -> Result<Str
)) ))
} }
pub(super) fn tool_edit_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let criterion_index = args
.get("criterion_index")
.and_then(|v| v.as_u64())
.ok_or("Missing required argument: criterion_index")? as usize;
let new_text = args
.get("new_text")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: new_text")?;
let root = ctx.state.get_project_root()?;
edit_criterion_in_file(&root, story_id, criterion_index, new_text)?;
Ok(format!(
"Criterion {criterion_index} updated for story '{story_id}'."
))
}
pub(super) fn tool_add_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> { pub(super) fn tool_add_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args let story_id = args
.get("story_id") .get("story_id")
@@ -349,6 +372,24 @@ pub(super) fn tool_add_criterion(args: &Value, ctx: &AppContext) -> Result<Strin
)) ))
} }
pub(super) fn tool_remove_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let criterion_index = args
.get("criterion_index")
.and_then(|v| v.as_u64())
.ok_or("Missing required argument: criterion_index")? as usize;
let root = ctx.state.get_project_root()?;
remove_criterion_from_file(&root, story_id, criterion_index)?;
Ok(format!(
"Removed criterion {criterion_index} from story '{story_id}'."
))
}
pub(super) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> { pub(super) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args let story_id = args
.get("story_id") .get("story_id")
@@ -1722,6 +1763,66 @@ mod tests {
assert!(result.unwrap().contains("Criterion 0 checked")); assert!(result.unwrap().contains("Criterion 0 checked"));
} }
#[test]
fn tool_remove_criterion_missing_story_id() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_remove_criterion(&json!({"criterion_index": 0}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("story_id"));
}
#[test]
fn tool_remove_criterion_missing_criterion_index() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_remove_criterion(&json!({"story_id": "1_test"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("criterion_index"));
}
#[test]
fn tool_remove_criterion_removes_item() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo_in(tmp.path());
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9905_test",
"2_current",
"---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Keep me\n- [ ] Remove me\n",
);
let ctx = test_ctx(tmp.path());
let result = tool_remove_criterion(
&json!({"story_id": "9905_test", "criterion_index": 1}),
&ctx,
);
assert!(result.is_ok(), "Expected ok: {result:?}");
assert!(result.unwrap().contains("Removed criterion 1"));
}
#[test]
fn tool_remove_criterion_out_of_range() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo_in(tmp.path());
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9906_test",
"2_current",
"---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Only one\n",
);
let ctx = test_ctx(tmp.path());
let result = tool_remove_criterion(
&json!({"story_id": "9906_test", "criterion_index": 5}),
&ctx,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("out of range"));
}
/// Regression test for bug 514: deleting a story must cancel its pending /// Regression test for bug 514: deleting a story must cancel its pending
/// rate-limit retry timer so the tick loop cannot re-spawn an agent. /// rate-limit retry timer so the tick loop cannot re-spawn an agent.
/// ///
+6 -1
View File
@@ -4,6 +4,7 @@ pub mod agents_sse;
pub mod anthropic; pub mod anthropic;
pub mod assets; pub mod assets;
pub mod bot_command; pub mod bot_command;
pub mod bot_config;
pub mod chat; pub mod chat;
pub mod context; pub mod context;
pub mod health; pub mod health;
@@ -23,6 +24,7 @@ pub mod ws;
use agents::AgentsApi; use agents::AgentsApi;
use anthropic::AnthropicApi; use anthropic::AnthropicApi;
use bot_command::BotCommandApi; use bot_command::BotCommandApi;
use bot_config::BotConfigApi;
use chat::ChatApi; use chat::ChatApi;
use context::AppContext; use context::AppContext;
use health::HealthApi; use health::HealthApi;
@@ -196,6 +198,7 @@ type ApiTuple = (
HealthApi, HealthApi,
BotCommandApi, BotCommandApi,
wizard::WizardApi, wizard::WizardApi,
BotConfigApi,
); );
type ApiService = OpenApiService<ApiTuple, ()>; type ApiService = OpenApiService<ApiTuple, ()>;
@@ -213,6 +216,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
HealthApi, HealthApi,
BotCommandApi { ctx: ctx.clone() }, BotCommandApi { ctx: ctx.clone() },
wizard::WizardApi { ctx: ctx.clone() }, wizard::WizardApi { ctx: ctx.clone() },
BotConfigApi { ctx: ctx.clone() },
); );
let api_service = let api_service =
@@ -228,7 +232,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
SettingsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() },
HealthApi, HealthApi,
BotCommandApi { ctx: ctx.clone() }, BotCommandApi { ctx: ctx.clone() },
wizard::WizardApi { ctx }, wizard::WizardApi { ctx: ctx.clone() },
BotConfigApi { ctx },
); );
let docs_service = let docs_service =
+2 -1
View File
@@ -7,7 +7,8 @@ pub use bug_ops::{
create_bug_file, create_refactor_file, create_spike_file, list_bug_files, list_refactor_files, create_bug_file, create_refactor_file, create_spike_file, list_bug_files, list_refactor_files,
}; };
pub use story_ops::{ pub use story_ops::{
add_criterion_to_file, check_criterion_in_file, create_story_file, update_story_in_file, add_criterion_to_file, check_criterion_in_file, create_story_file, edit_criterion_in_file,
remove_criterion_from_file, update_story_in_file,
}; };
pub use test_results::{ pub use test_results::{
read_test_results_from_story_file, write_coverage_baseline_to_story_file, read_test_results_from_story_file, write_coverage_baseline_to_story_file,
+160
View File
@@ -126,6 +126,111 @@ pub fn check_criterion_in_file(
Ok(()) Ok(())
} }
/// Remove an acceptance criterion from a story by its 0-based index (counting all criteria,
/// both checked and unchecked). Returns an error if the index is out of range.
pub fn remove_criterion_from_file(
project_root: &Path,
story_id: &str,
criterion_index: usize,
) -> Result<(), String> {
let contents = read_story_content(project_root, story_id)?;
let mut count: usize = 0;
let mut found = false;
let new_lines: Vec<String> = contents
.lines()
.filter(|line| {
let trimmed = line.trim();
if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") {
if count == criterion_index {
count += 1;
found = true;
return false;
}
count += 1;
}
true
})
.map(|s| s.to_string())
.collect();
if !found {
return Err(format!(
"Criterion index {criterion_index} out of range. Story '{story_id}' has \
{count} criteria (indices 0..{}).",
count.saturating_sub(1)
));
}
let mut new_str = new_lines.join("\n");
if contents.ends_with('\n') {
new_str.push('\n');
}
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &new_str);
Ok(())
}
/// Edit the text of an existing acceptance criterion without changing its checked state.
///
/// Finds the criterion at `criterion_index` (0-based, counting all criteria regardless
/// of checked state) and replaces its text with `new_text`.
pub fn edit_criterion_in_file(
project_root: &Path,
story_id: &str,
criterion_index: usize,
new_text: &str,
) -> Result<(), String> {
let contents = read_story_content(project_root, story_id)?;
let mut count: usize = 0;
let mut found = false;
let new_lines: Vec<String> = contents
.lines()
.map(|line| {
let trimmed = line.trim();
let prefix = if trimmed.starts_with("- [ ] ") {
Some("- [ ] ")
} else if trimmed.starts_with("- [x] ") {
Some("- [x] ")
} else {
None
};
if let Some(p) = prefix {
if count == criterion_index {
count += 1;
found = true;
let indent_len = line.len() - trimmed.len();
let indent = &line[..indent_len];
return format!("{indent}{p}{new_text}");
}
count += 1;
}
line.to_string()
})
.collect();
if !found {
return Err(format!(
"Criterion index {criterion_index} out of range. Story '{story_id}' has \
{count} criteria (indices 0..{}).",
count.saturating_sub(1)
));
}
let mut new_str = new_lines.join("\n");
if contents.ends_with('\n') {
new_str.push('\n');
}
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &new_str);
Ok(())
}
/// Add a new acceptance criterion to a story. /// Add a new acceptance criterion to a story.
/// ///
/// Appends `- [ ] {criterion}` after the last existing criterion line in the /// Appends `- [ ] {criterion}` after the last existing criterion line in the
@@ -520,6 +625,61 @@ mod tests {
assert!(result.unwrap_err().contains("Acceptance Criteria")); assert!(result.unwrap_err().contains("Acceptance Criteria"));
} }
// ── remove_criterion_from_file tests ──────────────────────────────────────
#[test]
fn remove_criterion_removes_by_index() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"20_remove_test",
&story_with_ac_section(&["First", "Second", "Third"]),
);
remove_criterion_from_file(tmp.path(), "20_remove_test", 1).unwrap();
let contents = read_story_content(tmp.path(), "20_remove_test").unwrap();
assert!(contents.contains("- [ ] First"), "First should remain");
assert!(!contents.contains("Second"), "Second should be removed");
assert!(contents.contains("- [ ] Third"), "Third should remain");
}
#[test]
fn remove_criterion_shifts_indices() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"21_remove_test",
&story_with_ac_section(&["A", "B", "C"]),
);
remove_criterion_from_file(tmp.path(), "21_remove_test", 0).unwrap();
let contents = read_story_content(tmp.path(), "21_remove_test").unwrap();
assert!(!contents.contains("- [ ] A"), "A should be removed");
assert!(contents.contains("- [ ] B"), "B should remain");
assert!(contents.contains("- [ ] C"), "C should remain");
// B is now at index 0, C at index 1 — verify by removing B next
remove_criterion_from_file(tmp.path(), "21_remove_test", 0).unwrap();
let contents2 = read_story_content(tmp.path(), "21_remove_test").unwrap();
assert!(!contents2.contains("- [ ] B"), "B should now be removed");
assert!(contents2.contains("- [ ] C"), "C should still remain");
}
#[test]
fn remove_criterion_out_of_range_returns_error() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"22_remove_test",
&story_with_ac_section(&["Only"]),
);
let result = remove_criterion_from_file(tmp.path(), "22_remove_test", 5);
assert!(result.is_err(), "should fail for out-of-range index");
assert!(result.unwrap_err().contains("out of range"));
}
// ── update_story_in_file tests ───────────────────────────────────────────── // ── update_story_in_file tests ─────────────────────────────────────────────
#[test] #[test]
+34
View File
@@ -57,6 +57,9 @@ pub struct StoryMetadata {
/// Story numbers this story depends on. Auto-assign will skip this story /// Story numbers this story depends on. Auto-assign will skip this story
/// until all dependencies have reached `5_done` or `6_archived`. /// until all dependencies have reached `5_done` or `6_archived`.
pub depends_on: Option<Vec<u32>>, pub depends_on: Option<Vec<u32>>,
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
/// does not advance it, and no mergemaster is spawned.
pub frozen: Option<bool>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -89,6 +92,8 @@ struct FrontMatter {
blocked: Option<bool>, blocked: Option<bool>,
/// Story numbers this story depends on. /// Story numbers this story depends on.
depends_on: Option<Vec<u32>>, depends_on: Option<Vec<u32>>,
/// When `true`, the story is frozen.
frozen: Option<bool>,
} }
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> { pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
@@ -129,6 +134,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
retry_count: front.retry_count, retry_count: front.retry_count,
blocked: front.blocked, blocked: front.blocked,
depends_on: front.depends_on, depends_on: front.depends_on,
frozen: front.frozen,
} }
} }
@@ -439,6 +445,20 @@ pub fn increment_retry_count_in_content(contents: &str) -> (String, u32) {
(updated, new_count) (updated, new_count)
} }
/// Return `true` if the story has `frozen: true` in the content store.
///
/// Used by the pipeline advance code to suppress stage transitions for frozen stories.
pub fn is_story_frozen_in_store(story_id: &str) -> bool {
let contents = match crate::db::read_content(story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
.ok()
.and_then(|m| m.frozen)
.unwrap_or(false)
}
/// Write `blocked: true` to story content (pure function). /// Write `blocked: true` to story content (pure function).
pub fn write_blocked_in_content(contents: &str) -> String { pub fn write_blocked_in_content(contents: &str) -> String {
set_front_matter_field(contents, "blocked", "true") set_front_matter_field(contents, "blocked", "true")
@@ -459,6 +479,20 @@ pub fn write_review_hold_in_content(contents: &str) -> String {
set_front_matter_field(contents, "review_hold", "true") set_front_matter_field(contents, "review_hold", "true")
} }
/// Write or update `depends_on` in story content (pure function).
///
/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`.
/// If `deps` is empty the field is removed.
pub fn write_depends_on_in_content(contents: &str, deps: &[u32]) -> String {
if deps.is_empty() {
remove_front_matter_field(contents, "depends_on")
} else {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
let yaml_value = format!("[{}]", nums.join(", "));
set_front_matter_field(contents, "depends_on", &yaml_value)
}
}
/// Resolve the effective QA mode for a story file. /// Resolve the effective QA mode for a story file.
/// ///
/// Reads the `qa` front matter field. If absent, falls back to `default`. /// Reads the `qa` front matter field. If absent, falls back to `default`.
+1 -1
View File
@@ -860,7 +860,7 @@ async fn main() -> Result<(), std::io::Error> {
// Optional Matrix bot: connect to the homeserver and start listening for // Optional Matrix bot: connect to the homeserver and start listening for
// messages if `.huskies/bot.toml` is present and enabled. // messages if `.huskies/bot.toml` is present and enabled.
if let Some(ref root) = startup_root { if let Some(ref root) = startup_root {
chat::transport::matrix::spawn_bot( let _ = chat::transport::matrix::spawn_bot(
root, root,
watcher_tx_for_bot, watcher_tx_for_bot,
perm_rx_for_bot, perm_rx_for_bot,