133 Commits

Author SHA1 Message Date
Timmy
618a2779ff Bump version to 0.6.0 2026-03-24 21:27:38 +00:00
Timmy
721d12bcfe Fixing code warnings 2026-03-24 21:26:48 +00:00
Timmy
df6d2db327 Upgrading toml and pulldown-cmark 2026-03-24 21:25:15 +00:00
Timmy
49285c1865 Binding promiscuously in Docker to make things happen 2026-03-24 21:24:57 +00:00
Timmy
0c15be43b8 Ignoring whatsapp history 2026-03-24 21:24:38 +00:00
Timmy
9408bd2cdf Bump version to 0.6.0 2026-03-24 21:06:15 +00:00
Timmy
a24e4c5c85 Making Matrix config fields optional to meet Twilio login needs 2026-03-24 21:05:35 +00:00
Timmy
c0133fe733 Ignoring backed up bot creds 2026-03-24 20:56:59 +00:00
dave
752c3904bf storkit: accept 380_story_assign_command_restarts_coder_when_story_is_already_in_progress 2026-03-24 19:02:42 +00:00
dave
bac53ac09a storkit: accept 381_story_bot_command_to_delete_a_worktree 2026-03-24 18:49:41 +00:00
dave
b2ef2eca5f storkit: accept 368_story_web_ui_oauth_flow_for_claude_authentication 2026-03-24 18:48:40 +00:00
dave
fb05f71e76 storkit: accept 379_bug_start_agent_ignores_story_front_matter_agent_assignment 2026-03-24 18:44:36 +00:00
dave
438be196c9 feat: scaffold creates per-transport bot.toml example files
New projects now get bot.toml.matrix.example, bot.toml.whatsapp-meta.example,
bot.toml.whatsapp-twilio.example, and bot.toml.slack.example in .storkit/
during scaffolding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:13:32 +00:00
dave
f1b4894d6e docs: split bot.toml.example into per-transport example files
Replace the monolithic bot.toml.example with separate files for each
transport: matrix, whatsapp-meta, whatsapp-twilio, and slack. Add a
chat bot configuration section to the README explaining that only one
transport can be active at a time and how to set up each one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:10:37 +00:00
dave
bd281fd749 storkit: done 383_refactor_reorganize_chat_system_into_chat_module_with_transport_submodules 2026-03-24 17:57:24 +00:00
dave
79edc28334 storkit: merge 383_refactor_reorganize_chat_system_into_chat_module_with_transport_submodules 2026-03-24 17:57:21 +00:00
dave
92c53704f0 storkit: create 383_refactor_reorganize_chat_system_into_chat_module_with_transport_submodules 2026-03-24 17:40:36 +00:00
dave
7223fa2f10 storkit: done 382_story_whatsapp_transport_supports_twilio_api_as_alternative_to_meta_cloud_api 2026-03-24 17:36:35 +00:00
dave
dedf951b17 storkit: merge 382_story_whatsapp_transport_supports_twilio_api_as_alternative_to_meta_cloud_api 2026-03-24 17:36:32 +00:00
dave
aad583defd storkit: create 382_story_whatsapp_transport_supports_twilio_api_as_alternative_to_meta_cloud_api 2026-03-24 16:41:13 +00:00
dave
88b02cf746 storkit: done 380_story_assign_command_restarts_coder_when_story_is_already_in_progress 2026-03-24 15:05:55 +00:00
dave
1a9833d820 storkit: merge 380_story_assign_command_restarts_coder_when_story_is_already_in_progress 2026-03-24 15:05:52 +00:00
dave
a904cda629 storkit: done 381_story_bot_command_to_delete_a_worktree 2026-03-24 15:02:54 +00:00
dave
c755c03f0e storkit: merge 381_story_bot_command_to_delete_a_worktree 2026-03-24 15:02:50 +00:00
dave
a8630f3e1b storkit: done 379_bug_start_agent_ignores_story_front_matter_agent_assignment 2026-03-24 14:59:32 +00:00
dave
9fb1bd5711 storkit: merge 379_bug_start_agent_ignores_story_front_matter_agent_assignment 2026-03-24 14:59:28 +00:00
dave
0b3ce0f33e storkit: done 368_story_web_ui_oauth_flow_for_claude_authentication 2026-03-24 14:54:30 +00:00
dave
f4b7573f0a storkit: create 381_story_bot_command_to_delete_a_worktree 2026-03-24 14:49:24 +00:00
dave
bb801ba826 storkit: create 380_story_assign_command_restarts_coder_when_story_is_already_in_progress 2026-03-24 14:49:21 +00:00
dave
53634d638d storkit: create 379_bug_start_agent_ignores_story_front_matter_agent_assignment 2026-03-24 14:44:30 +00:00
dave
b50e7cff00 storkit: create 368_story_web_ui_oauth_flow_for_claude_authentication 2026-03-24 14:40:50 +00:00
dave
68973b0bb8 storkit: create 368_story_web_ui_oauth_flow_for_claude_authentication 2026-03-24 13:36:24 +00:00
dave
34bbf5a122 storkit: accept 376_story_rename_mcp_whatsup_tool_to_status_for_consistency 2026-03-24 13:35:35 +00:00
dave
ed3c5f9c95 storkit: accept 376_story_rename_mcp_whatsup_tool_to_status_for_consistency 2026-03-24 11:10:25 +00:00
dave
59d1a2c069 storkit: done 376_story_rename_mcp_whatsup_tool_to_status_for_consistency 2026-03-24 11:09:21 +00:00
dave
52e73bfbea storkit: merge 376_story_rename_mcp_whatsup_tool_to_status_for_consistency 2026-03-24 11:09:17 +00:00
Timmy
4e590401a5 Bump version to 0.5.1 2026-03-24 10:55:06 +00:00
dave
6b6815325d storkit: accept 378_story_status_command_shows_work_item_type_story_bug_spike_refactor_next_to_each_item 2026-03-23 22:33:10 +00:00
dave
f874783b09 storkit: accept 377_bug_update_story_mcp_tool_writes_front_matter_values_as_yaml_strings_instead_of_native_types 2026-03-23 22:26:09 +00:00
dave
292f9cdfe2 storkit: accept 375_bug_default_project_toml_contains_rust_specific_setup_commands_for_non_rust_projects 2026-03-23 22:24:09 +00:00
dave
1cce46d3fa chore: bump version to 0.5.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:49:54 +00:00
dave
e85c06df19 storkit: done 377_bug_update_story_mcp_tool_writes_front_matter_values_as_yaml_strings_instead_of_native_types 2026-03-23 18:45:40 +00:00
dave
8b85ca743e storkit: merge 377_bug_update_story_mcp_tool_writes_front_matter_values_as_yaml_strings_instead_of_native_types 2026-03-23 18:45:37 +00:00
dave
1a7b6c7342 storkit: done 378_story_status_command_shows_work_item_type_story_bug_spike_refactor_next_to_each_item 2026-03-23 18:42:53 +00:00
dave
4a94158ef2 storkit: merge 378_story_status_command_shows_work_item_type_story_bug_spike_refactor_next_to_each_item 2026-03-23 18:42:49 +00:00
dave
f10ea1ecf2 storkit: done 374_story_web_ui_implements_all_bot_commands_as_slash_commands 2026-03-23 18:35:56 +00:00
dave
1a3b69301a storkit: merge 374_story_web_ui_implements_all_bot_commands_as_slash_commands 2026-03-23 18:35:52 +00:00
dave
6d3eab92fd storkit: create 378_story_status_command_shows_work_item_type_story_bug_spike_refactor_next_to_each_item 2026-03-23 18:32:14 +00:00
dave
f6920a87ad storkit: merge 375_bug_default_project_toml_contains_rust_specific_setup_commands_for_non_rust_projects 2026-03-23 18:32:05 +00:00
dave
5f9d903987 storkit: done 375_bug_default_project_toml_contains_rust_specific_setup_commands_for_non_rust_projects 2026-03-23 18:30:18 +00:00
dave
ea916d27f4 storkit: create 377_bug_update_story_mcp_tool_writes_front_matter_values_as_yaml_strings_instead_of_native_types 2026-03-23 18:25:53 +00:00
dave
970b9bcd9d storkit: accept 373_bug_scaffold_gitignore_missing_transient_pipeline_stage_directories 2026-03-23 18:12:45 +00:00
dave
a5ee6890f5 storkit: create 376_story_rename_mcp_whatsup_tool_to_status_for_consistency 2026-03-23 17:58:23 +00:00
dave
41dc3292bb storkit: accept 371_bug_no_arg_storkit_in_empty_directory_skips_scaffold 2026-03-23 17:56:56 +00:00
dave
3766f8b464 storkit: accept 370_bug_scaffold_does_not_create_mcp_json_in_project_root 2026-03-23 16:57:28 +00:00
dave
0c85ecc85c storkit: accept 369_bug_cli_treats_help_and_version_as_project_paths 2026-03-23 16:43:27 +00:00
dave
2c29a4d2b8 storkit: create 375_bug_default_project_toml_contains_rust_specific_setup_commands_for_non_rust_projects 2026-03-23 14:29:25 +00:00
dave
454d694d24 storkit: done 372_story_scaffold_auto_detects_tech_stack_and_configures_script_test 2026-03-23 14:25:58 +00:00
dave
96bedd70dc storkit: merge 372_story_scaffold_auto_detects_tech_stack_and_configures_script_test 2026-03-23 14:25:54 +00:00
dave
fffdd5c5ea storkit: create 374_story_web_ui_implements_all_bot_commands_as_slash_commands 2026-03-23 14:22:11 +00:00
dave
4805598932 storkit: done 373_bug_scaffold_gitignore_missing_transient_pipeline_stage_directories 2026-03-23 14:15:36 +00:00
dave
3d55e2fcc6 Add transient pipeline stages to scaffold .gitignore
work/2_current/, work/3_qa/, work/4_merge/ are not committed to git
(only 1_backlog, 5_done, 6_archived are). New projects were missing
these entries in .storkit/.gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:13:15 +00:00
dave
96b31d1a48 storkit: create 372_story_scaffold_auto_detects_tech_stack_and_configures_script_test 2026-03-23 14:12:37 +00:00
dave
11168fa426 storkit: create 373_bug_scaffold_gitignore_missing_transient_pipeline_stage_directories 2026-03-23 14:12:03 +00:00
dave
c2c2d65889 storkit: done 371_bug_no_arg_storkit_in_empty_directory_skips_scaffold 2026-03-23 14:08:46 +00:00
dave
5c8c4b7ff3 storkit: merge 371_bug_no_arg_storkit_in_empty_directory_skips_scaffold 2026-03-23 14:08:43 +00:00
dave
fbab93f493 storkit: create 372_story_scaffold_auto_detects_tech_stack_and_configures_script_test 2026-03-23 14:08:16 +00:00
dave
78ff6d104e storkit: create 371_bug_no_arg_storkit_in_empty_directory_skips_scaffold 2026-03-23 13:56:02 +00:00
Timmy
fcc2b9c3eb Bump version to 0.5.0 2026-03-23 13:11:57 +00:00
dave
0c4239501a storkit: done 370_bug_scaffold_does_not_create_mcp_json_in_project_root 2026-03-23 13:00:46 +00:00
dave
13b6ecd958 storkit: merge 370_bug_scaffold_does_not_create_mcp_json_in_project_root 2026-03-23 13:00:43 +00:00
dave
1816a94617 storkit: merge 369_bug_cli_treats_help_and_version_as_project_paths 2026-03-23 12:55:58 +00:00
dave
56d3373e69 Revert gVisor (runsc) from Docker setup
gVisor is incompatible with OrbStack bind mounts on macOS — writes to
/mnt/mac are blocked by the gVisor filesystem sandbox. Removing
runtime: runsc from docker-compose.yml, the gVisor setup docs from
README.md, and the runsc assertion test from rebuild.rs.

The existing Docker hardening (read-only root, cap_drop ALL,
no-new-privileges, resource limits) remains in place.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:53:10 +00:00
dave
efdb0c5814 storkit: create 370_bug_scaffold_does_not_create_mcp_json_in_project_root 2026-03-23 12:43:48 +00:00
dave
b8365275d8 storkit: create 370_bug_scaffold_does_not_create_mcp_json_in_project_root 2026-03-23 12:43:15 +00:00
dave
6ddfd29927 storkit: create 369_bug_cli_treats_help_and_version_as_project_paths 2026-03-23 12:43:04 +00:00
dave
01b157a2e4 storkit: create 369_bug_cli_treats_help_and_version_as_project_paths 2026-03-23 12:42:04 +00:00
dave
99a59d7ad1 storkit: create 369_bug_cli_treats_help_and_version_as_project_paths 2026-03-23 12:41:27 +00:00
dave
eb8adb6225 storkit: create 369_bug_cli_treats_help_and_version_as_project_paths 2026-03-23 12:39:15 +00:00
dave
2262f2ca6b storkit: create 368_story_web_ui_oauth_flow_for_claude_authentication 2026-03-23 11:59:43 +00:00
dave
2bb36d0e68 storkit: accept 360_story_run_storkit_container_under_gvisor_runsc_runtime 2026-03-23 11:48:32 +00:00
dave
86102f8ad6 storkit: done 360_story_run_storkit_container_under_gvisor_runsc_runtime 2026-03-23 11:45:47 +00:00
dave
edf47601c4 storkit: merge 360_story_run_storkit_container_under_gvisor_runsc_runtime 2026-03-23 11:45:43 +00:00
dave
b606e1de92 storkit: accept 367_story_rename_bot_whatsup_command_to_status 2026-03-23 02:38:20 +00:00
dave
0d5f0de876 storkit: accept 365_story_surface_api_rate_limit_warnings_in_chat 2026-03-22 23:29:13 +00:00
dave
bb41f3951c storkit: accept 366_story_bot_sends_shutdown_message_on_server_stop_or_rebuild 2026-03-22 23:11:51 +00:00
dave
e3d7931f17 storkit: done 367_story_rename_bot_whatsup_command_to_status 2026-03-22 22:40:47 +00:00
dave
87b5648123 storkit: merge 367_story_rename_bot_whatsup_command_to_status 2026-03-22 22:40:43 +00:00
dave
506bdd4df8 storkit: accept 363_story_mcp_tool_for_whatsup_story_triage 2026-03-22 21:44:39 +00:00
dave
a9bec3c29e storkit: accept 362_story_bot_whatsup_command_shows_in_progress_work_summary 2026-03-22 21:43:38 +00:00
dave
69936f457f storkit: done 365_story_surface_api_rate_limit_warnings_in_chat 2026-03-22 19:28:48 +00:00
dave
24dd3d9fa9 storkit: merge 365_story_surface_api_rate_limit_warnings_in_chat 2026-03-22 19:28:45 +00:00
dave
bc45a91b3e Fix frontend tests failing in Docker due to wrong-platform rollup binary
The bind-mounted node_modules from macOS contains darwin-arm64 native
binaries which don't work in the Linux container. Run npm install on
container startup to get the correct platform binaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:28:21 +00:00
dave
db7c11508e storkit: done 366_story_bot_sends_shutdown_message_on_server_stop_or_rebuild 2026-03-22 19:11:33 +00:00
dave
47173e0d3a storkit: merge 366_story_bot_sends_shutdown_message_on_server_stop_or_rebuild 2026-03-22 19:11:29 +00:00
dave
f610ef6046 Restore codebase deleted by bad auto-commit e4227cf
Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:07:07 +00:00
dave
89f776b978 storkit: create 367_story_rename_bot_whatsup_command_to_status 2026-03-22 19:01:22 +00:00
dave
e4227cf673 storkit: create 365_story_surface_api_rate_limit_warnings_in_chat 2026-03-22 18:19:23 +00:00
dave
f346712dd1 storkit: create 365_story_surface_api_rate_limit_warnings_in_chat 2026-03-22 18:19:23 +00:00
dave
f9419e5ea7 Fix worktree cleanup looping on orphaned directories
When git worktree remove fails with "not a working tree", fall back to
removing the directory directly and run git worktree prune to clean
stale metadata. Fixes bug 364.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:18:08 +00:00
dave
c32bab03a4 storkit: delete 364_bug_worktree_cleanup_loops_on_orphaned_directories 2026-03-22 18:17:43 +00:00
dave
ea23042698 storkit: create 366_story_bot_sends_shutdown_message_on_server_stop_or_rebuild 2026-03-22 18:17:04 +00:00
dave
3825b03fda storkit: create 365_story_surface_api_rate_limit_warnings_in_chat 2026-03-22 18:12:37 +00:00
dave
d6cfd18e6a storkit: create 364_bug_worktree_cleanup_loops_on_orphaned_directories 2026-03-22 18:07:37 +00:00
dave
01ac8a8345 Remove empty serve submodule reference
Blank folder, no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:06:23 +00:00
dave
153f8812d7 Remove obsolete TIMMY_BRIEFING.md
One-time briefing doc from spike 329, no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:06:15 +00:00
dave
01c7c39872 Update .ignore to use renamed storkit paths
.story_kit/ and .story_kit_port were stale references from before the
rename to storkit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:06:07 +00:00
dave
eec8f3ac15 storkit: delete 364_bug_test_suite_incompatible_with_hardened_docker_environment 2026-03-22 17:54:48 +00:00
dave
28626ab80a storkit: done 363_story_mcp_tool_for_whatsup_story_triage 2026-03-22 17:47:08 +00:00
dave
4262af7faa storkit: merge 363_story_mcp_tool_for_whatsup_story_triage 2026-03-22 17:47:05 +00:00
dave
628b60ad15 storkit: done 362_story_bot_whatsup_command_shows_in_progress_work_summary 2026-03-22 17:43:01 +00:00
dave
c504738949 storkit: merge 362_story_bot_whatsup_command_shows_in_progress_work_summary 2026-03-22 17:42:57 +00:00
Timmy
0d5b9724c1 Make ANTHROPIC_API_KEY optional in docker-compose
When unset, Claude Code falls back to OAuth credentials from
`claude login`, allowing agents to run on a Max subscription
instead of prepaid API credits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:31:09 +00:00
Timmy
b189ca845c Pre-create target dirs with storkit ownership in Dockerfile
Docker named volumes inherit directory ownership when first created.
By creating /workspace/target and /app/target as storkit-owned before
the USER directive, the volumes will be writable by the storkit user.
Without this, cargo build/test/clippy all fail with Permission Denied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:59:32 +00:00
Dave
8094d32cbb revert: remove Docker workarounds now that container is fixed
Reverts workarounds added by the 361 agent when the hardened Docker
container broke the test suite:

- gates.rs: restore tempfile::tempdir() (was changed to tempdir_in
  CARGO_MANIFEST_DIR to avoid noexec /tmp; noexec is now removed)
- pool/mod.rs: restore ps -p <pid> check in process_is_running (was
  changed to /proc/<pid> existence check; procps is now installed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:21:34 +00:00
Dave
1c2824fa31 fix: harden Docker environment so tests pass inside container
- Add procps to runtime stage so `ps` is available for process management
- Remove noexec from /tmp and /home/storkit tmpfs mounts so test scripts
  can be executed from tempdir
- Update coder agent system_prompt to run clippy --all-targets --all-features
  matching what the server acceptance gate actually runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:21:27 +00:00
Dave
af72f593e8 storkit: create 364_bug_test_suite_incompatible_with_hardened_docker_environment 2026-03-22 13:50:14 +00:00
Dave
ac8112bf0b storkit: accept 361_story_remove_deprecated_manual_qa_front_matter_field 2026-03-22 01:48:51 +00:00
Dave
9bf4b65707 storkit: accept 359_story_harden_docker_setup_for_security 2026-03-22 00:23:40 +00:00
Dave
240ebf055a storkit: accept 329_spike_evaluate_docker_orbstack_for_agent_isolation_and_resource_limiting 2026-03-22 00:22:39 +00:00
Dave
293a2fcfb6 storkit: done 361_story_remove_deprecated_manual_qa_front_matter_field 2026-03-21 21:51:31 +00:00
Dave
4ccc3d9149 storkit: merge 361_story_remove_deprecated_manual_qa_front_matter_field 2026-03-21 21:51:27 +00:00
Timmy
eef0f3ee7d Add clippy to Docker image
Acceptance gates run cargo clippy but the component wasn't installed
in the build stage. Agents were doing real work then failing every
gate check because clippy wasn't available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:43:09 +00:00
Dave
9dc7c21b05 storkit: create 363_story_mcp_tool_for_whatsup_story_triage 2026-03-21 21:29:42 +00:00
Dave
76369de391 storkit: create 362_story_bot_whatsup_command_shows_in_progress_work_summary 2026-03-21 21:26:02 +00:00
Dave
b747cc0fab storkit: create 362_story_bot_whatsup_command_shows_in_progress_work_summary 2026-03-21 21:25:36 +00:00
Dave
f74a0425a9 storkit: create 362_story_bot_whatsup_command_shows_in_progress_work_summary 2026-03-21 21:22:52 +00:00
Dave
b0b21765d9 storkit: create 362_story_bot_whatsup_command_shows_in_progress_work_summary 2026-03-21 21:22:16 +00:00
Timmy
9075bc1a84 Fix tmpfs ownership so storkit user can write to home dir
The tmpfs at /home/storkit defaulted to root ownership (mode=755),
so Claude Code couldn't write ~/.claude.json or ~/.cache/. Set
uid=999,gid=999 to match the storkit user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:19:16 +00:00
Timmy
9f873dc839 Fix Claude Code hanging in hardened container
Claude Code writes to ~/.claude.json, ~/.cache/, and ~/.npm/ which
failed silently on the read-only root filesystem. Add tmpfs at
/home/storkit so the home dir is writable (the claude-state volume
overlays on top for persistent .claude/ data).

Also fix .dockerignore: use **/target/ to match nested target dirs,
add .storkit/logs/ and **/node_modules/ to prevent multi-GB build
context transfers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:16:24 +00:00
Dave
3774c3dca7 storkit: done 359_story_harden_docker_setup_for_security 2026-03-21 20:57:07 +00:00
Timmy
cd095f9a99 Fix rebuild_and_restart in Docker by using cargo output path
Use the known cargo build output path instead of current_exe() when
re-execing after a rebuild. In Docker, the running binary lives at
/usr/local/bin/storkit (read-only) while cargo writes the new binary
to /app/target/release/storkit (a writable volume), so current_exe()
would just restart the old binary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:42:38 +00:00
Timmy
fe0f560b58 Harden Docker container security
Run as non-root user (fixes Claude Code refusing bypassPermissions as
root, which caused all agent spawns to exit instantly with no session).
Add read-only root filesystem, drop all capabilities, set
no-new-privileges, bind port to localhost only, and require
GIT_USER_NAME/GIT_USER_EMAIL env vars at startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:33:50 +00:00
106 changed files with 14534 additions and 9506 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
# Docker build context exclusions
**/target/
**/node_modules/
frontend/dist/
.storkit/worktrees/
.storkit/logs/
.storkit/work/6_archived/
.git/
*.swp
*.swo
.DS_Store

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@
# App specific (root-level; storkit subdirectory patterns live in .storkit/.gitignore)
store.json
.storkit_port
.storkit/bot.toml.bak
# Rust stuff
target

View File

@@ -3,6 +3,6 @@ frontend/
node_modules/
.claude/
.git/
.story_kit/
.storkit/
store.json
.story_kit_port
.storkit_port

3
.storkit/.gitignore vendored
View File

@@ -20,3 +20,6 @@ coverage/
# Token usage log (generated at runtime, contains cost data)
token_usage.jsonl
# Chat service logs
whatsapp_history.json

View File

@@ -228,7 +228,29 @@ If a user hands you this document and says "Apply this process to my project":
---
## 6. Code Quality
## 6. Chat Bot Configuration
Story Kit includes a chat bot that can be connected to one messaging platform at a time. The bot handles commands, LLM conversations, and pipeline notifications.
**Only one transport can be active at a time.** To configure the bot, copy the appropriate example file to `.storkit/bot.toml`:
| Transport | Example file | Webhook endpoint |
|-----------|-------------|-----------------|
| Matrix | `bot.toml.matrix.example` | *(uses Matrix sync, no webhook)* |
| WhatsApp (Meta Cloud API) | `bot.toml.whatsapp-meta.example` | `/webhook/whatsapp` |
| WhatsApp (Twilio) | `bot.toml.whatsapp-twilio.example` | `/webhook/whatsapp` |
| Slack | `bot.toml.slack.example` | `/webhook/slack` |
```bash
cp .storkit/bot.toml.matrix.example .storkit/bot.toml
# Edit bot.toml with your credentials
```
The `bot.toml` file is gitignored (it contains secrets). The example files are checked in for reference.
---
## 7. Code Quality
**MANDATORY:** Before completing Step 3 (Verification) of any story, you MUST run all applicable linters, formatters, and test suites and fix ALL errors and warnings. Zero tolerance for warnings or errors.

View File

@@ -1,61 +0,0 @@
homeserver = "https://matrix.example.com"
username = "@botname:example.com"
password = "your-bot-password"
# List one or more rooms to listen in. Use a single-element list for one room.
room_ids = ["!roomid:example.com"]
# Optional: the deprecated single-room key is still accepted for backwards compat.
# room_id = "!roomid:example.com"
allowed_users = ["@youruser:example.com"]
enabled = false
# Maximum conversation turns to remember per room (default: 20).
# history_size = 20
# Rooms where the bot responds to all messages (not just addressed ones).
# This list is updated automatically when users toggle ambient mode at runtime.
# ambient_rooms = ["!roomid:example.com"]
# ── WhatsApp Business API ──────────────────────────────────────────────
# Set transport = "whatsapp" to use WhatsApp instead of Matrix.
# The webhook endpoint will be available at /webhook/whatsapp.
# You must configure this URL in the Meta Developer Dashboard.
#
# transport = "whatsapp"
# whatsapp_phone_number_id = "123456789012345"
# whatsapp_access_token = "EAAx..."
# whatsapp_verify_token = "my-secret-verify-token"
#
# ── 24-hour messaging window & notification templates ─────────────────
# WhatsApp only allows free-form text messages within 24 hours of the last
# inbound message from a user. For proactive pipeline notifications sent
# after the window expires, an approved Meta message template is used.
#
# Register the template in the Meta Business Manager:
# 1. Go to Business Settings → WhatsApp → Message Templates → Create.
# 2. Category: UTILITY
# 3. Template name: pipeline_notification (or your chosen name below)
# 4. Language: English (en_US)
# 5. Body text (example):
# Story *{{1}}* has moved to *{{2}}*.
# Where {{1}} = story name, {{2}} = pipeline stage.
# 6. Submit for review. Meta typically approves utility templates within
# minutes; transactional categories may take longer.
#
# Once approved, set the name below (default: "pipeline_notification"):
# whatsapp_notification_template = "pipeline_notification"
# ── Slack Bot API ─────────────────────────────────────────────────────
# Set transport = "slack" to use Slack instead of Matrix.
# The webhook endpoint will be available at /webhook/slack.
# Configure this URL in the Slack App → Event Subscriptions → Request URL.
#
# Required Slack App scopes: chat:write, chat:update
# Subscribe to bot events: message.channels, message.groups, message.im
#
# transport = "slack"
# slack_bot_token = "xoxb-..."
# slack_signing_secret = "your-signing-secret"
# slack_channel_ids = ["C01ABCDEF"]

View File

@@ -0,0 +1,26 @@
# Matrix Transport
# Copy this file to bot.toml and fill in your values.
# Only one transport can be active at a time.
enabled = true
transport = "matrix"
homeserver = "https://matrix.example.com"
username = "@botname:example.com"
password = "your-bot-password"
# List one or more rooms to listen in.
room_ids = ["!roomid:example.com"]
# Users allowed to interact with the bot (fail-closed: empty = nobody).
allowed_users = ["@youruser:example.com"]
# Bot display name in chat.
# display_name = "Assistant"
# Maximum conversation turns to remember per room (default: 20).
# history_size = 20
# Rooms where the bot responds to all messages (not just addressed ones).
# This list is updated automatically when users toggle ambient mode at runtime.
# ambient_rooms = ["!roomid:example.com"]

View File

@@ -0,0 +1,23 @@
# Slack Transport
# Copy this file to bot.toml and fill in your values.
# Only one transport can be active at a time.
#
# Setup:
# 1. Create a Slack App at api.slack.com/apps
# 2. Add OAuth scopes: chat:write, chat:update
# 3. Subscribe to bot events: message.channels, message.groups, message.im
# 4. Install the app to your workspace
# 5. Set your webhook URL in Event Subscriptions: https://your-server/webhook/slack
enabled = true
transport = "slack"
slack_bot_token = "xoxb-..."
slack_signing_secret = "your-signing-secret"
slack_channel_ids = ["C01ABCDEF"]
# Bot display name (used in formatted messages).
# display_name = "Assistant"
# Maximum conversation turns to remember per channel (default: 20).
# history_size = 20

View File

@@ -0,0 +1,28 @@
# WhatsApp Transport (Meta Cloud API)
# Copy this file to bot.toml and fill in your values.
# Only one transport can be active at a time.
#
# Setup:
# 1. Create a Meta Business App at developers.facebook.com
# 2. Add the WhatsApp product
# 3. Copy your Phone Number ID and generate a permanent access token
# 4. Register your webhook URL: https://your-server/webhook/whatsapp
# 5. Set the verify token below to match what you configure in Meta's dashboard
enabled = true
transport = "whatsapp"
whatsapp_provider = "meta"
whatsapp_phone_number_id = "123456789012345"
whatsapp_access_token = "EAAx..."
whatsapp_verify_token = "my-secret-verify-token"
# Optional: name of the approved Meta message template used for notifications
# sent outside the 24-hour messaging window (default: "pipeline_notification").
# whatsapp_notification_template = "pipeline_notification"
# Bot display name (used in formatted messages).
# display_name = "Assistant"
# Maximum conversation turns to remember per user (default: 20).
# history_size = 20

View File

@@ -0,0 +1,24 @@
# WhatsApp Transport (Twilio)
# Copy this file to bot.toml and fill in your values.
# Only one transport can be active at a time.
#
# Setup:
# 1. Sign up at twilio.com
# 2. Activate the WhatsApp sandbox (Messaging > Try it out > Send a WhatsApp message)
# 3. Send the sandbox join code from your WhatsApp to the sandbox number
# 4. Copy your Account SID, Auth Token, and sandbox number below
# 5. Set your webhook URL in the Twilio console: https://your-server/webhook/whatsapp
enabled = true
transport = "whatsapp"
whatsapp_provider = "twilio"
twilio_account_sid = "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
twilio_auth_token = "your_auth_token"
twilio_whatsapp_number = "+14155238886"
# Bot display name (used in formatted messages).
# display_name = "Assistant"
# Maximum conversation turns to remember per user (default: 20).
# history_size = 20

View File

@@ -33,7 +33,7 @@ model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "coder-2"
@@ -43,7 +43,7 @@ model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "coder-3"
@@ -53,7 +53,7 @@ model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "qa-2"
@@ -130,7 +130,7 @@ model = "opus"
max_turns = 80
max_budget_usd = 20.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "qa"

View File

@@ -0,0 +1,22 @@
---
name: "WhatsApp transport supports Twilio API as alternative to Meta Cloud API"
---
# Story 382: WhatsApp transport supports Twilio API as alternative to Meta Cloud API
## User Story
As a user, I want to use Twilio's WhatsApp API instead of Meta's Cloud API directly, so that I can avoid Meta's painful developer onboarding and use Twilio's simpler signup process.
## Acceptance Criteria
- [ ] bot.toml supports a `whatsapp_provider` field with values `meta` (default, current behavior) or `twilio`
- [ ] When provider is `twilio`, messages are sent via Twilio's REST API (`api.twilio.com`) using Account SID + Auth Token
- [ ] When provider is `twilio`, inbound webhooks parse Twilio's form-encoded format instead of Meta's JSON
- [ ] Twilio config requires `twilio_account_sid`, `twilio_auth_token`, and `twilio_whatsapp_number` in bot.toml
- [ ] All existing bot commands and LLM passthrough work identically regardless of provider
- [ ] 24-hour messaging window logic still applies (Twilio enforces this server-side too)
## Out of Scope
- TBD

View File

@@ -0,0 +1,41 @@
---
name: "Reorganize chat system into chat module with transport submodules"
---
# Refactor 383: Reorganize chat system into chat module with transport submodules
## Current State
- TBD
## Desired State
Currently chat-related code is scattered at the top level of `src/`: `transport.rs`, `whatsapp.rs`, `slack.rs`, plus `matrix/` as a directory module. This should be reorganized into a clean module hierarchy:
```
src/
chat/
mod.rs # Generic chat traits, types, ChatTransport etc.
transport/
mod.rs
matrix/ # Existing matrix module moved here
whatsapp.rs # Existing whatsapp.rs moved here
slack.rs # Existing slack.rs moved here
twilio.rs # Future Twilio transport
```
The `ChatTransport` trait and shared chat types should live in `chat/mod.rs`. Each transport implementation becomes a submodule of `chat::transport`.
## Acceptance Criteria
- [ ] ChatTransport trait and shared chat types live in `chat/mod.rs`
- [ ] Matrix transport lives in `chat/transport/matrix/`
- [ ] WhatsApp transport lives in `chat/transport/whatsapp.rs`
- [ ] Slack transport lives in `chat/transport/slack.rs`
- [ ] Top-level `transport.rs`, `whatsapp.rs`, `slack.rs`, and `matrix/` are removed
- [ ] All existing tests pass without modification (or with only import path changes)
- [ ] No functional changes — pure file reorganization and re-exports
## Out of Scope
- TBD

View File

@@ -0,0 +1,28 @@
---
name: "Harden Docker setup for security"
retry_count: 3
blocked: true
---
# Story 359: Harden Docker setup for security
## User Story
As a storkit operator, I want the Docker container to run with hardened security settings, so that a compromised agent or malicious codebase cannot escape the container or affect the host.
## Acceptance Criteria
- [ ] Container runs as a non-root user
- [ ] Root filesystem is read-only with only necessary paths writable (e.g. /tmp, cargo cache, claude state volumes)
- [ ] Linux capabilities dropped to minimum required (cap_drop: ALL, add back only what's needed)
- [ ] no-new-privileges flag is set
- [ ] Resource limits (CPU and memory) are configured in docker-compose.yml
- [ ] Outbound network access is restricted where possible
- [ ] ANTHROPIC_API_KEY is passed via Docker secrets or .env file, not hardcoded in compose
- [ ] Image passes a CVE scan with no critical vulnerabilities
- [ ] Port binding uses 127.0.0.1 instead of 0.0.0.0 (e.g. "127.0.0.1:3001:3001") so the web UI is not exposed on all interfaces
- [ ] Git identity is configured via explicit GIT_USER_NAME and GIT_USER_EMAIL env vars; container fails loudly on startup if either is missing (note: multi-user/distributed case where different users need different identities is out of scope and will require a different solution)
## Out of Scope
- TBD

View File

@@ -0,0 +1,20 @@
---
name: "Remove deprecated manual_qa front matter field"
---
# Story 361: Remove deprecated manual_qa front matter field
## User Story
As a developer, I want the deprecated manual_qa boolean field removed from the codebase, so that the front matter schema stays clean and doesn't accumulate legacy boolean flags alongside the more expressive qa: server|agent|human field that replaced it.
## Acceptance Criteria
- [ ] manual_qa field is removed from the FrontMatter and StoryMetadata structs in story_metadata.rs
- [ ] Legacy mapping from manual_qa: true → qa: human is removed
- [ ] Any existing story files using manual_qa are migrated to qa: human
- [ ] Codebase compiles cleanly with no references to manual_qa remaining
## Out of Scope
- TBD

View File

@@ -0,0 +1,28 @@
---
name: "Bot whatsup command shows in-progress work summary"
---
# Story 362: Bot whatsup command shows in-progress work summary
## User Story
As a project owner in a Matrix room, I want to type "{bot_name} whatsup {story_number}" and see a full triage dump for that story, so that when something goes wrong I can immediately understand its state — blocked status, agent activity, git changes, and log tail — without hunting across multiple places or asking the bot to investigate.
## Acceptance Criteria
- [ ] '{bot_name} whatsup {number}' finds the story in work/2_current/ by story number
- [ ] Shows the story number, name, and current pipeline stage
- [ ] Shows relevant front matter fields: blocked, agent, and any other non-empty fields
- [ ] Shows which Acceptance Criteria are checked vs unchecked
- [ ] Shows active branch and worktree path if one exists
- [ ] Shows git diff --stat of changes on the branch since branching from master
- [ ] Shows last 5 commit messages on the feature branch (not master)
- [ ] Shows the last 20 lines of the agent log for this story (if a log exists)
- [ ] Returns a friendly message if the story is not found or not currently in progress
- [ ] Registered in the command registry so it appears in help output
- [ ] Handled at bot level without LLM invocation — uses git, filesystem, and log files only
## Out of Scope
- Interpreting or summarising log output with an LLM
- Showing logs from previous agent runs (only the current/most recent)

View File

@@ -0,0 +1,25 @@
---
name: "MCP tool for whatsup story triage"
---
# Story 363: MCP tool for whatsup story triage
## User Story
As an LLM assistant, I want to call a single MCP tool to get a full triage dump for an in-progress story, so that I can answer status questions quickly without making 8+ separate calls to piece together the picture myself.
## Acceptance Criteria
- [ ] 'whatsup' MCP tool accepts a story_id parameter
- [ ] Returns story front matter fields (name, blocked, agent, and any other non-empty fields)
- [ ] Returns AC checklist with checked/unchecked status
- [ ] Returns active branch and worktree path if one exists
- [ ] Returns git diff --stat of changes on the feature branch since branching from master
- [ ] Returns last 5 commit messages on the feature branch
- [ ] Returns last 20 lines of the most recent agent log for the story
- [ ] Returns a clear error if the story is not found or not in work/2_current/
- [ ] Registered and discoverable via the MCP tools/list endpoint
## Out of Scope
- TBD

View File

@@ -0,0 +1,64 @@
---
name: "Surface API rate limit warnings in chat"
---
# Story 365: Surface API rate limit warnings in chat
## User Story
As a project owner watching the chat, I want to see rate limit warnings surfaced directly in the conversation when they appear in the agent's PTY output, so that I know immediately when an agent is being throttled without having to watch server logs.
## Acceptance Criteria
- [x] Server detects rate limit warnings in pty-debug output lines
- [x] When a rate limit warning is detected, a notification is sent to the active chat (Matrix/Slack/WhatsApp)
- [x] The notification includes which agent/story triggered the rate limit
- [x] Rate limit notifications are debounced to avoid spamming the chat with repeated warnings
## Technical Context
Claude Code emits `rate_limit_event` JSON in its streaming output:
```json
{
"type": "rate_limit_event",
"rate_limit_info": {
"status": "allowed_warning",
"resetsAt": 1774443600,
"rateLimitType": "seven_day",
"utilization": 0.82,
"isUsingOverage": false,
"surpassedThreshold": 0.75
}
}
```
Key fields:
- `status`: `"allowed_warning"` when approaching limit, likely `"blocked"` or similar when hard-limited
- `rateLimitType`: e.g. `"seven_day"` rolling window
- `utilization`: 0.01.0 fraction of limit consumed
- `resetsAt`: Unix timestamp when the window resets
- `surpassedThreshold`: the threshold that triggered the warning (e.g. 0.75 = 75%)
These events are already logged as `[pty-debug] raw line:` in the server logs. The PTY reader in `server/src/llm/providers/claude_code.rs` (line ~234) sees them but doesn't currently parse or act on them.
## Out of Scope
- TBD
## Test Results
<!-- storkit-test-results: {"unit":[{"name":"rate_limit_event_json_sends_watcher_warning","status":"pass","details":"PTY reader detects rate_limit_event JSON and emits RateLimitWarning watcher event"},{"name":"rate_limit_warning_sends_notification_with_agent_and_story","status":"pass","details":"Notification listener sends chat message with agent and story info"},{"name":"rate_limit_warning_is_debounced","status":"pass","details":"Second warning within 60s window is suppressed"},{"name":"rate_limit_warnings_for_different_agents_both_notify","status":"pass","details":"Different agents are debounced independently"},{"name":"format_rate_limit_notification_includes_agent_and_story","status":"pass","details":"Notification text includes story number, name, and agent name"},{"name":"format_rate_limit_notification_falls_back_to_item_id","status":"pass","details":"Falls back to item_id when story name is unavailable"}],"integration":[]} -->
### Unit Tests (6 passed, 0 failed)
- ✅ rate_limit_event_json_sends_watcher_warning — PTY reader detects rate_limit_event JSON and emits RateLimitWarning watcher event
- ✅ rate_limit_warning_sends_notification_with_agent_and_story — Notification listener sends chat message with agent and story info
- ✅ rate_limit_warning_is_debounced — Second warning within 60s window is suppressed
- ✅ rate_limit_warnings_for_different_agents_both_notify — Different agents are debounced independently
- ✅ format_rate_limit_notification_includes_agent_and_story — Notification text includes story number, name, and agent name
- ✅ format_rate_limit_notification_falls_back_to_item_id — Falls back to item_id when story name is unavailable
### Integration Tests (0 passed, 0 failed)
*No integration tests recorded.*

View File

@@ -0,0 +1,20 @@
---
name: "Bot sends shutdown message on server stop or rebuild"
---
# Story 366: Bot sends shutdown message on server stop or rebuild
## User Story
As a project owner in a chat room, I want the bot to send a message when the server is shutting down (via ctrl-c or rebuild_and_restart), so that I know the bot is going offline and won't wonder why it stopped responding.
## Acceptance Criteria
- [ ] Bot sends a shutdown message to active chat channels when the server receives SIGINT/SIGTERM (ctrl-c)
- [ ] Bot sends a shutdown message before rebuild_and_restart kills the current process
- [ ] Message indicates the reason (manual stop vs rebuild)
- [ ] Message is sent best-effort — shutdown is not blocked if the message fails to send
## Out of Scope
- TBD

View File

@@ -0,0 +1,20 @@
---
name: "Rename bot whatsup command to status"
---
# Story 367: Rename bot whatsup command to status
## User Story
As a project owner using the bot from a phone, I want to type "status {number}" instead of "whatsup {number}" to get a story triage dump, because "whatsup" gets autocorrected to "WhatsApp" on mobile keyboards.
## Acceptance Criteria
- [ ] '{bot_name} status {number}' returns the same triage dump that 'whatsup' currently returns
- [ ] The 'whatsup' command is removed or aliased to 'status'
- [ ] Help output shows 'status' as the command name
- [ ] The MCP tool name (whatsup) is unaffected — this only changes the bot command
## Out of Scope
- TBD

View File

@@ -0,0 +1,25 @@
---
name: "Web UI OAuth flow for Claude authentication"
agent: "coder-opus"
---
# Story 368: Web UI OAuth flow for Claude authentication
## User Story
As a new user running storkit in Docker, I want to authenticate Claude through the web UI instead of running `claude login` in a terminal inside the container, so that the entire setup experience stays in the browser after `docker compose up`.
## Acceptance Criteria
- [ ] Backend exposes /auth/start endpoint that generates the Claude OAuth URL with redirect_uri pointing to localhost:3001
- [ ] Backend exposes /auth/callback endpoint that receives the OAuth token and stores it where Claude Code expects it
- [ ] Backend exposes /auth/status endpoint that reports whether valid Claude credentials exist
- [ ] Frontend shows a setup screen when no Claude auth is detected on first visit
- [ ] Setup screen has a 'Connect Claude Account' button that initiates the OAuth flow
- [ ] OAuth redirect returns to the web UI which confirms success and dismisses the setup screen
- [ ] Credentials are persisted in the claude-state Docker volume so they survive container restarts
- [ ] The entire flow works without any terminal interaction after docker compose up
## Out of Scope
- TBD

View File

@@ -0,0 +1,34 @@
---
name: "CLI treats --help and --version as project paths"
---
# Bug 369: CLI treats --help and --version as project paths
## Description
When running `storkit <anything>`, the binary treats the first argument as a project path, creates a directory for it, and scaffolds `.storkit/` inside. This happens for `--help`, `--version`, `serve`, `x`, or any other string. There is no validation that the argument is an existing directory or a reasonable path before creating it.
## How to Reproduce
1. Run `storkit --help` or `storkit serve` or `storkit x` in any directory
2. Observe that a directory with that name is created with a full `.storkit/` scaffold inside it
## Actual Result
Any argument is treated as a project path and a directory is created and scaffolded. No flags are recognised.
## Expected Result
- `storkit --help` prints usage info and exits
- `storkit --version` prints the version and exits
- `storkit <path>` only works if the path already exists as a directory
- If the path does not exist, storkit prints a clear error and exits non-zero
## Acceptance Criteria
- [ ] storkit --help prints usage information and exits with code 0
- [ ] storkit --version prints the version and exits with code 0
- [ ] storkit -h and storkit -V work as short aliases
- [ ] storkit does not create directories for any argument — the path must already exist
- [ ] If the path does not exist, storkit prints a clear error and exits non-zero
- [ ] Arguments starting with - that are not recognised produce a clear error message

View File

@@ -0,0 +1,33 @@
---
name: "Scaffold does not create .mcp.json in project root"
---
# Bug 370: Scaffold does not create .mcp.json in project root
## Description
Two related problems with project setup:
1. When the user clicks the "project setup" button in the web UI to open a new project, the scaffold does not reliably run — the `.storkit/` directory and associated files may not be created.
2. Even when the scaffold does run, it does not write `.mcp.json` to the project root. Without this file, agents spawned in worktrees cannot find the MCP server, causing `--permission-prompt-tool mcp__storkit__prompt_permission not found` errors and agent failures.
## How to Reproduce
1. Open the storkit web UI and use the project setup button to open a new project directory
2. Check whether the full scaffold was created (`.storkit/`, `CLAUDE.md`, `script/test`, etc.)
3. Check the project root for `.mcp.json`
## Actual Result
The scaffold may not run when using the UI project setup flow. When it does run, `.mcp.json` is not created in the project root. Agents fail because MCP tools are unavailable.
## Expected Result
Clicking the project setup button reliably runs the full scaffold, including `.mcp.json` pointing to the server's port.
## Acceptance Criteria
- [ ] The web UI project setup button triggers the full scaffold for new projects
- [ ] scaffold_story_kit writes .mcp.json to the project root with the server's port
- [ ] Existing .mcp.json is not overwritten if already present
- [ ] .mcp.json is included in .gitignore since the port is environment-specific

View File

@@ -0,0 +1,32 @@
---
name: "No-arg storkit in empty directory skips scaffold"
---
# Bug 371: No-arg storkit in empty directory skips scaffold
## Description
When running `storkit` with no path argument from an empty directory (no `.storkit/`), the server starts but never calls `open_project` or the scaffold. The `find_story_kit_root` check fails to find `.storkit/`, so the fallback at main.rs:179-186 just sets `project_root = cwd` without scaffolding. This means no `.storkit/`, no `project.toml`, no `.mcp.json`, no `CLAUDE.md` — the project is non-functional.
The explicit path branch (`storkit .`) works correctly because it calls `open_project``ensure_project_root_with_story_kit``scaffold_story_kit`. The no-arg branch should do the same.
## How to Reproduce
1. Create a new empty directory
2. cd into it
3. Run `storkit` (no path argument)
4. Observe that no scaffold is created — `.storkit/`, `CLAUDE.md`, `.mcp.json`, etc. are all missing
## Actual Result
Server starts with project_root set to cwd but no scaffold runs. The project is non-functional — no agent config, no MCP endpoint, no work pipeline directories.
## Expected Result
Running `storkit` with no arguments from a directory without `.storkit/` should scaffold the project the same as `storkit .` does — calling `open_project` and triggering `ensure_project_root_with_story_kit`.
## Acceptance Criteria
- [ ] Running `storkit` with no args from a dir without `.storkit/` calls `open_project` and triggers the full scaffold
- [ ] The no-arg fallback path in main.rs calls `open_project(cwd)` instead of just setting project_root directly
- [ ] After `storkit` completes startup, `.storkit/project.toml`, `.mcp.json`, `CLAUDE.md`, and `script/test` all exist

View File

@@ -0,0 +1,24 @@
---
name: "Scaffold auto-detects tech stack and configures script/test"
---
# Story 372: Scaffold auto-detects tech stack and configures script/test
## User Story
As a user setting up a new project with storkit, I want the scaffold to detect my project's tech stack and generate a working `script/test` automatically, so that agents can run tests immediately without manual configuration.
## Acceptance Criteria
- [ ] Scaffold detects Go projects (go.mod) and adds `go test ./...` to script/test
- [ ] Scaffold detects Node.js projects (package.json) and adds `npm test` to script/test
- [ ] Scaffold detects Rust projects (Cargo.toml) and adds `cargo test` to script/test
- [ ] Scaffold detects Python projects (pyproject.toml or requirements.txt) and adds `pytest` to script/test
- [ ] Scaffold handles multi-stack projects (e.g. Go + Next.js) by combining the relevant test commands
- [ ] project.toml component entries are generated to match detected tech stack
- [ ] Falls back to the generic 'No tests configured' stub if no known stack is detected
- [ ] Coder agent prompt includes instruction to configure `script/test` for the project's test framework if it still contains the generic stub
## Out of Scope
- TBD

View File

@@ -0,0 +1,28 @@
---
name: "Scaffold gitignore missing transient pipeline stage directories"
---
# Bug 373: Scaffold gitignore missing transient pipeline stage directories
## Description
The `write_story_kit_gitignore` function in `server/src/io/fs.rs` does not include the transient pipeline stages (`work/2_current/`, `work/3_qa/`, `work/4_merge/`) in the `.storkit/.gitignore` entries list. These stages are not committed to git (only `1_backlog`, `5_done`, and `6_archived` are commit-worthy per spike 92), so they should be ignored for new projects.
## How to Reproduce
1. Scaffold a new project with storkit
2. Check `.storkit/.gitignore`
## Actual Result
`.storkit/.gitignore` only contains `bot.toml`, `matrix_store/`, `matrix_device_id`, `worktrees/`, `merge_workspace/`, `coverage/`. The transient pipeline directories are missing.
## Expected Result
`.storkit/.gitignore` also includes `work/2_current/`, `work/3_qa/`, `work/4_merge/`.
## Acceptance Criteria
- [ ] Scaffold writes work/2_current/, work/3_qa/, work/4_merge/ to .storkit/.gitignore
- [ ] Idempotent — running scaffold again does not duplicate entries
- [ ] Existing .storkit/.gitignore files get the new entries appended on next scaffold run

View File

@@ -0,0 +1,30 @@
---
name: "Web UI implements all bot commands as slash commands"
---
# Story 374: Web UI implements all bot commands as slash commands
## User Story
As a user working in the storkit web UI, I want to type slash commands (e.g. `/status`, `/start 42`, `/cost`) in the chat input to trigger the same deterministic bot commands available in Matrix, so that I can manage my project entirely from the browser without needing a chat bot.
## Acceptance Criteria
- [ ] /status — shows pipeline status and agent availability; /status <number> shows story triage dump
- [ ] /assign <number> <model> — pre-assign a model to a story
- [ ] /start <number> — start a coder on a story; /start <number> opus for specific model
- [ ] /show <number> — display full text of a work item
- [ ] /move <number> <stage> — move a work item to a pipeline stage
- [ ] /delete <number> — remove a work item from the pipeline
- [ ] /cost — show token spend (24h total, top stories, by agent type, all-time)
- [ ] /git — show git status (branch, uncommitted changes, ahead/behind)
- [ ] /overview <number> — show implementation summary for a merged story
- [ ] /rebuild — rebuild the server binary and restart
- [ ] /reset — clear the current Claude Code session
- [ ] /help — list all available slash commands
- [ ] Slash commands are handled at the frontend/backend level without LLM invocation
- [ ] Unrecognised slash commands show a helpful error message
## Out of Scope
- TBD

View File

@@ -0,0 +1,43 @@
---
name: "Default project.toml contains Rust-specific setup commands for non-Rust projects"
---
# Bug 375: Default project.toml contains Rust-specific setup commands for non-Rust projects
## Description
When scaffolding a new project where no tech stack is detected, the generated `project.toml` contains Rust-specific setup commands (`cargo check`) as example fallback components. This causes coder agents to try to satisfy Rust gates on non-Rust projects.
## Fix
1. In `detect_components_toml()` fallback (when no stack markers found): replace the Rust/pnpm example components with a single generic `app` component with empty `setup = []`
2. In the onboarding prompt Step 4: simplify to configure `[[component]]` entries based on what the user told the LLM in Step 2 (tech stack), rather than re-scanning the filesystem independently
## Acceptance Criteria
- [ ] Default project.toml does not contain language-specific setup commands when that language is not detected in the project
- [ ] If go.mod is present, setup commands use Go tooling
- [ ] If package.json is present, setup commands use npm/node tooling
- [ ] If no known stack is detected, setup commands are empty or just echo a placeholder
## How to Reproduce
1. Create a new Go + Next.js project directory with `go.mod` and `package.json`
2. Run `storkit .` to scaffold
3. Check `.storkit/project.toml` — the component setup commands reference cargo/Rust
4. Start a coder agent — it creates a `Cargo.toml` trying to satisfy the Rust setup commands
## Actual Result
The scaffolded `project.toml` has Rust-specific setup commands (`cargo check`) even for non-Rust projects. Agents try to satisfy these and create spurious files.
## Expected Result
The scaffolded `project.toml` should have generic or stack-appropriate setup commands. If no known stack is detected, setup commands should be empty or minimal (not Rust-specific).
## Acceptance Criteria
- [ ] Default project.toml does not contain language-specific setup commands when that language is not detected in the project
- [ ] If go.mod is present, setup commands use Go tooling
- [ ] If package.json is present, setup commands use npm/node tooling
- [ ] If no known stack is detected, setup commands are empty or just echo a placeholder

View File

@@ -0,0 +1,22 @@
---
name: "Rename MCP whatsup tool to status for consistency"
agent: coder-opus
---
# Story 376: Rename MCP whatsup tool to status for consistency
## User Story
As a developer using storkit's MCP tools, I want the MCP tool to be called `status` instead of `whatsup`, so that the naming is consistent between the bot command (`status`), the web UI slash command (`/status`), and the MCP tool.
## Acceptance Criteria
- [ ] MCP tool is renamed from 'whatsup' to 'status'
- [ ] MCP tool is discoverable as 'status' via tools/list
- [ ] The tool still accepts a story_id parameter and returns the same triage data
- [ ] Old 'whatsup' tool name is removed from the MCP registry
- [ ] Any internal references to the whatsup tool name are updated
## Out of Scope
- TBD

View File

@@ -0,0 +1,30 @@
---
name: "update_story MCP tool writes front matter values as YAML strings instead of native types"
---
# Bug 377: update_story MCP tool writes front matter values as YAML strings instead of native types
## Description
The `update_story` MCP tool accepts `front_matter` as a `Map<String, String>`, so all values are written as quoted YAML strings. Fields like `retry_count` (expected `u32`) and `blocked` (expected `bool`) end up as `"0"` and `"false"` in the YAML. This causes `parse_front_matter()` to fail because serde_yaml cannot deserialize a quoted string into `u32` or `bool`. When parsing fails, the story `name` comes back as `None`, so the status command shows no title for the story.
## How to Reproduce
1. Call `update_story` with `front_matter: {"blocked": "false", "retry_count": "0"}`
2. Read the story file — front matter contains `blocked: "false"` and `retry_count: "0"` (quoted strings)
3. Call `get_pipeline_status` or the bot `status` command
4. The story shows with no title/name
## Actual Result
Front matter values are written as quoted YAML strings. `parse_front_matter()` fails to deserialize `"false"` as `bool` and `"0"` as `u32`, returning an error. The story name is lost and the status command shows no title.
## Expected Result
The `update_story` tool should write `blocked` and `retry_count` as native YAML types (unquoted `false` and `0`), or `parse_front_matter()` should accept both string and native representations. The story name should always be displayed correctly in the status command.
## Acceptance Criteria
- [ ] update_story with front_matter {"blocked": "false"} writes `blocked: false` (unquoted) in the YAML
- [ ] update_story with front_matter {"retry_count": "0"} writes `retry_count: 0` (unquoted) in the YAML
- [ ] Story name is displayed correctly in the status command after update_story modifies front matter fields

View File

@@ -0,0 +1,20 @@
---
name: "Status command shows work item type (story, bug, spike, refactor) next to each item"
---
# Story 378: Status command shows work item type (story, bug, spike, refactor) next to each item
## User Story
As a user viewing the pipeline status, I want to see the type of each work item (story, bug, spike, refactor) so that I can quickly understand what kind of work is in progress without having to open individual files.
## Acceptance Criteria
- [ ] The status command displays the work item type (story, bug, spike, refactor) as a label next to each item — e.g. "375 [bug] — Default project.toml contains Rust-specific setup commands"
- [ ] The type is extracted from the story_id filename convention ({id}_{type}_{slug})
- [ ] All known types are supported: story, bug, spike, refactor
- [ ] Unknown or missing types are omitted gracefully (no crash, no placeholder)
## Out of Scope
- TBD

View File

@@ -0,0 +1,34 @@
---
name: "start_agent ignores story front matter agent assignment"
---
# Bug 379: start_agent ignores story front matter agent assignment
## Description
When a model is pre-assigned to a story via the `assign` command (which writes `agent: coder-opus` to the story's YAML front matter), the MCP `start_agent` tool ignores this field. It only looks at the `agent_name` argument passed directly in the tool call. If none is passed, it auto-selects the first idle coder (usually sonnet), bypassing the user's assignment.
The auto-assign pipeline (`auto_assign.rs`) correctly reads and respects the front matter `agent` field, but the direct `tool_start_agent` path in `agent_tools.rs` does not.
Additionally, the `show` (whatsup/triage) command should display the assigned agent from the story's front matter so users can verify their assignment took effect.
## How to Reproduce
1. Run `assign 368 opus` — this writes `agent: coder-opus` to story 368's front matter
2. Run `start 368` (without specifying a model)
3. Observe that a sonnet coder is assigned, not coder-opus
4. Run `show 368` — the assigned agent is not displayed
## Actual Result
The `start_agent` MCP tool ignores the `agent` field in the story's front matter and picks the first idle coder. The `show` command does not display the pre-assigned agent.
## Expected Result
When no explicit `agent_name` is passed to `start_agent`, it should read the story's front matter `agent` field and use that agent if it's available. The `show` command should display the assigned agent from front matter.
## Acceptance Criteria
- [ ] start_agent without an explicit agent_name reads the story's front matter `agent` field and uses it if the agent is idle
- [ ] If the preferred agent from front matter is busy, start_agent either waits or falls back to auto-selection (matching auto_assign behavior)
- [ ] The show/triage command displays the assigned agent from story front matter when present

View File

@@ -0,0 +1,20 @@
---
name: "Assign command restarts coder when story is already in progress"
---
# Story 380: Assign command restarts coder when story is already in progress
## User Story
As a user, I want `assign X opus` on a running story to stop the current coder, update the front matter, and start the newly assigned agent, so that I can switch models mid-flight without manually stopping and restarting.
## Acceptance Criteria
- [ ] When assign is called on a story with a running coder, the current coder agent is stopped
- [ ] The story's front matter `agent` field is updated to the new agent name
- [ ] The newly assigned agent is started on the story automatically
- [ ] When assign is called on a story with no running coder, it behaves as before (just updates front matter)
## Out of Scope
- TBD

View File

@@ -0,0 +1,20 @@
---
name: "Bot command to delete a worktree"
---
# Story 381: Bot command to delete a worktree
## User Story
As a user, I want a bot command to delete a worktree so that I can clean up orphaned or unwanted worktrees without SSHing into the server.
## Acceptance Criteria
- [ ] A new bot command (e.g. `rmtree <story_number>`) deletes the worktree for the given story
- [ ] The command stops any running agent on that story before removing the worktree
- [ ] The command returns a confirmation message on success
- [ ] The command returns a helpful error if no worktree exists for the given story
## Out of Scope
- TBD

81
Cargo.lock generated
View File

@@ -1774,9 +1774,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.10"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
dependencies = [
"memchr",
"serde",
@@ -1815,7 +1815,7 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
@@ -1824,9 +1824,31 @@ dependencies = [
[[package]]
name = "jni-sys"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
@@ -1932,9 +1954,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libredox"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"bitflags 2.11.0",
"libc",
@@ -2948,9 +2970,9 @@ dependencies = [
[[package]]
name = "pulldown-cmark"
version = "0.13.1"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6"
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
dependencies = [
"bitflags 2.11.0",
"memchr",
@@ -3252,6 +3274,7 @@ dependencies = [
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
@@ -3625,9 +3648,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
@@ -3801,9 +3824,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.0.4"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
dependencies = [
"serde_core",
]
@@ -3994,7 +4017,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "storkit"
version = "0.4.1"
version = "0.6.0"
dependencies = [
"async-stream",
"async-trait",
@@ -4024,7 +4047,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-tungstenite 0.29.0",
"toml 1.0.7+spec-1.1.0",
"toml 1.1.0+spec-1.1.0",
"uuid",
"wait-timeout",
"walkdir",
@@ -4371,14 +4394,14 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.7+spec-1.1.0"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96"
checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_datetime 1.1.0+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.0",
@@ -4395,39 +4418,39 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.1+spec-1.1.0"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.5+spec-1.1.0"
version = "0.25.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
dependencies = [
"indexmap",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_datetime 1.1.0+spec-1.1.0",
"toml_parser",
"winnow 1.0.0",
]
[[package]]
name = "toml_parser"
version = "1.0.10+spec-1.1.0"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [
"winnow 1.0.0",
]
[[package]]
name = "toml_writer"
version = "1.0.7+spec-1.1.0"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
[[package]]
name = "tower"
@@ -4638,9 +4661,9 @@ dependencies = [
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b"
[[package]]
name = "unicode-xid"

View File

@@ -25,7 +25,7 @@ serde_yaml = "0.9"
strip-ansi-escapes = "0.2"
tempfile = "3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
toml = "1.0.7"
toml = "1.1.0"
uuid = { version = "1.22.0", features = ["v4", "serde"] }
tokio-tungstenite = "0.29.0"
walkdir = "2.5.0"
@@ -35,6 +35,6 @@ matrix-sdk = { version = "0.16.0", default-features = false, features = [
"sqlite",
"e2e-encryption",
] }
pulldown-cmark = { version = "0.13.1", default-features = false, features = [
pulldown-cmark = { version = "0.13.3", default-features = false, features = [
"html",
] }

View File

@@ -1,74 +0,0 @@
# Briefing for Timmy — Spike 329
Hey Timmy. You're running inside a Docker container as part of spike 329. Here's everything
you need to know to pick up where we left off.
## What this spike is
Evaluate running the full storkit stack (server, agents, web UI) inside a single Docker
container, using OrbStack on macOS for better bind-mount performance. The goal is host
isolation — not agent-to-agent isolation. Read the full spike doc at:
`.storkit/work/1_backlog/329_spike_evaluate_docker_orbstack_for_agent_isolation_and_resource_limiting.md`
## What's been done (2026-03-21)
### Environment confirmed
- Debian 12 bookworm, arm64, 10 CPUs
- Rust 1.90.0, Node v22.22.1, git 2.39.5, Claude Code CLI — all present
- Running under **OrbStack** (confirmed via bind-mount path `/run/host_mark/Users → /workspace`)
### Key benchmarks run
Bind-mount directory traversal is **~23x slower per file** than a Docker volume:
| Filesystem | Files | Time |
|---|---|---|
| Docker volume (`cargo/registry`) | 21,703 | 38ms |
| Bind mount `target/` subtree | 270,550 | 10,564ms |
| Bind mount non-target | 50,048 | 11,314ms |
Sequential I/O is fine (440 MB/s write, 1.3 GB/s read on bind mount). The problem is
purely stat-heavy operations — exactly what cargo does on incremental builds.
### Two bugs found and fixed
**Bug 1 — `target/` on bind mount** (`docker/docker-compose.yml`)
Added named Docker volumes to keep build artifacts off the slow bind mount:
```yaml
- workspace-target:/workspace/target
- storkit-target:/app/target
```
**Bug 2 — missing `build-essential` in runtime stage** (`docker/Dockerfile`)
The runtime stage copies the Rust toolchain but not `gcc`/`cc`. `cargo build` fails with
`linker 'cc' not found`. Fixed by adding `build-essential`, `pkg-config`, `libssl-dev`
to the runtime apt-get block.
### `./..:/app` bind mount
The original commit had this commented out. Another bot uncommented it — this is correct.
It lets `rebuild_and_restart` pick up live host changes. The `storkit-target:/app/target`
volume keeps `/app/target` off the bind mount.
## What still needs doing
1. **Rebuild the image** with the patched Dockerfile and run a full `cargo build --release`
benchmark end-to-end. This couldn't be done in the first session because the container
was already running the old (pre-fix) image.
2. **Docker Desktop vs OrbStack comparison** — repeat the benchmarks with Docker Desktop
to quantify the performance delta. We expect OrbStack to be significantly faster due to
VirtioFS vs gRPC-FUSE, but need actual numbers.
## Worktree git note
The worktree git refs are broken inside the container — they reference the host path
(`/Users/dave/workspace/...`) which doesn't exist in the container. Use
`git -C /workspace <command>` instead of running git from the worktree dir.
## Files changed so far (uncommitted)
- `docker/Dockerfile` — added `build-essential`, `pkg-config`, `libssl-dev` to runtime stage
- `docker/docker-compose.yml` — added `workspace-target` and `storkit-target` volumes
- `.storkit/work/1_backlog/329_spike_...md` — findings written up in full
These changes are **not yet committed**. Commit them before rebuilding the container.

View File

@@ -1,8 +1,9 @@
# Docker build context exclusions
target/
frontend/node_modules/
**/target/
**/node_modules/
frontend/dist/
.storkit/worktrees/
.storkit/logs/
.storkit/work/6_archived/
.git/
*.swp

View File

@@ -9,6 +9,9 @@
FROM rust:1.90-bookworm AS base
# Clippy is needed at runtime for acceptance gates (cargo clippy)
RUN rustup component add clippy
# ── System deps ──────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
@@ -33,10 +36,6 @@ RUN curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /usr/local/bin
# The CLI binary is `claude`.
RUN npm install -g @anthropic-ai/claude-code
# ── Biome (frontend linter) ─────────────────────────────────────────
# Installed project-locally via npm install, but having it global avoids
# needing node_modules for CI-style checks.
# ── Working directory ────────────────────────────────────────────────
# /app holds the storkit source (copied in at build time for the binary).
# /workspace is where the target project repo gets bind-mounted at runtime.
@@ -68,6 +67,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libssl-dev \
# procps provides ps, needed by tests and process management
procps \
&& rm -rf /var/lib/apt/lists/*
# Node.js in runtime
@@ -98,6 +99,24 @@ COPY --from=base /usr/local/bin/storkit /usr/local/bin/storkit
# Alternative: mount the source as a volume.
COPY --from=base /app /app
# ── Non-root user ────────────────────────────────────────────────────
# Claude Code refuses --dangerously-skip-permissions (bypassPermissions)
# when running as root. Create a dedicated user so agents can launch.
RUN groupadd -r storkit \
&& useradd -r -g storkit -m -d /home/storkit storkit \
&& mkdir -p /home/storkit/.claude \
&& chown -R storkit:storkit /home/storkit \
&& chown -R storkit:storkit /usr/local/cargo /usr/local/rustup \
&& chown -R storkit:storkit /app \
&& mkdir -p /workspace/target /app/target \
&& chown storkit:storkit /workspace/target /app/target
# ── Entrypoint ───────────────────────────────────────────────────────
# Validates required env vars (GIT_USER_NAME, GIT_USER_EMAIL) and
# configures git identity before starting the server.
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
USER storkit
WORKDIR /workspace
# ── Ports ────────────────────────────────────────────────────────────
@@ -106,10 +125,8 @@ EXPOSE 3001
# ── Volumes (defined in docker-compose.yml) ──────────────────────────
# /workspace bind mount: target project repo
# /root/.claude named volume: Claude Code sessions/state
# /home/storkit/.claude named volume: Claude Code sessions/state
# /usr/local/cargo/registry named volume: cargo dependency cache
# ── Entrypoint ───────────────────────────────────────────────────────
# Run storkit against the bind-mounted project at /workspace.
# The server picks up ANTHROPIC_API_KEY from the environment.
ENTRYPOINT ["entrypoint.sh"]
CMD ["storkit", "/workspace"]

View File

@@ -16,13 +16,19 @@ services:
dockerfile: docker/Dockerfile
container_name: storkit
ports:
# Web UI + MCP endpoint
- "3001:3001"
# Bind to localhost only — not exposed on all interfaces.
- "127.0.0.1:3001:3001"
environment:
# Required: Anthropic API key for Claude Code agents
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:?Set ANTHROPIC_API_KEY}
# Optional: Anthropic API key. If unset, Claude Code falls back to
# OAuth credentials from `claude login` (e.g. Max subscription).
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
# Required: git identity for agent commits
- GIT_USER_NAME=${GIT_USER_NAME:?Set GIT_USER_NAME}
- GIT_USER_EMAIL=${GIT_USER_EMAIL:?Set GIT_USER_EMAIL}
# Optional: override the server port (default 3001)
- STORKIT_PORT=3001
# Bind to all interfaces so Docker port forwarding works.
- STORKIT_HOST=0.0.0.0
# Optional: Matrix bot credentials (if using Matrix integration)
- MATRIX_HOMESERVER=${MATRIX_HOMESERVER:-}
- MATRIX_USER=${MATRIX_USER:-}
@@ -45,7 +51,7 @@ services:
# Claude Code state persists session history, projects config,
# and conversation transcripts so --resume works across restarts.
- claude-state:/root/.claude
- claude-state:/home/storkit/.claude
# Storkit source tree for rebuild_and_restart.
# The binary has CARGO_MANIFEST_DIR baked in at compile time
@@ -63,16 +69,37 @@ services:
- workspace-target:/workspace/target
- storkit-target:/app/target
# ── Security hardening ──────────────────────────────────────────
# Read-only root filesystem. Only explicitly mounted volumes and
# tmpfs paths are writable.
read_only: true
tmpfs:
- /tmp:size=512M,exec
- /home/storkit:size=512M,uid=999,gid=999,exec
# Drop all Linux capabilities, then add back only what's needed.
# SETUID/SETGID needed by Claude Code's PTY allocation (openpty).
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
# Prevent child processes from gaining new privileges via setuid,
# setgid, or other mechanisms.
security_opt:
- no-new-privileges:true
# Resource limits cap the whole system.
# Adjust based on your machine. These are conservative defaults.
deploy:
resources:
limits:
cpus: "4"
memory: 8G
cpus: "8"
memory: 24G
reservations:
cpus: "1"
memory: 2G
cpus: "2"
memory: 4G
# Health check verify the MCP endpoint responds
healthcheck:

34
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/sh
set -e
# ── Git identity ─────────────────────────────────────────────────────
# Agents commit code inside the container. Without a git identity,
# commits fail or use garbage defaults. Fail loudly at startup so the
# operator knows immediately.
if [ -z "$GIT_USER_NAME" ]; then
echo "FATAL: GIT_USER_NAME is not set. Export it in your environment or docker-compose.yml." >&2
exit 1
fi
if [ -z "$GIT_USER_EMAIL" ]; then
echo "FATAL: GIT_USER_EMAIL is not set. Export it in your environment or docker-compose.yml." >&2
exit 1
fi
# Use GIT_AUTHOR/COMMITTER env vars instead of git config --global,
# so the root filesystem can stay read-only (no ~/.gitconfig write).
export GIT_AUTHOR_NAME="$GIT_USER_NAME"
export GIT_COMMITTER_NAME="$GIT_USER_NAME"
export GIT_AUTHOR_EMAIL="$GIT_USER_EMAIL"
export GIT_COMMITTER_EMAIL="$GIT_USER_EMAIL"
# ── Frontend native deps ────────────────────────────────────────────
# The project repo is bind-mounted from the host, so node_modules/
# may contain native binaries for the wrong platform (e.g. darwin
# binaries on a Linux container). Reinstall to get the right ones.
if [ -d /workspace/frontend ] && [ -f /workspace/frontend/package.json ]; then
echo "Installing frontend dependencies for container platform..."
cd /workspace/frontend && npm install --prefer-offline 2>/dev/null || true
cd /workspace
fi
exec "$@"

View File

@@ -1,12 +1,12 @@
{
"name": "living-spec-standalone",
"version": "0.4.1",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "living-spec-standalone",
"version": "0.4.1",
"version": "0.6.0",
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0",

View File

@@ -1,7 +1,7 @@
{
"name": "living-spec-standalone",
"private": true,
"version": "0.4.1",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,6 +1,6 @@
import { defineConfig } from "@playwright/test";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "@playwright/test";
const configDir = dirname(fileURLToPath(new URL(import.meta.url)));
const frontendRoot = resolve(configDir, ".");

View File

@@ -382,6 +382,14 @@ export const api = {
deleteStory(storyId: string) {
return callMcpTool("delete_story", { story_id: storyId });
},
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
botCommand(command: string, args: string, baseUrl?: string) {
return requestJson<{ response: string }>(
"/bot/command",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
};
async function callMcpTool(

View File

@@ -40,6 +40,7 @@ vi.mock("../api/client", () => {
setAnthropicApiKey: vi.fn(),
readFile: vi.fn(),
listProjectFiles: vi.fn(),
botCommand: vi.fn(),
};
class ChatWebSocket {
connect(handlers: WsHandlers) {
@@ -64,6 +65,7 @@ const mockedApi = {
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
readFile: vi.mocked(api.readFile),
listProjectFiles: vi.mocked(api.listProjectFiles),
botCommand: vi.mocked(api.botCommand),
};
function setupMocks() {
@@ -76,6 +78,7 @@ function setupMocks() {
mockedApi.listProjectFiles.mockResolvedValue([]);
mockedApi.cancelChat.mockResolvedValue(true);
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
}
describe("Default provider selection (Story 206)", () => {
@@ -1457,3 +1460,204 @@ describe("File reference expansion (Story 269 AC4)", () => {
expect(mockedApi.readFile).not.toHaveBeenCalled();
});
});
describe("Slash command handling (Story 374)", () => {
beforeEach(() => {
capturedWsHandlers = null;
lastSendChatArgs = null;
setupMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("AC: /status calls botCommand and displays response", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/status" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"status",
"",
undefined,
);
});
expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
// Should NOT go to LLM
expect(lastSendChatArgs).toBeNull();
});
it("AC: /status <number> passes args to botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/status 42" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"status",
"42",
undefined,
);
});
});
it("AC: /start <number> calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/start 42 opus" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"start",
"42 opus",
undefined,
);
});
expect(await screen.findByText("Started agent")).toBeInTheDocument();
});
it("AC: /git calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "On branch main" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/git" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined);
});
});
it("AC: /cost calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/cost" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined);
});
});
it("AC: /reset clears messages and session without LLM", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
// First add a message so there is history to clear
act(() => {
capturedWsHandlers?.onUpdate([
{ role: "user", content: "hello" },
{ role: "assistant", content: "world" },
]);
});
expect(await screen.findByText("world")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/reset" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// LLM must NOT be invoked
expect(lastSendChatArgs).toBeNull();
// botCommand must NOT be invoked (reset is frontend-only)
expect(mockedApi.botCommand).not.toHaveBeenCalled();
// Confirmation message should appear
expect(await screen.findByText(/Session reset/)).toBeInTheDocument();
});
it("AC: unrecognised slash command shows error message", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/foobar" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(await screen.findByText(/Unknown command/)).toBeInTheDocument();
// Should NOT go to LLM
expect(lastSendChatArgs).toBeNull();
// Should NOT call botCommand
expect(mockedApi.botCommand).not.toHaveBeenCalled();
});
it("AC: /help shows help overlay", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/help" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(await screen.findByTestId("help-overlay")).toBeInTheDocument();
expect(lastSendChatArgs).toBeNull();
expect(mockedApi.botCommand).not.toHaveBeenCalled();
});
it("AC: botCommand API error shows error message in chat", async () => {
mockedApi.botCommand.mockRejectedValue(new Error("Server error"));
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/git" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(
await screen.findByText(/Error running command/),
).toBeInTheDocument();
});
});

View File

@@ -612,6 +612,80 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
return;
}
// /reset — clear session and message history without LLM
if (/^\/reset\s*$/i.test(messageText)) {
setMessages([]);
setClaudeSessionId(null);
setStreamingContent("");
setStreamingThinking("");
setActivityStatus(null);
setMessages([
{
role: "assistant",
content: "Session reset. Starting a fresh conversation.",
},
]);
return;
}
// Slash commands forwarded to the backend bot command endpoint
const slashMatch = messageText.match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
if (slashMatch) {
const cmd = slashMatch[1].toLowerCase();
const args = (slashMatch[2] ?? "").trim();
// Ignore commands handled elsewhere
if (cmd !== "btw") {
const knownCommands = new Set([
"status",
"assign",
"start",
"show",
"move",
"delete",
"cost",
"git",
"overview",
"rebuild",
]);
if (knownCommands.has(cmd)) {
// Show the slash command in chat as a user message (display only)
setMessages((prev: Message[]) => [
...prev,
{ role: "user", content: messageText },
]);
try {
const result = await api.botCommand(cmd, args, undefined);
setMessages((prev: Message[]) => [
...prev,
{ role: "assistant", content: result.response },
]);
} catch (e) {
setMessages((prev: Message[]) => [
...prev,
{
role: "assistant",
content: `**Error running command:** ${e}`,
},
]);
}
return;
}
// Unknown slash command
setMessages((prev: Message[]) => [
...prev,
{ role: "user", content: messageText },
{
role: "assistant",
content: `Unknown command: \`/${cmd}\`. Type \`/help\` to see available commands.`,
},
]);
return;
}
}
// /btw <question> — answered from context without disrupting main chat
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
if (btwMatch) {

View File

@@ -12,6 +12,57 @@ const SLASH_COMMANDS: SlashCommand[] = [
name: "/help",
description: "Show this list of available slash commands.",
},
{
name: "/status",
description:
"Show pipeline status and agent availability. `/status <number>` shows a story triage dump.",
},
{
name: "/assign <number> <model>",
description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
},
{
name: "/start <number>",
description:
"Start a coder on a story. Optionally specify a model: `/start <number> opus`.",
},
{
name: "/show <number>",
description: "Display the full text of a work item.",
},
{
name: "/move <number> <stage>",
description:
"Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
},
{
name: "/delete <number>",
description:
"Remove a work item from the pipeline and stop any running agent.",
},
{
name: "/cost",
description:
"Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
},
{
name: "/git",
description:
"Show git status: branch, uncommitted changes, and ahead/behind remote.",
},
{
name: "/overview <number>",
description: "Show the implementation summary for a merged story.",
},
{
name: "/rebuild",
description: "Rebuild the server binary and restart.",
},
{
name: "/reset",
description:
"Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
},
{
name: "/btw <question>",
description:

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "storkit",
"name": "workspace",
"lockfileVersion": 3,
"requires": true,
"packages": {}

View File

@@ -147,9 +147,65 @@ else
| sed 's/^/- /')
fi
# ── Generate summary overview ─────────────────────────────────
# Group completed items by keyword clusters to identify the
# release's focus areas.
generate_summary() {
local all_items="$1"
local themes=""
# Count items matching each theme keyword (one item per line via echo -e)
local expanded
expanded=$(echo -e "$all_items")
local bot_count=$(echo "$expanded" | grep -icE 'bot|command|chat|matrix|slack|whatsapp|status|help|assign|rebuild|shutdown|whatsup' || true)
local mcp_count=$(echo "$expanded" | grep -icE 'mcp|tool' || true)
local docker_count=$(echo "$expanded" | grep -icE 'docker|container|gvisor|orbstack|harden|security' || true)
local agent_count=$(echo "$expanded" | grep -icE 'agent|runtime|chatgpt|gemini|openai|model|coder' || true)
local ui_count=$(echo "$expanded" | grep -icE 'frontend|ui|web|oauth|scaffold' || true)
local infra_count=$(echo "$expanded" | grep -icE 'release|makefile|refactor|upgrade|worktree|pipeline' || true)
# Build theme list, highest count first
local -a theme_pairs=()
[ "$agent_count" -gt 0 ] && theme_pairs+=("${agent_count}:multi-model agents")
[ "$bot_count" -gt 0 ] && theme_pairs+=("${bot_count}:bot commands")
[ "$mcp_count" -gt 0 ] && theme_pairs+=("${mcp_count}:MCP tools")
[ "$docker_count" -gt 0 ] && theme_pairs+=("${docker_count}:Docker hardening")
[ "$ui_count" -gt 0 ] && theme_pairs+=("${ui_count}:developer experience")
[ "$infra_count" -gt 0 ] && theme_pairs+=("${infra_count}:infrastructure")
# Sort by count descending, take top 3
local sorted=$(printf '%s\n' "${theme_pairs[@]}" | sort -t: -k1 -nr | head -3)
local labels=""
while IFS=: read -r count label; do
[ -z "$label" ] && continue
if [ -z "$labels" ]; then
# Capitalise first theme
labels="$(echo "${label:0:1}" | tr '[:lower:]' '[:upper:]')${label:1}"
else
labels="${labels}, ${label}"
fi
done <<< "$sorted"
echo "$labels"
}
ALL_ITEMS="${FEATURES}${FIXES}${REFACTORS}"
SUMMARY=$(generate_summary "$ALL_ITEMS")
if [ -n "$SUMMARY" ]; then
SUMMARY_LINE="**Focus:** ${SUMMARY}"
else
SUMMARY_LINE=""
fi
# Assemble the release body.
RELEASE_BODY="## What's Changed"
if [ -n "$SUMMARY_LINE" ]; then
RELEASE_BODY="${RELEASE_BODY}
${SUMMARY_LINE}"
fi
if [ -n "$FEATURES" ]; then
RELEASE_BODY="${RELEASE_BODY}

1
serve

Submodule serve deleted from 1ec5c08ae7

View File

@@ -1,6 +1,6 @@
[package]
name = "storkit"
version = "0.4.1"
version = "0.6.0"
edition = "2024"
build = "build.rs"
@@ -18,7 +18,7 @@ notify = { workspace = true }
poem = { workspace = true, features = ["websocket"] }
poem-openapi = { workspace = true, features = ["swagger-ui"] }
portable-pty = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
reqwest = { workspace = true, features = ["json", "stream", "form"] }
rust-embed = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@@ -102,13 +102,29 @@ fn run_command_with_timeout(
args: &[&str],
dir: &Path,
) -> Result<(bool, String), String> {
let mut child = Command::new(program)
.args(args)
// On Linux, execve can return ETXTBSY (26) briefly after a file is written
// before the kernel releases its "write open" state. Retry once after a
// short pause to handle this race condition.
let mut last_err = None;
let mut cmd = Command::new(&program);
cmd.args(args)
.current_dir(dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn command: {e}"))?;
.stderr(std::process::Stdio::piped());
let mut child = loop {
match cmd.spawn() {
Ok(c) => break c,
Err(e) if e.raw_os_error() == Some(26) => {
// ETXTBSY — wait briefly and retry once
if last_err.is_some() {
return Err(format!("Failed to spawn command: {e}"));
}
last_err = Some(e);
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => return Err(format!("Failed to spawn command: {e}")),
}
};
// Drain stdout/stderr in background threads so the pipe buffers never fill.
let stdout_handle = child.stdout.take().map(|r| {
@@ -254,9 +270,8 @@ mod tests {
fn run_project_tests_uses_script_test_when_present_and_passes() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
@@ -276,9 +291,8 @@ mod tests {
fn run_project_tests_reports_failure_when_script_test_exits_nonzero() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
@@ -313,9 +327,8 @@ mod tests {
fn coverage_gate_passes_when_script_exits_zero() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
@@ -342,9 +355,8 @@ mod tests {
fn coverage_gate_fails_when_script_exits_nonzero() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();

View File

@@ -144,6 +144,10 @@ impl AgentPool {
}
}
pub fn port(&self) -> u16 {
self.port
}
/// Create a pool with a dummy watcher channel for unit tests.
#[cfg(test)]
pub fn new_test(port: u16) -> Self {
@@ -249,6 +253,24 @@ impl AgentPool {
}
}
// Read the preferred agent from the story's front matter before acquiring
// the lock. When no explicit agent_name is given, this lets start_agent
// honour `agent: coder-opus` written by the `assign` command — mirroring
// the auto_assign path (bug 379).
let front_matter_agent: Option<String> = if agent_name.is_none() {
find_active_story_stage(project_root, story_id).and_then(|stage_dir| {
let path = project_root
.join(".storkit")
.join("work")
.join(stage_dir)
.join(format!("{story_id}.md"));
let contents = std::fs::read_to_string(path).ok()?;
crate::io::story_metadata::parse_front_matter(&contents).ok()?.agent
})
} else {
None
};
// Atomically resolve agent name, check availability, and register as
// Pending. When `agent_name` is `None` the first idle coder is
// selected inside the lock so no TOCTOU race can occur between the
@@ -264,7 +286,32 @@ impl AgentPool {
resolved_name = match agent_name {
Some(name) => name.to_string(),
None => auto_assign::find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder)
None => {
// Honour the `agent:` field in the story's front matter so that
// `start 368` after `assign 368 opus` picks the right agent
// (bug 379). Mirrors the auto_assign selection logic.
if let Some(ref pref) = front_matter_agent {
let stage_matches = config
.find_agent(pref)
.map(|cfg| agent_config_stage(cfg) == PipelineStage::Coder)
.unwrap_or(false);
if stage_matches {
if auto_assign::is_agent_free(&agents, pref) {
pref.clone()
} else {
return Err(format!(
"Preferred agent '{pref}' from story front matter is busy; \
story '{story_id}' has been queued in work/2_current/ and will \
be auto-assigned when it becomes available"
));
}
} else {
// Stage mismatch — fall back to any free coder.
auto_assign::find_free_agent_for_stage(
&config,
&agents,
&PipelineStage::Coder,
)
.map(|s| s.to_string())
.ok_or_else(|| {
if config
@@ -281,7 +328,33 @@ impl AgentPool {
"No coder agent configured. Specify an agent_name explicitly."
.to_string()
}
})?,
})?
}
} else {
auto_assign::find_free_agent_for_stage(
&config,
&agents,
&PipelineStage::Coder,
)
.map(|s| s.to_string())
.ok_or_else(|| {
if config
.agent
.iter()
.any(|a| agent_config_stage(a) == PipelineStage::Coder)
{
format!(
"All coder agents are busy; story '{story_id}' has been \
queued in work/2_current/ and will be auto-assigned when \
one becomes available"
)
} else {
"No coder agent configured. Specify an agent_name explicitly."
.to_string()
}
})?
}
}
};
key = composite_key(story_id, &resolved_name);
@@ -522,7 +595,7 @@ impl AgentPool {
let run_result = match runtime_name {
"claude-code" => {
let runtime = ClaudeCodeRuntime::new(child_killers_clone.clone());
let runtime = ClaudeCodeRuntime::new(child_killers_clone.clone(), watcher_tx_clone.clone());
let ctx = RuntimeContext {
story_id: sid.clone(),
agent_name: aname.clone(),
@@ -1101,6 +1174,7 @@ mod tests {
use crate::agents::{AgentEvent, AgentStatus, PipelineStage};
use crate::config::ProjectConfig;
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use std::process::Command;
fn make_config(toml_str: &str) -> ProjectConfig {
ProjectConfig::parse(toml_str).unwrap()
@@ -1187,13 +1261,10 @@ mod tests {
/// Returns true if a process with the given PID is currently running.
fn process_is_running(pid: u32) -> bool {
std::process::Command::new("ps")
.arg("-p")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
Command::new("ps")
.args(["-p", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
@@ -2194,6 +2265,108 @@ stage = "coder"
assert_eq!(agents.len(), 1, "existing agents should not be affected");
}
// ── front matter agent preference (bug 379) ──────────────────────────────
#[tokio::test]
async fn start_agent_honours_front_matter_agent_when_idle() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
let backlog = sk.join("work/1_backlog");
std::fs::create_dir_all(&backlog).unwrap();
std::fs::write(
sk.join("project.toml"),
r#"
[[agent]]
name = "coder-sonnet"
stage = "coder"
[[agent]]
name = "coder-opus"
stage = "coder"
"#,
)
.unwrap();
// Story file with agent preference in front matter.
std::fs::write(
backlog.join("368_story_test.md"),
"---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n",
)
.unwrap();
let pool = AgentPool::new_test(3010);
// coder-sonnet is busy so without front matter the auto-selection
// would skip coder-opus and try something else.
pool.inject_test_agent("other-story", "coder-sonnet", AgentStatus::Running);
let result = pool
.start_agent(tmp.path(), "368_story_test", None, None)
.await;
match result {
Ok(info) => {
assert_eq!(
info.agent_name, "coder-opus",
"should pick the front-matter preferred agent"
);
}
Err(err) => {
// Allowed to fail for infrastructure reasons (no git repo),
// but NOT due to agent selection ignoring the preference.
assert!(
!err.contains("All coder agents are busy"),
"should not report busy when coder-opus is idle: {err}"
);
assert!(
!err.contains("coder-sonnet"),
"should not have picked coder-sonnet: {err}"
);
}
}
}
#[tokio::test]
async fn start_agent_returns_error_when_front_matter_agent_busy() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
let backlog = sk.join("work/1_backlog");
std::fs::create_dir_all(&backlog).unwrap();
std::fs::write(
sk.join("project.toml"),
r#"
[[agent]]
name = "coder-sonnet"
stage = "coder"
[[agent]]
name = "coder-opus"
stage = "coder"
"#,
)
.unwrap();
std::fs::write(
backlog.join("368_story_test.md"),
"---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n",
)
.unwrap();
let pool = AgentPool::new_test(3011);
// Preferred agent is busy — should NOT fall back to coder-sonnet.
pool.inject_test_agent("other-story", "coder-opus", AgentStatus::Running);
let result = pool
.start_agent(tmp.path(), "368_story_test", None, None)
.await;
assert!(result.is_err(), "expected error when preferred agent is busy");
let err = result.unwrap_err();
assert!(
err.contains("coder-opus"),
"error should mention the preferred agent: {err}"
);
assert!(
err.contains("busy") || err.contains("queued"),
"error should say agent is busy or story is queued: {err}"
);
}
// ── archive + cleanup integration test ───────────────────────────────────
#[tokio::test]

View File

@@ -7,6 +7,7 @@ use tokio::sync::broadcast;
use super::{AgentEvent, TokenUsage};
use crate::agent_log::AgentLogWriter;
use crate::io::watcher::WatcherEvent;
use crate::slog;
use crate::slog_warn;
@@ -47,6 +48,7 @@ pub(in crate::agents) async fn run_agent_pty_streaming(
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
inactivity_timeout_secs: u64,
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
watcher_tx: broadcast::Sender<WatcherEvent>,
) -> Result<PtyResult, String> {
let sid = story_id.to_string();
let aname = agent_name.to_string();
@@ -70,6 +72,7 @@ pub(in crate::agents) async fn run_agent_pty_streaming(
log_writer.as_deref(),
inactivity_timeout_secs,
&child_killers,
&watcher_tx,
)
})
.await
@@ -162,6 +165,7 @@ fn run_agent_pty_blocking(
log_writer: Option<&Mutex<AgentLogWriter>>,
inactivity_timeout_secs: u64,
child_killers: &Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
watcher_tx: &broadcast::Sender<WatcherEvent>,
) -> Result<PtyResult, String> {
let pty_system = native_pty_system();
@@ -342,6 +346,15 @@ fn run_agent_pty_blocking(
// because thinking and text already arrived via stream_event.
// The raw JSON is still forwarded as AgentJson below.
"assistant" | "user" => {}
"rate_limit_event" => {
slog!(
"[agent:{story_id}:{agent_name}] API rate limit warning received"
);
let _ = watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
});
}
"result" => {
// Extract token usage from the result event.
if let Some(usage) = TokenUsage::from_result_event(&json) {
@@ -390,6 +403,70 @@ fn run_agent_pty_blocking(
mod tests {
use super::*;
use crate::agents::AgentEvent;
use crate::io::watcher::WatcherEvent;
use std::collections::HashMap;
use std::sync::Arc;
// ── AC1: pty detects rate_limit_event and emits RateLimitWarning ─────────
/// Verify that when a `rate_limit_event` JSON line appears in PTY output,
/// `run_agent_pty_streaming` sends a `WatcherEvent::RateLimitWarning` with
/// the correct story_id and agent_name.
///
/// The command invoked is: `sh -p -- <script>` where `--` terminates
/// option parsing so the script path is treated as the operand.
#[tokio::test]
async fn rate_limit_event_json_sends_watcher_warning() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let script = tmp.path().join("emit_rate_limit.sh");
std::fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' '{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed_warning\"}}'\n",
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
let (watcher_tx, mut watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let event_log = Arc::new(Mutex::new(Vec::new()));
let child_killers = Arc::new(Mutex::new(HashMap::new()));
// sh -p "--" <script>: -p = privileged mode, "--" = end options,
// then the script path is the file operand.
let result = run_agent_pty_streaming(
"365_story_test",
"coder-1",
"sh",
&[script.to_string_lossy().to_string()],
"--",
"/tmp",
&tx,
&event_log,
None,
0,
child_killers,
watcher_tx,
)
.await;
assert!(result.is_ok(), "PTY run should succeed: {:?}", result.err());
let evt = watcher_rx
.try_recv()
.expect("Expected a RateLimitWarning to be sent on watcher_tx");
match evt {
WatcherEvent::RateLimitWarning {
story_id,
agent_name,
} => {
assert_eq!(story_id, "365_story_test");
assert_eq!(agent_name, "coder-1");
}
other => panic!("Expected RateLimitWarning, got: {other:?}"),
}
}
#[test]
fn test_emit_event_writes_to_log_writer() {

View File

@@ -5,6 +5,7 @@ use portable_pty::ChildKiller;
use tokio::sync::broadcast;
use crate::agent_log::AgentLogWriter;
use crate::io::watcher::WatcherEvent;
use super::{AgentEvent, AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
@@ -15,13 +16,18 @@ use super::{AgentEvent, AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStat
/// token tracking, and inactivity timeout behaviour.
pub struct ClaudeCodeRuntime {
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
watcher_tx: broadcast::Sender<WatcherEvent>,
}
impl ClaudeCodeRuntime {
pub fn new(
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
watcher_tx: broadcast::Sender<WatcherEvent>,
) -> Self {
Self { child_killers }
Self {
child_killers,
watcher_tx,
}
}
}
@@ -45,6 +51,7 @@ impl AgentRuntime for ClaudeCodeRuntime {
log_writer,
ctx.inactivity_timeout_secs,
Arc::clone(&self.child_killers),
self.watcher_tx.clone(),
)
.await?;

View File

@@ -144,16 +144,20 @@ mod tests {
#[test]
fn claude_code_runtime_get_status_returns_idle() {
use std::collections::HashMap;
use crate::io::watcher::WatcherEvent;
let killers = Arc::new(Mutex::new(HashMap::new()));
let runtime = ClaudeCodeRuntime::new(killers);
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
assert_eq!(runtime.get_status(), RuntimeStatus::Idle);
}
#[test]
fn claude_code_runtime_stream_events_empty() {
use std::collections::HashMap;
use crate::io::watcher::WatcherEvent;
let killers = Arc::new(Mutex::new(HashMap::new()));
let runtime = ClaudeCodeRuntime::new(killers);
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
assert!(runtime.stream_events().is_empty());
}
}

View File

@@ -4,6 +4,8 @@
//! sending and editing messages, allowing the bot logic (commands, htop,
//! notifications) to work against any chat platform — Matrix, WhatsApp, etc.
pub mod transport;
use async_trait::async_trait;
/// A platform-agnostic identifier for a sent message.
@@ -13,9 +15,6 @@ use async_trait::async_trait;
/// producing and consuming these identifiers.
pub type MessageId = String;
/// A platform-agnostic identifier for a chat room / channel / conversation.
pub type RoomId = String;
/// Abstraction over a chat platform's message-sending capabilities.
///
/// Implementations must be `Send + Sync` so they can be shared across
@@ -65,11 +64,11 @@ mod tests {
#[test]
fn whatsapp_transport_satisfies_trait() {
fn assert_transport<T: ChatTransport>() {}
assert_transport::<crate::whatsapp::WhatsAppTransport>();
assert_transport::<crate::chat::transport::whatsapp::WhatsAppTransport>();
// Verify it can be wrapped in Arc<dyn ChatTransport>.
let _: Arc<dyn ChatTransport> =
Arc::new(crate::whatsapp::WhatsAppTransport::new(
Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
@@ -81,7 +80,7 @@ mod tests {
#[test]
fn matrix_transport_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<crate::matrix::transport_impl::MatrixTransport>();
assert_send_sync::<crate::chat::transport::matrix::transport_impl::MatrixTransport>();
}
/// Verify that SlackTransport satisfies the ChatTransport trait and
@@ -89,9 +88,24 @@ mod tests {
#[test]
fn slack_transport_satisfies_trait() {
fn assert_transport<T: ChatTransport>() {}
assert_transport::<crate::slack::SlackTransport>();
assert_transport::<crate::chat::transport::slack::SlackTransport>();
let _: Arc<dyn ChatTransport> =
Arc::new(crate::slack::SlackTransport::new("xoxb-test".to_string()));
Arc::new(crate::chat::transport::slack::SlackTransport::new("xoxb-test".to_string()));
}
/// Verify that TwilioWhatsAppTransport satisfies the ChatTransport trait
/// and can be used as `Arc<dyn ChatTransport>` (compile-time check).
#[test]
fn twilio_transport_satisfies_trait() {
fn assert_transport<T: ChatTransport>() {}
assert_transport::<crate::chat::transport::whatsapp::TwilioWhatsAppTransport>();
let _: Arc<dyn ChatTransport> =
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
"ACtest".to_string(),
"authtoken".to_string(),
"+14155551234".to_string(),
));
}
}

View File

@@ -0,0 +1,537 @@
//! Assign command: pre-assign or re-assign a coder model to a story.
//!
//! `{bot_name} assign {number} {model}` finds the story by number, updates the
//! `agent` field in its front matter, and — when a coder is already running on
//! the story — stops the current coder and starts the newly-assigned one.
//!
//! When no coder is running (the story has not been started yet), the command
//! behaves as before: it simply persists the assignment in the front matter so
//! that the next `start` invocation picks it up automatically.
use crate::agents::{AgentPool, AgentStatus};
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field};
use std::path::Path;
/// All pipeline stage directories to search when finding a work item by number.
const STAGES: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
/// A parsed assign command from a Matrix message body.
#[derive(Debug, PartialEq)]
pub enum AssignCommand {
/// Assign the story with this number to the given model.
Assign {
story_number: String,
model: String,
},
/// The user typed `assign` but without valid arguments.
BadArgs,
}
/// Parse an assign command from a raw Matrix message body.
///
/// Strips the bot mention prefix and checks whether the first word is `assign`.
/// Returns `None` when the message is not an assign command at all.
pub fn extract_assign_command(
message: &str,
bot_name: &str,
bot_user_id: &str,
) -> Option<AssignCommand> {
let stripped = strip_mention(message, bot_name, bot_user_id);
let trimmed = stripped
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
if !cmd.eq_ignore_ascii_case("assign") {
return None;
}
// Split args into story number and model.
let (number_str, model_str) = match args.split_once(char::is_whitespace) {
Some((n, m)) => (n.trim(), m.trim()),
None => (args, ""),
};
if number_str.is_empty()
|| !number_str.chars().all(|c| c.is_ascii_digit())
|| model_str.is_empty()
{
return Some(AssignCommand::BadArgs);
}
Some(AssignCommand::Assign {
story_number: number_str.to_string(),
model: model_str.to_string(),
})
}
/// Resolve a model name hint (e.g. `"opus"`) to a full agent name
/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`,
/// it is returned unchanged to prevent double-prefixing.
pub fn resolve_agent_name(model: &str) -> String {
if model.starts_with("coder-") {
model.to_string()
} else {
format!("coder-{model}")
}
}
/// Handle an assign command asynchronously.
///
/// Finds the work item by `story_number` across all pipeline stages, updates
/// the `agent` field in its front matter, and — if a coder is currently
/// running on the story — stops it and starts the newly-assigned agent.
/// Returns a markdown-formatted response string.
pub async fn handle_assign(
bot_name: &str,
story_number: &str,
model_str: &str,
project_root: &Path,
agents: &AgentPool,
) -> String {
// Find the story file across all pipeline stages.
let mut found: Option<(std::path::PathBuf, String)> = None;
'outer: for stage in STAGES {
let dir = project_root.join(".storkit").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())
.map(|s| s.to_string())
{
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("")
.to_string();
if file_num == story_number {
found = Some((path, stem));
break 'outer;
}
}
}
}
}
let (path, story_id) = match found {
Some(f) => f,
None => {
return format!(
"No story, bug, or spike with number **{story_number}** found."
);
}
};
// Read the human-readable name from front matter for the response.
let story_name = std::fs::read_to_string(&path)
.ok()
.and_then(|contents| {
parse_front_matter(&contents)
.ok()
.and_then(|m| m.name)
})
.unwrap_or_else(|| story_id.clone());
let agent_name = resolve_agent_name(model_str);
// Write `agent: <agent_name>` into the story's front matter.
let write_result = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read story file: {e}"))
.and_then(|contents| {
let updated = set_front_matter_field(&contents, "agent", &agent_name);
std::fs::write(&path, &updated)
.map_err(|e| format!("Failed to write story file: {e}"))
});
if let Err(e) = write_result {
return format!("Failed to assign model to **{story_name}**: {e}");
}
// Check whether a coder is already running on this story.
let running_coders: Vec<_> = agents
.list_agents()
.unwrap_or_default()
.into_iter()
.filter(|a| {
a.story_id == story_id
&& a.agent_name.starts_with("coder")
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
})
.collect();
if running_coders.is_empty() {
// No coder running — just persist the assignment.
return format!(
"Assigned **{agent_name}** to **{story_name}** (story {story_number}). \
The model will be used when the story starts."
);
}
// Stop each running coder, then start the newly assigned one.
let stopped: Vec<String> = running_coders
.iter()
.map(|a| a.agent_name.clone())
.collect();
for coder in &running_coders {
if let Err(e) = agents
.stop_agent(project_root, &story_id, &coder.agent_name)
.await
{
crate::slog!(
"[matrix-bot] assign: failed to stop agent {} for {}: {e}",
coder.agent_name,
story_id
);
}
}
crate::slog!(
"[matrix-bot] assign (bot={bot_name}): stopped {:?} for {}; starting {agent_name}",
stopped,
story_id
);
match agents
.start_agent(project_root, &story_id, Some(&agent_name), None)
.await
{
Ok(info) => {
format!(
"Reassigned **{story_name}** (story {story_number}): \
stopped **{}** and started **{}**.",
stopped.join(", "),
info.agent_name
)
}
Err(e) => {
format!(
"Assigned **{agent_name}** to **{story_name}** (story {story_number}): \
stopped **{}** but failed to start the new agent: {e}",
stopped.join(", ")
)
}
}
}
/// Strip the bot mention prefix from a raw Matrix message body.
///
/// Mirrors the logic in `commands::strip_bot_mention` and `start::strip_mention`.
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
_ => Some(rest),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- extract_assign_command -----------------------------------------------
#[test]
fn extract_with_full_user_id() {
let cmd = extract_assign_command(
"@timmy:home.local assign 42 opus",
"Timmy",
"@timmy:home.local",
);
assert_eq!(
cmd,
Some(AssignCommand::Assign {
story_number: "42".to_string(),
model: "opus".to_string()
})
);
}
#[test]
fn extract_with_display_name() {
let cmd = extract_assign_command("Timmy assign 42 sonnet", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(AssignCommand::Assign {
story_number: "42".to_string(),
model: "sonnet".to_string()
})
);
}
#[test]
fn extract_with_localpart() {
let cmd = extract_assign_command("@timmy assign 7 opus", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(AssignCommand::Assign {
story_number: "7".to_string(),
model: "opus".to_string()
})
);
}
#[test]
fn extract_case_insensitive_command() {
let cmd = extract_assign_command("Timmy ASSIGN 99 opus", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(AssignCommand::Assign {
story_number: "99".to_string(),
model: "opus".to_string()
})
);
}
#[test]
fn extract_no_args_is_bad_args() {
let cmd = extract_assign_command("Timmy assign", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(AssignCommand::BadArgs));
}
#[test]
fn extract_missing_model_is_bad_args() {
let cmd = extract_assign_command("Timmy assign 42", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(AssignCommand::BadArgs));
}
#[test]
fn extract_non_numeric_number_is_bad_args() {
let cmd = extract_assign_command("Timmy assign abc opus", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(AssignCommand::BadArgs));
}
#[test]
fn extract_non_assign_command_returns_none() {
let cmd = extract_assign_command("Timmy help", "Timmy", "@timmy:home.local");
assert_eq!(cmd, None);
}
// -- resolve_agent_name --------------------------------------------------
#[test]
fn resolve_agent_name_prefixes_bare_model() {
assert_eq!(resolve_agent_name("opus"), "coder-opus");
assert_eq!(resolve_agent_name("sonnet"), "coder-sonnet");
assert_eq!(resolve_agent_name("haiku"), "coder-haiku");
}
#[test]
fn resolve_agent_name_does_not_double_prefix() {
assert_eq!(resolve_agent_name("coder-opus"), "coder-opus");
assert_eq!(resolve_agent_name("coder-sonnet"), "coder-sonnet");
}
// -- handle_assign (no running coder) ------------------------------------
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
#[tokio::test]
async fn handle_assign_returns_not_found_for_unknown_number() {
let tmp = tempfile::tempdir().unwrap();
for stage in STAGES {
std::fs::create_dir_all(tmp.path().join(".storkit/work").join(stage)).unwrap();
}
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
let response = handle_assign("Timmy", "999", "opus", tmp.path(), &agents).await;
assert!(
response.contains("No story") && response.contains("999"),
"unexpected response: {response}"
);
}
#[tokio::test]
async fn handle_assign_writes_front_matter_when_no_coder_running() {
let tmp = tempfile::tempdir().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"42_story_test.md",
"---\nname: Test Feature\n---\n\n# Story 42\n",
);
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
let response = handle_assign("Timmy", "42", "opus", tmp.path(), &agents).await;
assert!(
response.contains("coder-opus"),
"response should mention agent: {response}"
);
assert!(
response.contains("Test Feature"),
"response should mention story name: {response}"
);
// Should say "will be used when the story starts" (no restart)
assert!(
response.contains("start"),
"response should indicate assignment for future start: {response}"
);
let contents = std::fs::read_to_string(
tmp.path().join(".storkit/work/1_backlog/42_story_test.md"),
)
.unwrap();
assert!(
contents.contains("agent: coder-opus"),
"front matter should contain agent field: {contents}"
);
}
#[tokio::test]
async fn handle_assign_with_already_prefixed_name_does_not_double_prefix() {
let tmp = tempfile::tempdir().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"7_story_small.md",
"---\nname: Small Story\n---\n",
);
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
let response = handle_assign("Timmy", "7", "coder-opus", tmp.path(), &agents).await;
assert!(
response.contains("coder-opus"),
"should not double-prefix: {response}"
);
assert!(
!response.contains("coder-coder-opus"),
"must not double-prefix: {response}"
);
let contents = std::fs::read_to_string(
tmp.path().join(".storkit/work/1_backlog/7_story_small.md"),
)
.unwrap();
assert!(
contents.contains("agent: coder-opus"),
"must write coder-opus, not coder-coder-opus: {contents}"
);
}
#[tokio::test]
async fn handle_assign_overwrites_existing_agent_field() {
let tmp = tempfile::tempdir().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"5_story_existing.md",
"---\nname: Existing\nagent: coder-sonnet\n---\n",
);
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
handle_assign("Timmy", "5", "opus", tmp.path(), &agents).await;
let contents = std::fs::read_to_string(
tmp.path().join(".storkit/work/1_backlog/5_story_existing.md"),
)
.unwrap();
assert!(
contents.contains("agent: coder-opus"),
"should overwrite old agent: {contents}"
);
assert!(
!contents.contains("coder-sonnet"),
"old agent should no longer appear: {contents}"
);
}
#[tokio::test]
async fn handle_assign_finds_story_in_any_stage() {
let tmp = tempfile::tempdir().unwrap();
write_story_file(
tmp.path(),
"3_qa",
"99_story_in_qa.md",
"---\nname: In QA\n---\n",
);
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
let response = handle_assign("Timmy", "99", "opus", tmp.path(), &agents).await;
assert!(
response.contains("coder-opus"),
"should find story in qa stage: {response}"
);
}
// -- handle_assign (with running coder) ----------------------------------
#[tokio::test]
async fn handle_assign_stops_running_coder_and_reports_reassignment() {
let tmp = tempfile::tempdir().unwrap();
write_story_file(
tmp.path(),
"2_current",
"10_story_current.md",
"---\nname: Current Story\nagent: coder-sonnet\n---\n",
);
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
// Inject a running coder for this story.
agents.inject_test_agent("10_story_current", "coder-sonnet", AgentStatus::Running);
let response = handle_assign("Timmy", "10", "opus", tmp.path(), &agents).await;
// The response should mention both stopped and started agents.
assert!(
response.contains("coder-sonnet"),
"response should mention the stopped agent: {response}"
);
// Should indicate a restart occurred (not just "will be used when starts")
assert!(
response.to_lowercase().contains("stop") || response.to_lowercase().contains("reassign"),
"response should indicate stop/reassign: {response}"
);
}
}

View File

@@ -2,7 +2,7 @@ use crate::agents::AgentPool;
use crate::http::context::{PermissionDecision, PermissionForward};
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use crate::slog;
use crate::transport::ChatTransport;
use crate::chat::ChatTransport;
use matrix_sdk::{
Client,
config::SyncSettings,
@@ -213,10 +213,11 @@ pub async fn run_bot(
watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
agents: Arc<AgentPool>,
shutdown_rx: tokio::sync::watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
) -> Result<(), String> {
let store_path = project_root.join(".storkit").join("matrix_store");
let client = Client::builder()
.homeserver_url(&config.homeserver)
.homeserver_url(config.homeserver.as_deref().unwrap_or_default())
.sqlite_store(&store_path, None)
.build()
.await
@@ -231,7 +232,10 @@ pub async fn run_bot(
let mut login_builder = client
.matrix_auth()
.login_username(&config.username, &config.password)
.login_username(
config.username.as_deref().unwrap_or_default(),
config.password.as_deref().unwrap_or_default(),
)
.initial_device_display_name("Storkit Bot");
if let Some(ref device_id) = saved_device_id {
@@ -264,8 +268,10 @@ pub async fn run_bot(
{
use matrix_sdk::ruma::api::client::uiaa;
let password_auth = uiaa::AuthData::Password(uiaa::Password::new(
uiaa::UserIdentifier::UserIdOrLocalpart(config.username.clone()),
config.password.clone(),
uiaa::UserIdentifier::UserIdOrLocalpart(
config.username.clone().unwrap_or_default(),
),
config.password.clone().unwrap_or_default(),
));
if let Err(e) = client
.encryption()
@@ -368,8 +374,16 @@ pub async fn run_bot(
// Create the transport abstraction based on the configured transport type.
let transport: Arc<dyn ChatTransport> = match config.transport.as_str() {
"whatsapp" => {
slog!("[matrix-bot] Using WhatsApp transport");
Arc::new(crate::whatsapp::WhatsAppTransport::new(
if config.whatsapp_provider == "twilio" {
slog!("[matrix-bot] Using WhatsApp/Twilio transport");
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
config.twilio_account_sid.clone().unwrap_or_default(),
config.twilio_auth_token.clone().unwrap_or_default(),
config.twilio_whatsapp_number.clone().unwrap_or_default(),
))
} else {
slog!("[matrix-bot] Using WhatsApp/Meta transport");
Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
config.whatsapp_phone_number_id.clone().unwrap_or_default(),
config.whatsapp_access_token.clone().unwrap_or_default(),
config
@@ -378,6 +392,7 @@ pub async fn run_bot(
.unwrap_or_else(|| "pipeline_notification".to_string()),
))
}
}
_ => {
slog!("[matrix-bot] Using Matrix transport");
Arc::new(super::transport_impl::MatrixTransport::new(client.clone()))
@@ -426,6 +441,30 @@ pub async fn run_bot(
notif_project_root,
);
// Spawn a shutdown watcher that sends a best-effort goodbye message to all
// configured rooms when the server is about to stop (SIGINT/SIGTERM or rebuild).
{
let shutdown_transport = Arc::clone(&transport);
let shutdown_rooms: Vec<String> =
announce_room_ids.iter().map(|r| r.to_string()).collect();
let shutdown_bot_name = announce_bot_name.clone();
let mut rx = shutdown_rx;
tokio::spawn(async move {
// Wait until the channel holds Some(reason).
if rx.wait_for(|v| v.is_some()).await.is_ok() {
let reason = rx.borrow().clone();
let notifier = crate::rebuild::BotShutdownNotifier::new(
shutdown_transport,
shutdown_rooms,
shutdown_bot_name,
);
if let Some(r) = reason {
notifier.notify(r).await;
}
}
});
}
// Send a startup announcement to each configured room so users know the
// bot is online. This runs once per process start — the sync loop handles
// reconnects internally so this code is never reached again on a network
@@ -836,6 +875,46 @@ async fn on_room_message(
return;
}
// Check for the assign command, which requires async agent ops (stop +
// start) and cannot be handled by the sync command registry.
if let Some(assign_cmd) = super::assign::extract_assign_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
) {
let response = match assign_cmd {
super::assign::AssignCommand::Assign {
story_number,
model,
} => {
slog!(
"[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}"
);
super::assign::handle_assign(
&ctx.bot_name,
&story_number,
&model,
&ctx.project_root,
&ctx.agents,
)
.await
}
super::assign::AssignCommand::BadArgs => {
format!(
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
ctx.bot_name
)
}
};
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
return;
}
// Check for the htop command, which requires async Matrix access (Room)
// and cannot be handled by the sync command registry.
if let Some(htop_cmd) =
@@ -894,6 +973,39 @@ async fn on_room_message(
return;
}
// Check for the rmtree command, which requires async agent/worktree ops
// and cannot be handled by the sync command registry.
if let Some(rmtree_cmd) = super::rmtree::extract_rmtree_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
) {
let response = match rmtree_cmd {
super::rmtree::RmtreeCommand::Rmtree { story_number } => {
slog!(
"[matrix-bot] Handling rmtree command from {sender}: story {story_number}"
);
super::rmtree::handle_rmtree(
&ctx.bot_name,
&story_number,
&ctx.project_root,
&ctx.agents,
)
.await
}
super::rmtree::RmtreeCommand::BadArgs => {
format!("Usage: `{} rmtree <number>`", ctx.bot_name)
}
};
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
return;
}
// Check for the start command, which requires async agent ops and cannot
// be handled by the sync command registry.
if let Some(start_cmd) = super::start::extract_start_command(
@@ -1505,7 +1617,7 @@ mod tests {
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
agents: Arc::new(AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
transport: Arc::new(crate::whatsapp::WhatsAppTransport::new("test-phone".to_string(), "test-token".to_string(), "pipeline_notification".to_string())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new("test-phone".to_string(), "test-token".to_string(), "pipeline_notification".to_string())),
};
// Clone must work (required by Matrix SDK event handler injection).
let _cloned = ctx.clone();

View File

@@ -1,7 +1,7 @@
//! Handler for the `ambient` command.
use super::CommandContext;
use crate::matrix::config::save_ambient_rooms;
use crate::chat::transport::matrix::config::save_ambient_rooms;
/// Toggle ambient mode for this room.
///

View File

@@ -0,0 +1,57 @@
//! Handler stub for the `assign` command.
//!
//! The real implementation lives in `crate::chat::transport::matrix::assign` (async). This
//! stub exists only so that `assign` appears in the help registry — the
//! handler always returns `None` so the bot's message loop falls through to
//! the async handler in `bot.rs`.
use super::CommandContext;
pub(super) fn handle_assign(_ctx: &CommandContext) -> Option<String> {
// Handled asynchronously in bot.rs / crate::chat::transport::matrix::assign.
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
// -- registration / help ------------------------------------------------
#[test]
fn assign_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "assign");
assert!(found, "assign command must be in the registry");
}
#[test]
fn assign_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("assign"),
"help should list assign command: {output}"
);
}
#[test]
fn assign_command_falls_through_to_none_in_registry() {
// The assign handler in the registry returns None (handled async in bot.rs).
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy assign 42 opus",
);
assert!(
result.is_none(),
"assign should not produce a sync response (handled async): {result:?}"
);
}
}

View File

@@ -14,6 +14,7 @@ mod move_story;
mod overview;
mod show;
mod status;
mod triage;
use crate::agents::AgentPool;
use std::collections::HashSet;
@@ -39,7 +40,7 @@ pub struct BotCommand {
/// message body.
///
/// All identifiers are platform-agnostic strings so this struct works with
/// any [`ChatTransport`](crate::transport::ChatTransport) implementation.
/// any [`ChatTransport`](crate::chat::ChatTransport) implementation.
pub struct CommandDispatch<'a> {
/// The bot's display name (e.g., "Timmy").
pub bot_name: &'a str,
@@ -88,7 +89,7 @@ pub fn commands() -> &'static [BotCommand] {
},
BotCommand {
name: "status",
description: "Show pipeline status and agent availability",
description: "Show pipeline status and agent availability; or `status <number>` for a story triage dump",
handler: status::handle_status,
},
BotCommand {
@@ -136,6 +137,11 @@ pub fn commands() -> &'static [BotCommand] {
description: "Remove a work item from the pipeline: `delete <number>`",
handler: handle_delete_fallback,
},
BotCommand {
name: "rmtree",
description: "Delete the worktree for a story without removing it from the pipeline: `rmtree <number>`",
handler: handle_rmtree_fallback,
},
BotCommand {
name: "reset",
description: "Clear the current Claude Code session and start fresh",
@@ -251,6 +257,16 @@ fn handle_start_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `rmtree` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// rmtree is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "rmtree" as a prompt.
fn handle_rmtree_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `delete` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// delete is detected and handled before `try_handle_command` is invoked.

View File

@@ -7,28 +7,45 @@ use std::collections::{HashMap, HashSet};
use super::CommandContext;
pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
if ctx.args.trim().is_empty() {
Some(build_pipeline_status(ctx.project_root, ctx.agents))
} else {
super::triage::handle_triage(ctx)
}
}
/// Format a short display label for a work item.
///
/// Extracts the leading numeric ID from the file stem (e.g. `"293"` from
/// `"293_story_register_all_bot_commands"`) and combines it with the human-
/// readable name from the front matter when available.
/// Extracts the leading numeric ID and optional type tag from the file stem
/// (e.g. `"293"` and `"story"` from `"293_story_register_all_bot_commands"`)
/// and combines them with the human-readable name from the front matter when
/// available. Known types (`story`, `bug`, `spike`, `refactor`) are shown as
/// bracketed labels; unknown or missing types are omitted silently.
///
/// Examples:
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 — Register all bot commands"`
/// - `("293_story_foo", None)` → `"293"`
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 [story] — Register all bot commands"`
/// - `("375_bug_foo", None)` → `"375 [bug]"`
/// - `("293_story_foo", None)` → `"293 [story]"`
/// - `("no_number_here", None)` → `"no_number_here"`
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
let number = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(stem);
match name {
Some(n) => format!("{number}{n}"),
let mut parts = stem.splitn(3, '_');
let first = parts.next().unwrap_or(stem);
let (number, type_label) = if !first.is_empty() && first.chars().all(|c| c.is_ascii_digit()) {
let t = parts.next().and_then(|t| match t {
"story" | "bug" | "spike" | "refactor" => Some(t),
_ => None,
});
(first, t)
} else {
(stem, None)
};
let prefix = match type_label {
Some(t) => format!("{number} [{t}]"),
None => number.to_string(),
};
match name {
Some(n) => format!("{prefix}{n}"),
None => prefix,
}
}
@@ -196,13 +213,13 @@ mod tests {
#[test]
fn short_label_extracts_number_and_name() {
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
assert_eq!(label, "293 — Register all bot commands");
assert_eq!(label, "293 [story] — Register all bot commands");
}
#[test]
fn short_label_number_only_when_no_name() {
let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
assert_eq!(label, "297");
assert_eq!(label, "297 [story]");
}
#[test]
@@ -220,6 +237,37 @@ mod tests {
);
}
#[test]
fn short_label_shows_bug_type() {
let label = story_short_label("375_bug_default_project_toml", Some("Default project.toml issue"));
assert_eq!(label, "375 [bug] — Default project.toml issue");
}
#[test]
fn short_label_shows_spike_type() {
let label = story_short_label("61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture"));
assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
}
#[test]
fn short_label_shows_refactor_type() {
let label = story_short_label("260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys"));
assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
}
#[test]
fn short_label_omits_unknown_type() {
let label = story_short_label("42_task_do_something", Some("Do something"));
assert_eq!(label, "42 — Do something");
}
#[test]
fn short_label_no_type_when_only_id() {
// Stem with only a numeric ID and no type segment
let label = story_short_label("42", Some("Some item"));
assert_eq!(label, "42 — Some item");
}
// -- build_pipeline_status formatting -----------------------------------
#[test]
@@ -244,8 +292,8 @@ mod tests {
"output must not show full filename stem: {output}"
);
assert!(
output.contains("293 — Register all bot commands"),
"output must show number and title: {output}"
output.contains("293 [story] — Register all bot commands"),
"output must show number, type, and title: {output}"
);
}
@@ -284,7 +332,7 @@ mod tests {
let output = build_pipeline_status(tmp.path(), &agents);
assert!(
output.contains("293 — Register all bot commands — $0.29"),
output.contains("293 [story] — Register all bot commands — $0.29"),
"output must show cost next to story: {output}"
);
}
@@ -347,7 +395,7 @@ mod tests {
let output = build_pipeline_status(tmp.path(), &agents);
assert!(
output.contains("293 — Register all bot commands — $0.29"),
output.contains("293 [story] — Register all bot commands — $0.29"),
"output must show aggregated cost: {output}"
);
}

View File

@@ -0,0 +1,548 @@
//! Handler for the story triage dump subcommand of `status`.
//!
//! Produces a triage dump for a story that is currently in-progress
//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state,
//! git diff, recent commits, and the tail of the agent log.
//!
//! The command is handled entirely at the bot level — no LLM invocation.
use super::CommandContext;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Handle `{bot_name} status {number}`.
pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} status <number>`\n\nShows a triage dump for a story currently in progress.",
ctx.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} status <number>`",
ctx.bot_name
));
}
let current_dir = ctx
.project_root
.join(".storkit")
.join("work")
.join("2_current");
match find_story_in_dir(&current_dir, num_str) {
Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)),
None => Some(format!(
"Story **{num_str}** is not currently in progress (not found in `work/2_current/`)."
)),
}
}
/// Find a `.md` file whose numeric prefix matches `num_str` in `dir`.
///
/// Returns `(path, file_stem)` for the first match.
fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> {
let entries = std::fs::read_dir(dir).ok()?;
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 Some((path.clone(), stem.to_string()));
}
}
}
None
}
/// Build the full triage dump for a story.
fn build_triage_dump(
ctx: &CommandContext,
story_path: &Path,
story_id: &str,
num_str: &str,
) -> String {
let contents = match std::fs::read_to_string(story_path) {
Ok(c) => c,
Err(e) => return format!("Failed to read story {num_str}: {e}"),
};
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)");
let mut out = String::new();
// ---- Header ----
out.push_str(&format!("## Story {num_str}{name}\n"));
out.push_str("**Stage:** In Progress (`2_current`)\n\n");
// ---- Front matter fields ----
if let Some(ref m) = meta {
let mut fields: Vec<String> = Vec::new();
if let Some(true) = m.blocked {
fields.push("**blocked:** true".to_string());
}
if let Some(ref agent) = m.agent {
fields.push(format!("**agent:** {agent}"));
}
if let Some(ref qa) = m.qa {
fields.push(format!("**qa:** {qa}"));
}
if let Some(true) = m.review_hold {
fields.push("**review_hold:** true".to_string());
}
if let Some(rc) = m.retry_count
&& rc > 0
{
fields.push(format!("**retry_count:** {rc}"));
}
if let Some(ref cb) = m.coverage_baseline {
fields.push(format!("**coverage_baseline:** {cb}"));
}
if let Some(ref mf) = m.merge_failure {
fields.push(format!("**merge_failure:** {mf}"));
}
if !fields.is_empty() {
out.push_str("**Front matter:**\n");
for f in &fields {
out.push_str(&format!("{f}\n"));
}
out.push('\n');
}
}
// ---- Acceptance criteria ----
let criteria = parse_acceptance_criteria(&contents);
if !criteria.is_empty() {
out.push_str("**Acceptance Criteria:**\n");
for (checked, text) in &criteria {
let mark = if *checked { "" } else { "" };
out.push_str(&format!(" {mark} {text}\n"));
}
let total = criteria.len();
let done = criteria.iter().filter(|(c, _)| *c).count();
out.push_str(&format!(" *{done}/{total} complete*\n"));
out.push('\n');
}
// ---- Worktree and branch ----
let wt_path = crate::worktree::worktree_path(ctx.project_root, story_id);
let branch = format!("feature/story-{story_id}");
if wt_path.is_dir() {
out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display()));
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
// ---- git diff --stat ----
let diff_stat = run_git(
&wt_path,
&["diff", "--stat", "master...HEAD"],
);
if !diff_stat.is_empty() {
out.push_str("**Diff stat (vs master):**\n```\n");
out.push_str(&diff_stat);
out.push_str("```\n\n");
} else {
out.push_str("**Diff stat (vs master):** *(no changes)*\n\n");
}
// ---- Last 5 commits on feature branch ----
let log = run_git(
&wt_path,
&[
"log",
"master..HEAD",
"--pretty=format:%h %s",
"-5",
],
);
if !log.is_empty() {
out.push_str("**Recent commits (branch only):**\n```\n");
out.push_str(&log);
out.push_str("\n```\n\n");
} else {
out.push_str("**Recent commits (branch only):** *(none yet)*\n\n");
}
} else {
out.push_str(&format!("**Branch:** `{branch}`\n"));
out.push_str("**Worktree:** *(not yet created)*\n\n");
}
// ---- Agent log tail ----
let log_dir = ctx
.project_root
.join(".storkit")
.join("logs")
.join(story_id);
match latest_log_file(&log_dir) {
Some(log_path) => {
let tail = read_log_tail(&log_path, 20);
let filename = log_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("agent.log");
if tail.is_empty() {
out.push_str(&format!("**Agent log** (`{filename}`):** *(empty)*\n"));
} else {
out.push_str(&format!("**Agent log tail** (`{filename}`):\n```\n"));
out.push_str(&tail);
out.push_str("\n```\n");
}
}
None => {
out.push_str("**Agent log:** *(no log found)*\n");
}
}
out
}
/// Parse acceptance criteria from story markdown.
///
/// Returns a list of `(checked, text)` for every `- [ ] ...` and `- [x] ...` line.
fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
contents
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
Some((true, text.to_string()))
} else {
trimmed.strip_prefix("- [ ] ").map(|text| (false, text.to_string()))
}
})
.collect()
}
/// Run a git command in the given directory, returning trimmed stdout (or empty on error).
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()
}
/// Find the most recently modified `.log` file in the given directory,
/// regardless of agent name.
fn latest_log_file(log_dir: &Path) -> Option<PathBuf> {
if !log_dir.is_dir() {
return None;
}
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
for entry in std::fs::read_dir(log_dir).ok()?.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("log") {
continue;
}
let modified = match entry.metadata().and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => continue,
};
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
best = Some((path, modified));
}
}
best.map(|(p, _)| p)
}
/// Read the last `n` non-empty lines from a file as a single string.
fn read_log_tail(path: &Path, n: usize) -> String {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
};
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
// ---------------------------------------------------------------------------
// 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 status_triage_cmd(root: &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 status {args}"))
}
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
// -- registration -------------------------------------------------------
#[test]
fn whatsup_command_is_not_registered() {
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
assert!(!found, "whatsup command must not be in the registry (renamed to status)");
}
#[test]
fn status_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("status"),
"help should list status command: {output}"
);
}
// -- input validation ---------------------------------------------------
#[test]
fn whatsup_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = status_triage_cmd(tmp.path(), "").unwrap();
assert!(
output.contains("Pipeline Status"),
"no args should show pipeline status: {output}"
);
}
#[test]
fn whatsup_non_numeric_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = status_triage_cmd(tmp.path(), "abc").unwrap();
assert!(
output.contains("Invalid"),
"non-numeric arg should return error: {output}"
);
}
// -- not found ----------------------------------------------------------
#[test]
fn whatsup_story_not_in_current_returns_friendly_message() {
let tmp = tempfile::TempDir::new().unwrap();
// Create the directory but put the story in backlog, not current
write_story_file(
tmp.path(),
"1_backlog",
"42_story_not_in_current.md",
"---\nname: Not in current\n---\n",
);
let output = status_triage_cmd(tmp.path(), "42").unwrap();
assert!(
output.contains("42"),
"message should include story number: {output}"
);
assert!(
output.contains("not") || output.contains("Not"),
"message should say not found/in progress: {output}"
);
}
// -- found in 2_current -------------------------------------------------
#[test]
fn whatsup_shows_story_name_and_stage() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"99_story_my_feature.md",
"---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
);
let output = status_triage_cmd(tmp.path(), "99").unwrap();
assert!(output.contains("99"), "should show story number: {output}");
assert!(
output.contains("My Feature"),
"should show story name: {output}"
);
assert!(
output.contains("In Progress") || output.contains("2_current"),
"should show pipeline stage: {output}"
);
}
#[test]
fn whatsup_shows_acceptance_criteria() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"99_story_criteria_test.md",
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
);
let output = status_triage_cmd(tmp.path(), "99").unwrap();
assert!(
output.contains("First thing"),
"should show unchecked criterion: {output}"
);
assert!(
output.contains("Done thing"),
"should show checked criterion: {output}"
);
// 1 of 3 done
assert!(
output.contains("1/3"),
"should show checked/total count: {output}"
);
}
#[test]
fn whatsup_shows_blocked_field() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"55_story_blocked_story.md",
"---\nname: Blocked Story\nblocked: true\n---\n",
);
let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!(
output.contains("blocked"),
"should show blocked field: {output}"
);
}
#[test]
fn whatsup_shows_agent_field() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"55_story_agent_story.md",
"---\nname: Agent Story\nagent: coder-1\n---\n",
);
let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!(
output.contains("coder-1"),
"should show agent field: {output}"
);
}
#[test]
fn whatsup_no_worktree_shows_not_created() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"77_story_no_worktree.md",
"---\nname: No Worktree\n---\n",
);
let output = status_triage_cmd(tmp.path(), "77").unwrap();
// Branch name should still appear
assert!(
output.contains("feature/story-77"),
"should show branch name: {output}"
);
}
#[test]
fn whatsup_no_log_shows_no_log_message() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"77_story_no_log.md",
"---\nname: No Log\n---\n",
);
let output = status_triage_cmd(tmp.path(), "77").unwrap();
assert!(
output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"),
"should indicate no log exists: {output}"
);
}
// -- parse_acceptance_criteria ------------------------------------------
#[test]
fn parse_criteria_mixed() {
let input = "## AC\n- [ ] First\n- [x] Done\n- [X] Also done\n- [ ] Last\n";
let result = parse_acceptance_criteria(input);
assert_eq!(result.len(), 4);
assert_eq!(result[0], (false, "First".to_string()));
assert_eq!(result[1], (true, "Done".to_string()));
assert_eq!(result[2], (true, "Also done".to_string()));
assert_eq!(result[3], (false, "Last".to_string()));
}
#[test]
fn parse_criteria_empty() {
let input = "# Story\nNo checkboxes here.\n";
let result = parse_acceptance_criteria(input);
assert!(result.is_empty());
}
// -- read_log_tail -------------------------------------------------------
#[test]
fn read_log_tail_returns_last_n_lines() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("test.log");
let content = (1..=30).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
std::fs::write(&path, &content).unwrap();
let tail = read_log_tail(&path, 5);
let lines: Vec<&str> = tail.lines().collect();
assert_eq!(lines.len(), 5);
assert_eq!(lines[0], "line 26");
assert_eq!(lines[4], "line 30");
}
#[test]
fn read_log_tail_fewer_lines_than_n() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("short.log");
std::fs::write(&path, "line A\nline B\n").unwrap();
let tail = read_log_tail(&path, 20);
assert!(tail.contains("line A"));
assert!(tail.contains("line B"));
}
// -- latest_log_file ----------------------------------------------------
#[test]
fn latest_log_file_returns_none_for_missing_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let result = latest_log_file(&tmp.path().join("nonexistent"));
assert!(result.is_none());
}
#[test]
fn latest_log_file_finds_log() {
let tmp = tempfile::TempDir::new().unwrap();
let log_path = tmp.path().join("coder-1-sess-abc.log");
std::fs::write(&log_path, "some log content\n").unwrap();
let result = latest_log_file(tmp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), log_path);
}
}

View File

@@ -13,11 +13,17 @@ fn default_permission_timeout_secs() -> u64 {
#[derive(Deserialize, Clone, Debug)]
pub struct BotConfig {
/// Matrix homeserver URL, e.g. `https://matrix.example.com`
pub homeserver: String,
/// Only required when `transport = "matrix"` (the default).
#[serde(default)]
pub homeserver: Option<String>,
/// Bot user ID, e.g. `@storykit:example.com`
pub username: String,
/// Only required when `transport = "matrix"`.
#[serde(default)]
pub username: Option<String>,
/// Bot password
pub password: String,
/// Only required when `transport = "matrix"`.
#[serde(default)]
pub password: Option<String>,
/// Matrix room IDs to join, e.g. `["!roomid:example.com"]`.
/// Use an array for multiple rooms; a single string is accepted via the
/// deprecated `room_id` key for backwards compatibility.
@@ -87,6 +93,26 @@ pub struct BotConfig {
/// use. Defaults to `"pipeline_notification"`.
#[serde(default)]
pub whatsapp_notification_template: Option<String>,
/// Which WhatsApp provider to use: `"meta"` (default, direct Graph API)
/// or `"twilio"` (Twilio REST API as alternative to Meta).
///
/// When `"twilio"`, the Twilio-specific fields below are required instead
/// of the Meta `whatsapp_phone_number_id` / `whatsapp_access_token` pair.
#[serde(default = "default_whatsapp_provider")]
pub whatsapp_provider: String,
// ── Twilio WhatsApp fields ─────────────────────────────────────────
// Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`.
/// Twilio Account SID (starts with `AC`).
#[serde(default)]
pub twilio_account_sid: Option<String>,
/// Twilio Auth Token.
#[serde(default)]
pub twilio_auth_token: Option<String>,
/// Twilio WhatsApp sender number in E.164 format, e.g. `+14155551234`.
#[serde(default)]
pub twilio_whatsapp_number: Option<String>,
// ── Slack Bot API fields ─────────────────────────────────────────
// These are only required when `transport = "slack"`.
@@ -106,6 +132,10 @@ fn default_transport() -> String {
"matrix".to_string()
}
fn default_whatsapp_provider() -> String {
"meta".to_string()
}
impl BotConfig {
/// Load bot configuration from `.storkit/bot.toml`.
///
@@ -133,7 +163,31 @@ impl BotConfig {
}
if config.transport == "whatsapp" {
// Validate WhatsApp-specific fields.
if config.whatsapp_provider == "twilio" {
// Validate Twilio-specific fields.
if config.twilio_account_sid.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_account_sid"
);
return None;
}
if config.twilio_auth_token.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_auth_token"
);
return None;
}
if config.twilio_whatsapp_number.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_whatsapp_number"
);
return None;
}
} else {
// Validate Meta (default) WhatsApp fields.
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
@@ -155,6 +209,7 @@ impl BotConfig {
);
return None;
}
}
} else if config.transport == "slack" {
// Validate Slack-specific fields.
if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
@@ -178,13 +233,34 @@ impl BotConfig {
);
return None;
}
} else if config.room_ids.is_empty() {
} else {
// Default transport is Matrix — validate Matrix-specific fields.
if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"matrix\" requires homeserver"
);
return None;
}
if config.username.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"matrix\" requires username"
);
return None;
}
if config.password.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"matrix\" requires password"
);
return None;
}
if config.room_ids.is_empty() {
eprintln!(
"[matrix-bot] bot.toml has no room_ids configured — \
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
);
return None;
}
}
Some(config)
}
@@ -286,8 +362,8 @@ enabled = true
let result = BotConfig::load(tmp.path());
assert!(result.is_some());
let config = result.unwrap();
assert_eq!(config.homeserver, "https://matrix.example.com");
assert_eq!(config.username, "@bot:example.com");
assert_eq!(config.homeserver.as_deref(), Some("https://matrix.example.com"));
assert_eq!(config.username.as_deref(), Some("@bot:example.com"));
assert_eq!(
config.effective_room_ids(),
&["!abc:example.com", "!def:example.com"]
@@ -722,6 +798,128 @@ whatsapp_access_token = "EAAtoken"
assert!(BotConfig::load(tmp.path()).is_none());
}
// ── Twilio config tests ─────────────────────────────────────────────
#[test]
fn load_twilio_whatsapp_reads_config() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "whatsapp"
whatsapp_provider = "twilio"
twilio_account_sid = "ACtest"
twilio_auth_token = "authtest"
twilio_whatsapp_number = "+14155551234"
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.transport, "whatsapp");
assert_eq!(config.whatsapp_provider, "twilio");
assert_eq!(config.twilio_account_sid.as_deref(), Some("ACtest"));
assert_eq!(config.twilio_auth_token.as_deref(), Some("authtest"));
assert_eq!(
config.twilio_whatsapp_number.as_deref(),
Some("+14155551234")
);
}
#[test]
fn load_whatsapp_provider_defaults_to_meta() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "whatsapp"
whatsapp_phone_number_id = "123456"
whatsapp_access_token = "EAAtoken"
whatsapp_verify_token = "my-verify"
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.whatsapp_provider, "meta");
}
#[test]
fn load_twilio_returns_none_when_missing_account_sid() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "whatsapp"
whatsapp_provider = "twilio"
twilio_auth_token = "authtest"
twilio_whatsapp_number = "+14155551234"
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
#[test]
fn load_twilio_returns_none_when_missing_auth_token() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "whatsapp"
whatsapp_provider = "twilio"
twilio_account_sid = "ACtest"
twilio_whatsapp_number = "+14155551234"
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
#[test]
fn load_twilio_returns_none_when_missing_whatsapp_number() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "whatsapp"
whatsapp_provider = "twilio"
twilio_account_sid = "ACtest"
twilio_auth_token = "authtest"
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
// ── Slack config tests ─────────────────────────────────────────────
#[test]

View File

@@ -14,7 +14,7 @@ use tokio::sync::{Mutex as TokioMutex, watch};
use crate::agents::{AgentPool, AgentStatus};
use crate::slog;
use crate::transport::ChatTransport;
use crate::chat::ChatTransport;
use super::bot::markdown_to_html;

View File

@@ -15,6 +15,7 @@
//! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in
//! `bot.toml`. Each room maintains its own independent conversation history.
pub mod assign;
mod bot;
pub mod commands;
mod config;
@@ -22,6 +23,7 @@ pub mod delete;
pub mod htop;
pub mod rebuild;
pub mod reset;
pub mod rmtree;
pub mod start;
pub mod notifications;
pub mod transport_impl;
@@ -32,9 +34,10 @@ pub use config::BotConfig;
use crate::agents::AgentPool;
use crate::http::context::PermissionForward;
use crate::io::watcher::WatcherEvent;
use crate::rebuild::ShutdownReason;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc};
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc, watch};
/// Attempt to start the Matrix bot.
///
@@ -50,12 +53,17 @@ use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc};
/// `prompt_permission` tool. The bot locks it during active chat sessions
/// to surface permission prompts to the Matrix room and relay user decisions.
///
/// `shutdown_rx` is a watch channel that delivers a `ShutdownReason` when the
/// server is about to stop (SIGINT/SIGTERM or rebuild). The bot uses this to
/// announce the shutdown to all configured rooms before the process exits.
///
/// Must be called from within a Tokio runtime context (e.g., from `main`).
pub fn spawn_bot(
project_root: &Path,
watcher_tx: broadcast::Sender<WatcherEvent>,
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
agents: Arc<AgentPool>,
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
) {
let config = match BotConfig::load(project_root) {
Some(c) => c,
@@ -76,14 +84,15 @@ pub fn spawn_bot(
crate::slog!(
"[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}",
config.homeserver,
config.homeserver.as_deref().unwrap_or("(none)"),
config.effective_room_ids()
);
let root = project_root.to_path_buf();
let watcher_rx = watcher_tx.subscribe();
tokio::spawn(async move {
if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx, agents).await {
if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx, agents, shutdown_rx).await
{
crate::slog!("[matrix-bot] Fatal error: {e}");
}
});

View File

@@ -6,9 +6,11 @@
use crate::io::story_metadata::parse_front_matter;
use crate::io::watcher::WatcherEvent;
use crate::slog;
use crate::transport::ChatTransport;
use crate::chat::ChatTransport;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
/// Human-readable display name for a pipeline stage directory.
@@ -99,6 +101,44 @@ pub fn format_error_notification(
(plain, html)
}
/// Search all pipeline stages for a story name.
///
/// Tries each known pipeline stage directory in order and returns the first
/// name found. Used for events (like rate-limit warnings) that arrive without
/// a known stage.
fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> Option<String> {
for stage in &["2_current", "3_qa", "4_merge", "1_backlog", "5_done"] {
if let Some(name) = read_story_name(project_root, stage, item_id) {
return Some(name);
}
}
None
}
/// Minimum time between rate-limit notifications for the same agent.
const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60);
/// Format a rate limit warning notification message.
///
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
pub fn format_rate_limit_notification(
item_id: &str,
story_name: Option<&str>,
agent_name: &str,
) -> (String, String) {
let number = extract_story_number(item_id).unwrap_or(item_id);
let name = story_name.unwrap_or(item_id);
let plain = format!(
"\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit"
);
let html = format!(
"\u{26a0}\u{fe0f} <strong>#{number}</strong> <em>{name}</em> \u{2014} \
{agent_name} hit an API rate limit"
);
(plain, html)
}
/// Spawn a background task that listens for watcher events and posts
/// stage-transition notifications to all configured rooms via the
/// [`ChatTransport`] abstraction.
@@ -110,6 +150,10 @@ pub fn spawn_notification_listener(
) {
tokio::spawn(async move {
let mut rx = watcher_rx;
// Tracks when a rate-limit notification was last sent for each
// "story_id:agent_name" key, to debounce repeated warnings.
let mut rate_limit_last_notified: HashMap<String, Instant> = HashMap::new();
loop {
match rx.recv().await {
Ok(WatcherEvent::WorkItem {
@@ -163,6 +207,43 @@ pub fn spawn_notification_listener(
}
}
}
Ok(WatcherEvent::RateLimitWarning {
ref story_id,
ref agent_name,
}) => {
// Debounce: skip if we sent a notification for this agent
// within the last RATE_LIMIT_DEBOUNCE seconds.
let debounce_key = format!("{story_id}:{agent_name}");
let now = Instant::now();
if let Some(&last) = rate_limit_last_notified.get(&debounce_key)
&& now.duration_since(last) < RATE_LIMIT_DEBOUNCE
{
slog!(
"[matrix-bot] Rate-limit notification debounced for \
{story_id}:{agent_name}"
);
continue;
}
rate_limit_last_notified.insert(debounce_key, now);
let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = format_rate_limit_notification(
story_id,
story_name.as_deref(),
agent_name,
);
slog!("[matrix-bot] Sending rate-limit notification: {plain}");
for room_id in &room_ids {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!(
"[matrix-bot] Failed to send rate-limit notification \
to {room_id}: {e}"
);
}
}
}
Ok(_) => {} // Ignore non-work-item events
Err(broadcast::error::RecvError::Lagged(n)) => {
slog!(
@@ -183,6 +264,144 @@ pub fn spawn_notification_listener(
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use crate::chat::MessageId;
// ── MockTransport ───────────────────────────────────────────────────────
type CallLog = Arc<std::sync::Mutex<Vec<(String, String, String)>>>;
/// Records every `send_message` call for inspection in tests.
struct MockTransport {
calls: CallLog,
}
impl MockTransport {
fn new() -> (Arc<Self>, CallLog) {
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
(Arc::new(Self { calls: Arc::clone(&calls) }), calls)
}
}
#[async_trait]
impl crate::chat::ChatTransport for MockTransport {
async fn send_message(&self, room_id: &str, plain: &str, html: &str) -> Result<MessageId, String> {
self.calls.lock().unwrap().push((room_id.to_string(), plain.to_string(), html.to_string()));
Ok("mock-msg-id".to_string())
}
async fn edit_message(&self, _room_id: &str, _id: &str, _plain: &str, _html: &str) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
Ok(())
}
}
// ── spawn_notification_listener: RateLimitWarning ───────────────────────
/// AC2 + AC3: when a RateLimitWarning event arrives, send_message is called
/// with a notification that names the agent and story.
#[tokio::test]
async fn rate_limit_warning_sends_notification_with_agent_and_story() {
let tmp = tempfile::tempdir().unwrap();
let stage_dir = tmp.path().join(".storkit").join("work").join("2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
std::fs::write(
stage_dir.join("365_story_rate_limit.md"),
"---\nname: Rate Limit Test Story\n---\n",
)
.unwrap();
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let (transport, calls) = MockTransport::new();
spawn_notification_listener(
transport,
vec!["!room123:example.org".to_string()],
watcher_rx,
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "365_story_rate_limit".to_string(),
agent_name: "coder-1".to_string(),
}).unwrap();
// Give the spawned task time to process the event.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "Expected exactly one notification");
let (room_id, plain, _html) = &calls[0];
assert_eq!(room_id, "!room123:example.org");
assert!(plain.contains("365"), "plain should contain story number");
assert!(plain.contains("Rate Limit Test Story"), "plain should contain story name");
assert!(plain.contains("coder-1"), "plain should contain agent name");
assert!(plain.contains("rate limit"), "plain should mention rate limit");
}
/// AC4: a second RateLimitWarning for the same agent within the debounce
/// window must NOT trigger a second notification.
#[tokio::test]
async fn rate_limit_warning_is_debounced() {
let tmp = tempfile::tempdir().unwrap();
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let (transport, calls) = MockTransport::new();
spawn_notification_listener(
transport,
vec!["!room1:example.org".to_string()],
watcher_rx,
tmp.path().to_path_buf(),
);
// Send the same warning twice in rapid succession.
for _ in 0..2 {
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_debounce".to_string(),
agent_name: "coder-2".to_string(),
}).unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "Debounce should suppress the second notification");
}
/// AC4 (corollary): warnings for different agents are NOT debounced against
/// each other — both should produce notifications.
#[tokio::test]
async fn rate_limit_warnings_for_different_agents_both_notify() {
let tmp = tempfile::tempdir().unwrap();
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let (transport, calls) = MockTransport::new();
spawn_notification_listener(
transport,
vec!["!room1:example.org".to_string()],
watcher_rx,
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
}).unwrap();
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_foo".to_string(),
agent_name: "coder-2".to_string(),
}).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 2, "Different agents should each trigger a notification");
}
// ── stage_display_name ──────────────────────────────────────────────────
@@ -319,6 +538,35 @@ mod tests {
);
}
// ── format_rate_limit_notification ─────────────────────────────────────
#[test]
fn format_rate_limit_notification_includes_agent_and_story() {
let (plain, html) = format_rate_limit_notification(
"365_story_my_feature",
Some("My Feature"),
"coder-2",
);
assert_eq!(
plain,
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
);
assert_eq!(
html,
"\u{26a0}\u{fe0f} <strong>#365</strong> <em>My Feature</em> \u{2014} coder-2 hit an API rate limit"
);
}
#[test]
fn format_rate_limit_notification_falls_back_to_item_id() {
let (plain, _html) =
format_rate_limit_notification("42_story_thing", None, "coder-1");
assert_eq!(
plain,
"\u{26a0}\u{fe0f} #42 42_story_thing \u{2014} coder-1 hit an API rate limit"
);
}
// ── format_stage_notification ───────────────────────────────────────────
#[test]

View File

@@ -50,7 +50,7 @@ pub async fn handle_rebuild(
agents: &Arc<AgentPool>,
) -> String {
crate::slog!("[matrix-bot] rebuild command received (bot={bot_name})");
match crate::rebuild::rebuild_and_restart(agents, project_root).await {
match crate::rebuild::rebuild_and_restart(agents, project_root, None).await {
Ok(msg) => msg,
Err(e) => format!("Rebuild failed: {e}"),
}

View File

@@ -5,7 +5,7 @@
//! with clean context. File-system memories (auto-memory directory) are not
//! affected — only the in-memory/persisted conversation state is cleared.
use crate::matrix::bot::{ConversationHistory, RoomConversation};
use crate::chat::transport::matrix::bot::{ConversationHistory, RoomConversation};
use matrix_sdk::ruma::OwnedRoomId;
use std::path::Path;
@@ -52,7 +52,7 @@ pub async fn handle_reset(
let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default);
conv.session_id = None;
conv.entries.clear();
crate::matrix::bot::save_history(project_root, &guard);
crate::chat::transport::matrix::bot::save_history(project_root, &guard);
}
crate::slog!("[matrix-bot] reset command: cleared session for room {room_id} (bot={bot_name})");
"Session reset. Starting fresh — previous context has been cleared.".to_string()
@@ -138,7 +138,7 @@ mod tests {
#[tokio::test]
async fn handle_reset_clears_session_and_entries() {
use crate::matrix::bot::{ConversationEntry, ConversationRole};
use crate::chat::transport::matrix::bot::{ConversationEntry, ConversationRole};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;

View File

@@ -0,0 +1,282 @@
//! Rmtree command: delete the worktree for a story without deleting the story file.
//!
//! `{bot_name} rmtree {number}` finds the worktree for the given story number,
//! stops any running agent, and removes the worktree directory and branch.
//! The story file in the pipeline is left untouched.
use crate::agents::{AgentPool, AgentStatus};
use std::path::Path;
/// A parsed rmtree command from a Matrix message body.
#[derive(Debug, PartialEq)]
pub enum RmtreeCommand {
/// Remove the worktree for the story with this number.
Rmtree { story_number: String },
/// The user typed `rmtree` but without a valid numeric argument.
BadArgs,
}
/// Parse an rmtree command from a raw Matrix message body.
///
/// Strips the bot mention prefix and checks whether the first word is `rmtree`.
/// Returns `None` when the message is not an rmtree command at all.
pub fn extract_rmtree_command(
message: &str,
bot_name: &str,
bot_user_id: &str,
) -> Option<RmtreeCommand> {
let stripped = strip_mention(message, bot_name, bot_user_id);
let trimmed = stripped
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
if !cmd.eq_ignore_ascii_case("rmtree") {
return None;
}
if !args.is_empty() && args.chars().all(|c| c.is_ascii_digit()) {
Some(RmtreeCommand::Rmtree {
story_number: args.to_string(),
})
} else {
Some(RmtreeCommand::BadArgs)
}
}
/// Handle an rmtree command asynchronously.
///
/// Finds the worktree for `story_number` under `.storkit/worktrees/`, stops any
/// running agent, and removes the worktree directory and its feature branch.
/// Returns a markdown-formatted response string.
pub async fn handle_rmtree(
bot_name: &str,
story_number: &str,
project_root: &Path,
agents: &AgentPool,
) -> String {
// Find the story_id by listing worktree directories.
let worktrees = match crate::worktree::list_worktrees(project_root) {
Ok(wt) => wt,
Err(e) => return format!("Failed to list worktrees: {e}"),
};
let entry = worktrees.into_iter().find(|e| {
e.story_id
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.map(|n| n == story_number)
.unwrap_or(false)
});
let story_id = match entry {
Some(e) => e.story_id,
None => {
return format!("No worktree found for story **{story_number}**.");
}
};
// Stop any running or pending agents for this story.
let running_agents: Vec<(String, String)> = agents
.list_agents()
.unwrap_or_default()
.into_iter()
.filter(|a| {
a.story_id == story_id
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
})
.map(|a| (a.story_id.clone(), a.agent_name.clone()))
.collect();
let mut stopped_agents: Vec<String> = Vec::new();
for (sid, agent_name) in &running_agents {
if let Err(e) = agents.stop_agent(project_root, sid, agent_name).await {
return format!("Failed to stop agent '{agent_name}' for story {story_number}: {e}");
}
stopped_agents.push(agent_name.clone());
}
// Remove the worktree.
if let Err(e) = crate::worktree::prune_worktree_sync(project_root, &story_id) {
return format!("Failed to remove worktree for story {story_number}: {e}");
}
crate::slog!(
"[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})"
);
let mut response = format!("Removed worktree for **{story_id}**.");
if !stopped_agents.is_empty() {
let agent_list = stopped_agents.join(", ");
response.push_str(&format!(" Stopped agent(s): {agent_list}."));
}
response
}
/// Strip the bot mention prefix from a raw Matrix message body.
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
_ => Some(rest),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- extract_rmtree_command ---------------------------------------------
#[test]
fn extract_with_full_user_id() {
let cmd = extract_rmtree_command(
"@timmy:home.local rmtree 42",
"Timmy",
"@timmy:home.local",
);
assert_eq!(
cmd,
Some(RmtreeCommand::Rmtree {
story_number: "42".to_string()
})
);
}
#[test]
fn extract_with_display_name() {
let cmd = extract_rmtree_command("Timmy rmtree 310", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(RmtreeCommand::Rmtree {
story_number: "310".to_string()
})
);
}
#[test]
fn extract_with_localpart() {
let cmd = extract_rmtree_command("@timmy rmtree 7", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(RmtreeCommand::Rmtree {
story_number: "7".to_string()
})
);
}
#[test]
fn extract_case_insensitive_command() {
let cmd = extract_rmtree_command("Timmy RMTREE 99", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(RmtreeCommand::Rmtree {
story_number: "99".to_string()
})
);
}
#[test]
fn extract_no_args_is_bad_args() {
let cmd = extract_rmtree_command("Timmy rmtree", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(RmtreeCommand::BadArgs));
}
#[test]
fn extract_non_numeric_arg_is_bad_args() {
let cmd = extract_rmtree_command("Timmy rmtree foo", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(RmtreeCommand::BadArgs));
}
#[test]
fn extract_non_rmtree_command_returns_none() {
let cmd = extract_rmtree_command("Timmy help", "Timmy", "@timmy:home.local");
assert_eq!(cmd, None);
}
// -- handle_rmtree (integration-style, uses temp filesystem) -----------
#[tokio::test]
async fn handle_rmtree_returns_not_found_for_unknown_number() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
std::fs::create_dir_all(project_root.join(".storkit").join("worktrees")).unwrap();
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
let response = handle_rmtree("Timmy", "999", project_root, &agents).await;
assert!(
response.contains("No worktree found") && response.contains("999"),
"unexpected response: {response}"
);
}
#[tokio::test]
async fn handle_rmtree_removes_worktree_and_confirms() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path().join("my-project");
std::fs::create_dir_all(&project_root).unwrap();
// Init a git repo so worktree ops work.
std::process::Command::new("git")
.args(["init"])
.current_dir(&project_root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(&project_root)
.output()
.unwrap();
// Create a real git worktree for story 42.
let story_id = "42_story_some_feature";
let wt_path = crate::worktree::worktree_path(&project_root, story_id);
let branch = format!("feature/story-{story_id}");
std::process::Command::new("git")
.args(["worktree", "add", &wt_path.to_string_lossy(), "-b", &branch])
.current_dir(&project_root)
.output()
.unwrap();
assert!(wt_path.exists(), "worktree should exist before rmtree");
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
let response = handle_rmtree("Timmy", "42", &project_root, &agents).await;
assert!(
response.contains("42_story_some_feature"),
"unexpected response: {response}"
);
assert!(!wt_path.exists(), "worktree directory should be removed");
}
}

View File

@@ -356,14 +356,14 @@ mod tests {
#[test]
fn start_command_is_registered() {
use crate::matrix::commands::commands;
use crate::chat::transport::matrix::commands::commands;
let found = commands().iter().any(|c| c.name == "start");
assert!(found, "start command must be in the registry");
}
#[test]
fn start_command_appears_in_help() {
let result = crate::matrix::commands::tests::try_cmd_addressed(
let result = crate::chat::transport::matrix::commands::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
@@ -378,7 +378,7 @@ mod tests {
#[test]
fn start_command_falls_through_to_none_in_registry() {
// The start handler in the registry returns None (handled async in bot.rs).
let result = crate::matrix::commands::tests::try_cmd_addressed(
let result = crate::chat::transport::matrix::commands::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy start 42",

View File

@@ -10,7 +10,7 @@ use matrix_sdk::ruma::events::room::message::{
ReplacementMetadata, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
};
use crate::transport::{ChatTransport, MessageId};
use crate::chat::{ChatTransport, MessageId};
/// Matrix-backed [`ChatTransport`] implementation.
///

View File

@@ -0,0 +1,3 @@
pub mod matrix;
pub mod slack;
pub mod whatsapp;

View File

@@ -14,9 +14,9 @@ use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use crate::agents::AgentPool;
use crate::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::slog;
use crate::transport::{ChatTransport, MessageId};
use crate::chat::{ChatTransport, MessageId};
// ── Slack API base URL (overridable for tests) ──────────────────────────
@@ -669,7 +669,7 @@ pub async fn slash_command_receive(
format!("{} {keyword} {}", ctx.bot_name, payload.text)
};
use crate::matrix::commands::{CommandDispatch, try_handle_command};
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
let dispatch = CommandDispatch {
bot_name: &ctx.bot_name,
@@ -701,7 +701,7 @@ async fn handle_incoming_message(
user: &str,
message: &str,
) {
use crate::matrix::commands::{CommandDispatch, try_handle_command};
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
let dispatch = CommandDispatch {
bot_name: &ctx.bot_name,
@@ -721,12 +721,12 @@ async fn handle_incoming_message(
}
// Check for async commands (htop, delete).
if let Some(htop_cmd) = crate::matrix::htop::extract_htop_command(
if let Some(htop_cmd) = crate::chat::transport::matrix::htop::extract_htop_command(
message,
&ctx.bot_name,
&ctx.bot_user_id,
) {
use crate::matrix::htop::HtopCommand;
use crate::chat::transport::matrix::htop::HtopCommand;
slog!("[slack] Handling htop command from {user} in {channel}");
match htop_cmd {
HtopCommand::Stop => {
@@ -738,7 +738,7 @@ async fn handle_incoming_message(
HtopCommand::Start { duration_secs } => {
// On Slack, htop uses native message editing for live updates.
let snapshot =
crate::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
Ok(id) => id,
Err(e) => {
@@ -755,7 +755,7 @@ async fn handle_incoming_message(
let total_ticks = (duration_secs as usize) / 2;
for tick in 1..=total_ticks {
tokio::time::sleep(interval).await;
let updated = crate::matrix::htop::build_htop_message(
let updated = crate::chat::transport::matrix::htop::build_htop_message(
&agents,
(tick * 2) as u32,
duration_secs,
@@ -773,15 +773,15 @@ async fn handle_incoming_message(
return;
}
if let Some(del_cmd) = crate::matrix::delete::extract_delete_command(
if let Some(del_cmd) = crate::chat::transport::matrix::delete::extract_delete_command(
message,
&ctx.bot_name,
&ctx.bot_user_id,
) {
let response = match del_cmd {
crate::matrix::delete::DeleteCommand::Delete { story_number } => {
crate::chat::transport::matrix::delete::DeleteCommand::Delete { story_number } => {
slog!("[slack] Handling delete command from {user}: story {story_number}");
crate::matrix::delete::handle_delete(
crate::chat::transport::matrix::delete::handle_delete(
&ctx.bot_name,
&story_number,
&ctx.project_root,
@@ -789,7 +789,7 @@ async fn handle_incoming_message(
)
.await
}
crate::matrix::delete::DeleteCommand::BadArgs => {
crate::chat::transport::matrix::delete::DeleteCommand::BadArgs => {
format!("Usage: `{} delete <number>`", ctx.bot_name)
}
};
@@ -810,7 +810,7 @@ async fn handle_llm_message(
user_message: &str,
) {
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use crate::matrix::drain_complete_paragraphs;
use crate::chat::transport::matrix::drain_complete_paragraphs;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch;
@@ -1408,7 +1408,7 @@ mod tests {
fn slash_command_dispatches_through_command_registry() {
// Verify that the synthetic message built by the slash handler
// correctly dispatches through try_handle_command.
use crate::matrix::commands::{CommandDispatch, try_handle_command};
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
@@ -1435,7 +1435,7 @@ mod tests {
#[test]
fn slash_command_show_passes_args_through_registry() {
use crate::matrix::commands::{CommandDispatch, try_handle_command};
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();

View File

@@ -14,13 +14,14 @@ use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use crate::agents::AgentPool;
use crate::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::{ChatTransport, MessageId};
use crate::slog;
use crate::transport::{ChatTransport, MessageId};
// ── Graph API base URL (overridable for tests) ──────────────────────────
// ── API base URLs (overridable for tests) ────────────────────────────────
const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0";
const TWILIO_API_BASE: &str = "https://api.twilio.com";
/// Graph API error code indicating the 24-hour messaging window has elapsed.
///
@@ -45,6 +46,7 @@ const OUTSIDE_WINDOW_ERR: &str = "OUTSIDE_MESSAGING_WINDOW";
/// between free-form text and a template message.
pub struct MessagingWindowTracker {
last_message: std::sync::Mutex<HashMap<String, std::time::Instant>>,
#[allow(dead_code)] // Used by Meta provider path (is_within_window → send_notification)
window_duration: std::time::Duration,
}
@@ -82,6 +84,7 @@ impl MessagingWindowTracker {
/// Returns `true` when the last inbound message from `phone` arrived within
/// the 24-hour window, meaning free-form replies are still permitted.
#[allow(dead_code)] // Used by Meta provider path (send_notification)
pub fn is_within_window(&self, phone: &str) -> bool {
let map = self.last_message.lock().unwrap();
match map.get(phone) {
@@ -104,6 +107,7 @@ pub struct WhatsAppTransport {
client: reqwest::Client,
/// Name of the approved Meta message template used for notifications
/// outside the 24-hour messaging window.
#[allow(dead_code)] // Used by Meta provider path (send_template_notification)
notification_template_name: String,
/// Optional base URL override for tests.
api_base: String,
@@ -125,11 +129,7 @@ impl WhatsAppTransport {
}
#[cfg(test)]
fn with_api_base(
phone_number_id: String,
access_token: String,
api_base: String,
) -> Self {
fn with_api_base(phone_number_id: String, access_token: String, api_base: String) -> Self {
Self {
phone_number_id,
access_token,
@@ -208,6 +208,7 @@ impl WhatsAppTransport {
///
/// The template body is expected to accept two positional parameters:
/// `{{1}}` = story name, `{{2}}` = pipeline stage.
#[allow(dead_code)] // Meta provider path — template fallback for expired 24h window
pub async fn send_template_notification(
&self,
to: &str,
@@ -281,6 +282,7 @@ impl WhatsAppTransport {
///
/// This method never crashes on a messaging-window error — it always
/// attempts the template fallback and logs what happened.
#[allow(dead_code)] // Meta provider path — window-aware notification dispatch
pub async fn send_notification(
&self,
to: &str,
@@ -357,6 +359,183 @@ impl ChatTransport for WhatsAppTransport {
}
}
// ── Twilio Transport ────────────────────────────────────────────────────
/// WhatsApp transport that routes through Twilio's REST API.
///
/// Sends messages via `POST {TWILIO_API_BASE}/2010-04-01/Accounts/{account_sid}/Messages.json`
/// using HTTP Basic Auth (Account SID as username, Auth Token as password).
///
/// Inbound messages from Twilio arrive as `application/x-www-form-urlencoded`
/// POST bodies; use [`extract_twilio_text_messages`] to parse them.
pub struct TwilioWhatsAppTransport {
account_sid: String,
auth_token: String,
/// Sender number in E.164 format, e.g. `+14155551234`.
from_number: String,
client: reqwest::Client,
/// Optional base URL override for tests.
api_base: String,
}
impl TwilioWhatsAppTransport {
pub fn new(account_sid: String, auth_token: String, from_number: String) -> Self {
Self {
account_sid,
auth_token,
from_number,
client: reqwest::Client::new(),
api_base: TWILIO_API_BASE.to_string(),
}
}
#[cfg(test)]
fn with_api_base(
account_sid: String,
auth_token: String,
from_number: String,
api_base: String,
) -> Self {
Self {
account_sid,
auth_token,
from_number,
client: reqwest::Client::new(),
api_base,
}
}
/// Send a WhatsApp message via Twilio's Messaging REST API.
async fn send_text(&self, to: &str, body: &str) -> Result<String, String> {
let url = format!(
"{}/2010-04-01/Accounts/{}/Messages.json",
self.api_base, self.account_sid
);
// Twilio expects the WhatsApp number with a "whatsapp:" prefix.
let from = if self.from_number.starts_with("whatsapp:") {
self.from_number.clone()
} else {
format!("whatsapp:{}", self.from_number)
};
let to_wa = if to.starts_with("whatsapp:") {
to.to_string()
} else {
format!("whatsapp:{}", to)
};
let params = [
("From", from.as_str()),
("To", to_wa.as_str()),
("Body", body),
];
let resp = self
.client
.post(&url)
.basic_auth(&self.account_sid, Some(&self.auth_token))
.form(&params)
.send()
.await
.map_err(|e| format!("Twilio API request failed: {e}"))?;
let status = resp.status();
let resp_text = resp
.text()
.await
.unwrap_or_else(|_| "<no body>".to_string());
if !status.is_success() {
return Err(format!("Twilio API returned {status}: {resp_text}"));
}
let parsed: TwilioSendResponse = serde_json::from_str(&resp_text)
.map_err(|e| format!("Failed to parse Twilio API response: {e} — body: {resp_text}"))?;
Ok(parsed.sid.unwrap_or_default())
}
}
#[async_trait]
impl ChatTransport for TwilioWhatsAppTransport {
async fn send_message(
&self,
recipient: &str,
plain: &str,
_html: &str,
) -> Result<MessageId, String> {
slog!("[whatsapp/twilio] send_message to {recipient}: {plain:.80}");
self.send_text(recipient, plain).await
}
async fn edit_message(
&self,
recipient: &str,
_original_message_id: &str,
plain: &str,
html: &str,
) -> Result<(), String> {
// Twilio does not support message editing — send a new message.
slog!(
"[whatsapp/twilio] edit_message — Twilio does not support edits, sending new message"
);
self.send_message(recipient, plain, html).await.map(|_| ())
}
async fn send_typing(&self, _recipient: &str, _typing: bool) -> Result<(), String> {
// Twilio WhatsApp API does not expose typing indicators.
Ok(())
}
}
// ── Twilio API request/response types ──────────────────────────────────
#[derive(Deserialize)]
struct TwilioSendResponse {
sid: Option<String>,
}
// ── Twilio webhook types (Twilio → us) ─────────────────────────────────
/// Form-encoded fields from a Twilio WhatsApp inbound webhook POST.
#[derive(Deserialize, Debug)]
pub struct TwilioWebhookForm {
/// Sender number with `whatsapp:` prefix, e.g. `whatsapp:+15551234567`.
#[serde(rename = "From")]
pub from: Option<String>,
/// Message body text.
#[serde(rename = "Body")]
pub body: Option<String>,
}
/// Extract text messages from a Twilio form-encoded webhook body.
///
/// Returns `(sender_phone, message_body)` pairs, with the `whatsapp:` prefix
/// stripped from the sender number.
pub fn extract_twilio_text_messages(bytes: &[u8]) -> Vec<(String, String)> {
let form: TwilioWebhookForm = match serde_urlencoded::from_bytes(bytes) {
Ok(f) => f,
Err(e) => {
slog!("[whatsapp/twilio] Failed to parse webhook form body: {e}");
return vec![];
}
};
let from = match form.from {
Some(f) => f,
None => return vec![],
};
let body = match form.body {
Some(b) if !b.is_empty() => b,
_ => return vec![],
};
// Strip the "whatsapp:" prefix so the sender is stored as a plain phone number.
let sender = from.strip_prefix("whatsapp:").unwrap_or(&from).to_string();
vec![(sender, body)]
}
// ── Graph API request/response types ────────────────────────────────────
#[derive(Serialize)]
@@ -399,6 +578,7 @@ struct GraphApiError {
// ── Template message types ──────────────────────────────────────────────
#[allow(dead_code)] // Meta provider path — template message types
#[derive(Serialize)]
struct GraphTemplateMessage<'a> {
messaging_product: &'a str,
@@ -407,6 +587,7 @@ struct GraphTemplateMessage<'a> {
template: GraphTemplate<'a>,
}
#[allow(dead_code)]
#[derive(Serialize)]
struct GraphTemplate<'a> {
name: &'a str,
@@ -414,17 +595,20 @@ struct GraphTemplate<'a> {
components: Vec<GraphTemplateComponent>,
}
#[allow(dead_code)]
#[derive(Serialize)]
struct GraphLanguage {
code: &'static str,
}
#[allow(dead_code)]
#[derive(Serialize)]
struct GraphTemplateComponent {
r#type: &'static str,
parameters: Vec<GraphTemplateParameter>,
}
#[allow(dead_code)]
#[derive(Serialize)]
struct GraphTemplateParameter {
r#type: &'static str,
@@ -455,11 +639,13 @@ pub struct WebhookChange {
pub struct WebhookValue {
#[serde(default)]
pub messages: Vec<WebhookMessage>,
#[allow(dead_code)] // Present in Meta webhook JSON, kept for deserialization
pub metadata: Option<WebhookMetadata>,
}
#[derive(Deserialize, Debug)]
pub struct WebhookMetadata {
#[allow(dead_code)]
pub phone_number_id: Option<String>,
}
@@ -557,9 +743,7 @@ struct PersistedWhatsAppHistory {
const WHATSAPP_HISTORY_FILE: &str = ".storkit/whatsapp_history.json";
/// Load WhatsApp conversation history from disk.
pub fn load_whatsapp_history(
project_root: &std::path::Path,
) -> HashMap<String, RoomConversation> {
pub fn load_whatsapp_history(project_root: &std::path::Path) -> HashMap<String, RoomConversation> {
let path = project_root.join(WHATSAPP_HISTORY_FILE);
let data = match std::fs::read_to_string(&path) {
Ok(d) => d,
@@ -615,7 +799,9 @@ pub struct VerifyQuery {
/// Shared context for webhook handlers, injected via Poem's `Data` extractor.
pub struct WhatsAppWebhookContext {
pub verify_token: String,
pub transport: Arc<WhatsAppTransport>,
/// Active provider: `"meta"` (Meta Graph API) or `"twilio"` (Twilio REST API).
pub provider: String,
pub transport: Arc<dyn ChatTransport>,
pub project_root: PathBuf,
pub agents: Arc<AgentPool>,
pub bot_name: String,
@@ -630,23 +816,27 @@ pub struct WhatsAppWebhookContext {
pub window_tracker: Arc<MessagingWindowTracker>,
}
/// GET /webhook/whatsapp — Meta verification handshake.
/// GET /webhook/whatsapp — webhook verification.
///
/// Meta sends `hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>`.
/// We return the challenge if the token matches.
/// For Meta: responds to the `hub.mode=subscribe` challenge handshake.
/// For Twilio: Twilio does not send GET verification; always returns 200 OK.
#[handler]
pub async fn webhook_verify(
Query(q): Query<VerifyQuery>,
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
) -> Response {
// Twilio does not use a GET challenge; just acknowledge.
if ctx.provider == "twilio" {
return Response::builder().status(StatusCode::OK).body("ok");
}
// Meta verification handshake.
if q.hub_mode.as_deref() == Some("subscribe")
&& q.hub_verify_token.as_deref() == Some(&ctx.verify_token)
&& let Some(challenge) = q.hub_challenge
{
slog!("[whatsapp] Webhook verification succeeded");
return Response::builder()
.status(StatusCode::OK)
.body(challenge);
return Response::builder().status(StatusCode::OK).body(challenge);
}
slog!("[whatsapp] Webhook verification failed");
Response::builder()
@@ -654,7 +844,13 @@ pub async fn webhook_verify(
.body("Verification failed")
}
/// POST /webhook/whatsapp — receive incoming messages from Meta.
/// POST /webhook/whatsapp — receive incoming messages.
///
/// Dispatches to the appropriate parser based on the configured provider:
/// - `"meta"`: parses Meta's JSON `WebhookPayload`.
/// - `"twilio"`: parses Twilio's `application/x-www-form-urlencoded` body.
///
/// Both providers expect a `200 OK` response, even on parse errors.
#[handler]
pub async fn webhook_receive(
req: &Request,
@@ -672,23 +868,31 @@ pub async fn webhook_receive(
}
};
let messages = if ctx.provider == "twilio" {
let msgs = extract_twilio_text_messages(&bytes);
if msgs.is_empty() {
slog!("[whatsapp/twilio] No text messages in webhook body; ignoring");
}
msgs
} else {
let payload: WebhookPayload = match serde_json::from_slice(&bytes) {
Ok(p) => p,
Err(e) => {
slog!("[whatsapp] Failed to parse webhook payload: {e}");
// Meta expects 200 even on parse errors to avoid retries.
return Response::builder()
.status(StatusCode::OK)
.body("ok");
return Response::builder().status(StatusCode::OK).body("ok");
}
};
let messages = extract_text_messages(&payload);
if messages.is_empty() {
let msgs = extract_text_messages(&payload);
if msgs.is_empty() {
// Status updates, read receipts, etc. — acknowledge silently.
return Response::builder()
.status(StatusCode::OK)
.body("ok");
return Response::builder().status(StatusCode::OK).body("ok");
}
msgs
};
if messages.is_empty() {
return Response::builder().status(StatusCode::OK).body("ok");
}
let ctx = Arc::clone(*ctx);
@@ -699,18 +903,12 @@ pub async fn webhook_receive(
}
});
Response::builder()
.status(StatusCode::OK)
.body("ok")
Response::builder().status(StatusCode::OK).body("ok")
}
/// Dispatch an incoming WhatsApp message to bot commands.
async fn handle_incoming_message(
ctx: &WhatsAppWebhookContext,
sender: &str,
message: &str,
) {
use crate::matrix::commands::{CommandDispatch, try_handle_command};
async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
// Record this inbound message to keep the 24-hour window open.
ctx.window_tracker.record_message(sender);
@@ -733,12 +931,12 @@ async fn handle_incoming_message(
}
// Check for async commands (htop, delete).
if let Some(htop_cmd) = crate::matrix::htop::extract_htop_command(
if let Some(htop_cmd) = crate::chat::transport::matrix::htop::extract_htop_command(
message,
&ctx.bot_name,
&ctx.bot_user_id,
) {
use crate::matrix::htop::HtopCommand;
use crate::chat::transport::matrix::htop::HtopCommand;
slog!("[whatsapp] Handling htop command from {sender}");
match htop_cmd {
HtopCommand::Stop => {
@@ -752,26 +950,26 @@ async fn handle_incoming_message(
HtopCommand::Start { duration_secs } => {
// On WhatsApp, send a single snapshot instead of a live-updating
// dashboard since we can't edit messages.
let snapshot =
crate::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
let _ = ctx
.transport
.send_message(sender, &snapshot, "")
.await;
let snapshot = crate::chat::transport::matrix::htop::build_htop_message(
&ctx.agents,
0,
duration_secs,
);
let _ = ctx.transport.send_message(sender, &snapshot, "").await;
}
}
return;
}
if let Some(del_cmd) = crate::matrix::delete::extract_delete_command(
if let Some(del_cmd) = crate::chat::transport::matrix::delete::extract_delete_command(
message,
&ctx.bot_name,
&ctx.bot_user_id,
) {
let response = match del_cmd {
crate::matrix::delete::DeleteCommand::Delete { story_number } => {
crate::chat::transport::matrix::delete::DeleteCommand::Delete { story_number } => {
slog!("[whatsapp] Handling delete command from {sender}: story {story_number}");
crate::matrix::delete::handle_delete(
crate::chat::transport::matrix::delete::handle_delete(
&ctx.bot_name,
&story_number,
&ctx.project_root,
@@ -779,7 +977,7 @@ async fn handle_incoming_message(
)
.await
}
crate::matrix::delete::DeleteCommand::BadArgs => {
crate::chat::transport::matrix::delete::DeleteCommand::BadArgs => {
format!("Usage: `{} delete <number>`", ctx.bot_name)
}
};
@@ -793,22 +991,16 @@ async fn handle_incoming_message(
}
/// Forward a message to Claude Code and send the response back via WhatsApp.
async fn handle_llm_message(
ctx: &WhatsAppWebhookContext,
sender: &str,
user_message: &str,
) {
async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_message: &str) {
use crate::chat::transport::matrix::drain_complete_paragraphs;
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use crate::matrix::drain_complete_paragraphs;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch;
// Look up existing session ID for this sender.
let resume_session_id: Option<String> = {
let guard = ctx.history.lock().await;
guard
.get(sender)
.and_then(|conv| conv.session_id.clone())
guard.get(sender).and_then(|conv| conv.session_id.clone())
};
let bot_name = &ctx.bot_name;
@@ -879,9 +1071,7 @@ async fn handle_llm_message(
let last_text = messages
.iter()
.rev()
.find(|m| {
m.role == crate::llm::types::Role::Assistant && !m.content.is_empty()
})
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
.map(|m| m.content.clone())
.unwrap_or_default();
if !last_text.is_empty() {
@@ -1022,7 +1212,10 @@ mod tests {
let result = transport.send_message("15551234567", "hello", "").await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("24-hour messaging window"), "unexpected: {msg}");
assert!(
msg.contains("24-hour messaging window"),
"unexpected: {msg}"
);
}
// ── send_template_notification ────────────────────────────────────
@@ -1356,6 +1549,133 @@ mod tests {
assert_eq!(conv.entries[1].content, "hi there!");
}
// ── TwilioWhatsAppTransport tests ─────────────────────────────────
#[tokio::test]
async fn twilio_send_message_calls_twilio_api() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
.with_body(r#"{"sid": "SMtest123"}"#)
.create_async()
.await;
let transport = TwilioWhatsAppTransport::with_api_base(
"ACtest".to_string(),
"authtoken".to_string(),
"+14155551234".to_string(),
server.url(),
);
let result = transport.send_message("+15551234567", "hello", "").await;
assert!(result.is_ok(), "unexpected err: {:?}", result.err());
assert_eq!(result.unwrap(), "SMtest123");
mock.assert_async().await;
}
#[tokio::test]
async fn twilio_send_message_returns_err_on_api_error() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
.with_status(401)
.with_body(r#"{"message": "Unauthorized"}"#)
.create_async()
.await;
let transport = TwilioWhatsAppTransport::with_api_base(
"ACtest".to_string(),
"badtoken".to_string(),
"+14155551234".to_string(),
server.url(),
);
let result = transport.send_message("+15551234567", "hello", "").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("401"));
}
#[tokio::test]
async fn twilio_edit_message_sends_new_message() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
.with_body(r#"{"sid": "SMedit456"}"#)
.create_async()
.await;
let transport = TwilioWhatsAppTransport::with_api_base(
"ACtest".to_string(),
"authtoken".to_string(),
"+14155551234".to_string(),
server.url(),
);
let result = transport
.edit_message("+15551234567", "old-sid", "updated text", "")
.await;
assert!(result.is_ok());
mock.assert_async().await;
}
#[tokio::test]
async fn twilio_send_typing_is_noop() {
let transport = TwilioWhatsAppTransport::new(
"ACtest".to_string(),
"authtoken".to_string(),
"+14155551234".to_string(),
);
assert!(transport.send_typing("+15551234567", true).await.is_ok());
}
// ── extract_twilio_text_messages tests ────────────────────────────
#[test]
fn extract_twilio_text_messages_parses_valid_form() {
let body = b"From=whatsapp%3A%2B15551234567&Body=hello+world&To=whatsapp%3A%2B14155551234&MessageSid=SMtest";
let msgs = extract_twilio_text_messages(body);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].0, "+15551234567");
assert_eq!(msgs[0].1, "hello world");
}
#[test]
fn extract_twilio_text_messages_strips_whatsapp_prefix() {
let body = b"From=whatsapp%3A%2B15551234567&Body=hi";
let msgs = extract_twilio_text_messages(body);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].0, "+15551234567");
}
#[test]
fn extract_twilio_text_messages_returns_empty_on_missing_from() {
let body = b"Body=hello";
let msgs = extract_twilio_text_messages(body);
assert!(msgs.is_empty());
}
#[test]
fn extract_twilio_text_messages_returns_empty_on_missing_body() {
let body = b"From=whatsapp%3A%2B15551234567";
let msgs = extract_twilio_text_messages(body);
assert!(msgs.is_empty());
}
#[test]
fn extract_twilio_text_messages_returns_empty_on_empty_body() {
let body = b"From=whatsapp%3A%2B15551234567&Body=";
let msgs = extract_twilio_text_messages(body);
assert!(msgs.is_empty());
}
#[test]
fn extract_twilio_text_messages_returns_empty_on_invalid_form() {
let body = b"not valid form encoded {{{{";
// serde_urlencoded is lenient, so this might parse or return empty
// Either way it must not panic.
let _msgs = extract_twilio_text_messages(body);
}
#[test]
fn load_whatsapp_history_returns_empty_when_file_missing() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -0,0 +1,305 @@
//! Bot command HTTP endpoint.
//!
//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot
//! commands available in Matrix without going through the LLM.
//!
//! Synchronous commands (status, git, cost, move, show, overview, help) are
//! dispatched directly through the matrix command registry.
//! Asynchronous commands (assign, start, delete, rebuild) are dispatched to
//! their dedicated async handlers. The `reset` command is handled by the frontend
//! (it clears local session state and message history) and is not routed here.
use crate::http::context::{AppContext, OpenApiResult};
use crate::chat::transport::matrix::commands::CommandDispatch;
use poem::http::StatusCode;
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
#[derive(Tags)]
enum BotCommandTags {
BotCommand,
}
/// Body for `POST /api/bot/command`.
#[derive(Object, Deserialize)]
struct BotCommandRequest {
/// The command keyword without the leading slash (e.g. `"status"`, `"start"`).
command: String,
/// Any text after the command keyword, trimmed (may be empty).
#[oai(default)]
args: String,
}
/// Response body for `POST /api/bot/command`.
#[derive(Object, Serialize)]
struct BotCommandResponse {
/// Markdown-formatted response text.
response: String,
}
pub struct BotCommandApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "BotCommandTags::BotCommand")]
impl BotCommandApi {
/// Execute a slash command without LLM invocation.
///
/// Dispatches to the same handlers used by the Matrix and Slack bots.
/// Returns a markdown-formatted response that the frontend can display
/// directly in the chat panel.
#[oai(path = "/bot/command", method = "post")]
async fn run_command(
&self,
body: Json<BotCommandRequest>,
) -> OpenApiResult<Json<BotCommandResponse>> {
let project_root = self.ctx.state.get_project_root().map_err(|e| {
poem::Error::from_string(e, StatusCode::BAD_REQUEST)
})?;
let cmd = body.command.trim().to_ascii_lowercase();
let args = body.args.trim();
let response = dispatch_command(&cmd, args, &project_root, &self.ctx.agents).await;
Ok(Json(BotCommandResponse { response }))
}
}
/// Dispatch a command keyword + args to the appropriate handler.
async fn dispatch_command(
cmd: &str,
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
match cmd {
"assign" => dispatch_assign(args, project_root, agents).await,
"start" => dispatch_start(args, project_root, agents).await,
"delete" => dispatch_delete(args, project_root, agents).await,
"rebuild" => dispatch_rebuild(project_root, agents).await,
// All other commands go through the synchronous command registry.
_ => dispatch_sync(cmd, args, project_root, agents),
}
}
fn dispatch_sync(
cmd: &str,
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
let ambient_rooms: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
// Use a synthetic bot name/id so strip_bot_mention passes through.
let bot_name = "__web_ui__";
let bot_user_id = "@__web_ui__:localhost";
let room_id = "__web_ui__";
let dispatch = CommandDispatch {
bot_name,
bot_user_id,
project_root,
agents,
ambient_rooms: &ambient_rooms,
room_id,
};
// Build a synthetic message that the registry can parse.
let synthetic = if args.is_empty() {
format!("{bot_name} {cmd}")
} else {
format!("{bot_name} {cmd} {args}")
};
match crate::chat::transport::matrix::commands::try_handle_command(&dispatch, &synthetic) {
Some(response) => response,
None => {
// Command exists in the registry but its fallback handler returns None
// (start, delete, rebuild, reset, htop — handled elsewhere or in
// the frontend). Should not be reached for those since we intercept
// them above. For genuinely unknown commands, tell the user.
format!("Unknown command: `/{cmd}`. Type `/help` to see available commands.")
}
}
}
async fn dispatch_assign(
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
// args: "<number> <model>"
let mut parts = args.splitn(2, char::is_whitespace);
let number_str = parts.next().unwrap_or("").trim();
let model_str = parts.next().unwrap_or("").trim();
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) || model_str.is_empty() {
return "Usage: `/assign <number> <model>` (e.g. `/assign 42 opus`)".to_string();
}
crate::chat::transport::matrix::assign::handle_assign("web-ui", number_str, model_str, project_root, agents)
.await
}
async fn dispatch_start(
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
// args: "<number>" or "<number> <model_hint>"
let mut parts = args.splitn(2, char::is_whitespace);
let number_str = parts.next().unwrap_or("").trim();
let hint_str = parts.next().unwrap_or("").trim();
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
return "Usage: `/start <number>` or `/start <number> <model>` (e.g. `/start 42 opus`)"
.to_string();
}
let agent_hint = if hint_str.is_empty() {
None
} else {
Some(hint_str)
};
crate::chat::transport::matrix::start::handle_start("web-ui", number_str, agent_hint, project_root, agents)
.await
}
async fn dispatch_delete(
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
let number_str = args.trim();
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
return "Usage: `/delete <number>` (e.g. `/delete 42`)".to_string();
}
crate::chat::transport::matrix::delete::handle_delete("web-ui", number_str, project_root, agents).await
}
async fn dispatch_rebuild(
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
crate::chat::transport::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_api(dir: &TempDir) -> BotCommandApi {
BotCommandApi {
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
}
}
#[tokio::test]
async fn help_command_returns_response() {
let dir = TempDir::new().unwrap();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "help".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
let resp = result.unwrap().0;
assert!(!resp.response.is_empty());
}
#[tokio::test]
async fn unknown_command_returns_error_message() {
let dir = TempDir::new().unwrap();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "nonexistent_xyz".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
let resp = result.unwrap().0;
assert!(
resp.response.contains("Unknown command"),
"expected 'Unknown command' in: {}",
resp.response
);
}
#[tokio::test]
async fn start_without_number_returns_usage() {
let dir = TempDir::new().unwrap();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "start".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
let resp = result.unwrap().0;
assert!(
resp.response.contains("Usage"),
"expected usage hint in: {}",
resp.response
);
}
#[tokio::test]
async fn delete_without_number_returns_usage() {
let dir = TempDir::new().unwrap();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "delete".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
let resp = result.unwrap().0;
assert!(
resp.response.contains("Usage"),
"expected usage hint in: {}",
resp.response
);
}
#[tokio::test]
async fn git_command_returns_response() {
let dir = TempDir::new().unwrap();
// Initialise a bare git repo so the git command has something to query.
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.ok();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "git".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn run_command_requires_project_root() {
// Create a context with no project root set.
let dir = TempDir::new().unwrap();
let ctx = AppContext::new_test(dir.path().to_path_buf());
// Clear the project root.
*ctx.state.project_root.lock().unwrap() = None;
let api = BotCommandApi { ctx: Arc::new(ctx) };
let body = BotCommandRequest {
command: "status".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_err(), "should fail when no project root is set");
}
}

View File

@@ -1,5 +1,6 @@
use crate::agents::{AgentPool, ReconciliationEvent};
use crate::io::watcher::WatcherEvent;
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
use crate::state::SessionState;
use crate::store::JsonFileStore;
use crate::workflow::WorkflowState;
@@ -52,6 +53,20 @@ pub struct AppContext {
/// Child process of the QA app launched for manual testing.
/// Only one instance runs at a time.
pub qa_app_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
/// Best-effort shutdown notifier for active bot channels (Slack / WhatsApp).
///
/// When set, the MCP `rebuild_and_restart` tool uses this to announce the
/// shutdown to configured channels before re-execing the server binary.
/// `None` when no webhook-based bot transport is configured.
pub bot_shutdown: Option<Arc<BotShutdownNotifier>>,
/// Watch sender used to signal the Matrix bot task that the server is
/// shutting down (rebuild path). The bot task listens for this signal and
/// sends a shutdown announcement to all configured rooms.
///
/// Wrapped in `Arc` so `AppContext` can implement `Clone`.
/// `None` when no Matrix bot is configured.
pub matrix_shutdown_tx:
Option<Arc<tokio::sync::watch::Sender<Option<ShutdownReason>>>>,
}
#[cfg(test)]
@@ -73,6 +88,8 @@ impl AppContext {
perm_tx,
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
bot_shutdown: None,
matrix_shutdown_tx: None,
}
}
}

View File

@@ -29,8 +29,18 @@ pub(super) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
/// Rebuild the server binary and re-exec (delegates to `crate::rebuild`).
pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String, String> {
slog!("[rebuild] Rebuild and restart requested via MCP tool");
// Signal the Matrix bot (if active) so it can send its own shutdown
// announcement before the process is replaced. Best-effort: we wait up
// to 1.5 s for the message to be delivered.
if let Some(ref tx) = ctx.matrix_shutdown_tx {
let _ = tx.send(Some(crate::rebuild::ShutdownReason::Rebuild));
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
let project_root = ctx.state.get_project_root().unwrap_or_default();
crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root).await
let notifier = ctx.bot_shutdown.as_deref();
crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root, notifier).await
}
/// Generate a Claude Code permission rule string for the given tool name and input.

View File

@@ -15,6 +15,7 @@ pub mod merge_tools;
pub mod qa_tools;
pub mod shell_tools;
pub mod story_tools;
pub mod status_tools;
/// Returns true when the Accept header includes text/event-stream.
fn wants_sse(req: &Request) -> bool {
@@ -1121,6 +1122,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
"required": ["worktree_path"]
}
},
{
"name": "status",
"description": "Get a full triage dump for an in-progress story: front matter, AC checklist, active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Returns a clear error if the story is not in work/2_current/.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (filename stem, e.g. '42_story_my_feature')"
}
},
"required": ["story_id"]
}
}
]
}),
@@ -1209,6 +1224,8 @@ async fn handle_tools_call(
"git_add" => git_tools::tool_git_add(&args, ctx).await,
"git_commit" => git_tools::tool_git_commit(&args, ctx).await,
"git_log" => git_tools::tool_git_log(&args, ctx).await,
// Story triage
"status" => status_tools::tool_status(&args, ctx).await,
_ => Err(format!("Unknown tool: {tool_name}")),
};
@@ -1324,7 +1341,8 @@ mod tests {
assert!(names.contains(&"git_add"));
assert!(names.contains(&"git_commit"));
assert!(names.contains(&"git_log"));
assert_eq!(tools.len(), 48);
assert!(names.contains(&"status"));
assert_eq!(tools.len(), 49);
}
#[test]

View File

@@ -0,0 +1,364 @@
use crate::http::context::AppContext;
use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
/// Parse all AC items from a story file, returning (text, is_checked) pairs.
fn parse_ac_items(contents: &str) -> Vec<(String, bool)> {
let mut in_ac_section = false;
let mut items = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
if trimmed == "## Acceptance Criteria" {
in_ac_section = true;
continue;
}
// Stop at the next heading
if in_ac_section && trimmed.starts_with("## ") {
break;
}
if in_ac_section {
if let Some(rest) = trimmed.strip_prefix("- [x] ").or(trimmed.strip_prefix("- [X] ")) {
items.push((rest.to_string(), true));
} else if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
items.push((rest.to_string(), false));
}
}
}
items
}
/// Find the most recent log file for any agent under `.storkit/logs/{story_id}/`.
fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option<PathBuf> {
let dir = project_root
.join(".storkit")
.join("logs")
.join(story_id);
if !dir.is_dir() {
return None;
}
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
let entries = fs::read_dir(&dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if !name.ends_with(".log") {
continue;
}
let modified = match entry.metadata().and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => continue,
};
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
best = Some((path, modified));
}
}
best.map(|(p, _)| p)
}
/// Return the last N raw lines from a file.
fn last_n_lines(path: &Path, n: usize) -> Result<Vec<String>, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?;
let lines: Vec<String> = content
.lines()
.rev()
.take(n)
.map(|l| l.to_string())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
Ok(lines)
}
/// Run `git diff --stat {base}...HEAD` in the worktree.
async fn git_diff_stat(worktree: &Path, base: &str) -> Option<String> {
let dir = worktree.to_path_buf();
let base_arg = format!("{base}...HEAD");
tokio::task::spawn_blocking(move || {
let output = std::process::Command::new("git")
.args(["diff", "--stat", &base_arg])
.current_dir(&dir)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
})
.await
.ok()
.flatten()
}
/// Return the last N commit messages on the current branch relative to base.
async fn git_log_commits(worktree: &Path, base: &str, count: usize) -> Option<Vec<String>> {
let dir = worktree.to_path_buf();
let range = format!("{base}..HEAD");
let count_str = count.to_string();
tokio::task::spawn_blocking(move || {
let output = std::process::Command::new("git")
.args(["log", &range, "--oneline", &format!("-{count_str}")])
.current_dir(&dir)
.output()
.ok()?;
if output.status.success() {
let lines: Vec<String> = String::from_utf8(output.stdout)
.ok()?
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
Some(lines)
} else {
None
}
})
.await
.ok()
.flatten()
}
/// Return the active branch name for the given directory.
async fn git_branch(dir: &Path) -> Option<String> {
let dir = dir.to_path_buf();
tokio::task::spawn_blocking(move || {
let output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&dir)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
})
.await
.ok()
.flatten()
}
pub(super) async fn tool_status(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 root = ctx.state.get_project_root()?;
let current_dir = root.join(".storkit").join("work").join("2_current");
let filepath = current_dir.join(format!("{story_id}.md"));
if !filepath.exists() {
return Err(format!(
"Story '{story_id}' not found in work/2_current/. Check the story_id and ensure it is in the current stage."
));
}
let contents =
fs::read_to_string(&filepath).map_err(|e| format!("Failed to read story file: {e}"))?;
// --- Front matter ---
let mut front_matter = serde_json::Map::new();
if let Ok(meta) = crate::io::story_metadata::parse_front_matter(&contents) {
if let Some(name) = &meta.name {
front_matter.insert("name".to_string(), json!(name));
}
if let Some(agent) = &meta.agent {
front_matter.insert("agent".to_string(), json!(agent));
}
if let Some(true) = meta.blocked {
front_matter.insert("blocked".to_string(), json!(true));
}
if let Some(qa) = &meta.qa {
front_matter.insert("qa".to_string(), json!(qa.as_str()));
}
if let Some(rc) = meta.retry_count
&& rc > 0
{
front_matter.insert("retry_count".to_string(), json!(rc));
}
if let Some(mf) = &meta.merge_failure {
front_matter.insert("merge_failure".to_string(), json!(mf));
}
if let Some(rh) = meta.review_hold
&& rh
{
front_matter.insert("review_hold".to_string(), json!(rh));
}
}
// --- AC checklist ---
let ac_items: Vec<Value> = parse_ac_items(&contents)
.into_iter()
.map(|(text, checked)| json!({ "text": text, "checked": checked }))
.collect();
// --- Worktree ---
let worktree_path = root.join(".storkit").join("worktrees").join(story_id);
let (_, worktree_info) = if worktree_path.is_dir() {
let branch = git_branch(&worktree_path).await;
(
branch.clone(),
Some(json!({
"path": worktree_path.to_string_lossy(),
"branch": branch,
})),
)
} else {
(None, None)
};
// --- Git diff stat ---
let diff_stat = if worktree_path.is_dir() {
git_diff_stat(&worktree_path, "master").await
} else {
None
};
// --- Last 5 commits ---
let commits = if worktree_path.is_dir() {
git_log_commits(&worktree_path, "master", 5).await
} else {
None
};
// --- Most recent agent log (last 20 lines) ---
let agent_log = match find_most_recent_log(&root, story_id) {
Some(log_path) => {
let filename = log_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
match last_n_lines(&log_path, 20) {
Ok(lines) => Some(json!({
"file": filename,
"lines": lines,
})),
Err(_) => None,
}
}
None => None,
};
let result = json!({
"story_id": story_id,
"front_matter": front_matter,
"acceptance_criteria": ac_items,
"worktree": worktree_info,
"git_diff_stat": diff_stat,
"commits": commits,
"agent_log": agent_log,
});
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parse_ac_items_returns_checked_and_unchecked() {
let content = "---\nname: test\n---\n\n## Acceptance Criteria\n\n- [ ] item one\n- [x] item two\n- [X] item three\n\n## Out of Scope\n\n- [ ] not an ac\n";
let items = parse_ac_items(content);
assert_eq!(items.len(), 3);
assert_eq!(items[0], ("item one".to_string(), false));
assert_eq!(items[1], ("item two".to_string(), true));
assert_eq!(items[2], ("item three".to_string(), true));
}
#[test]
fn parse_ac_items_empty_when_no_section() {
let content = "---\nname: test\n---\n\nNo AC section here.\n";
let items = parse_ac_items(content);
assert!(items.is_empty());
}
#[test]
fn find_most_recent_log_returns_none_for_missing_dir() {
let tmp = tempdir().unwrap();
let result = find_most_recent_log(tmp.path(), "nonexistent_story");
assert!(result.is_none());
}
#[test]
fn find_most_recent_log_returns_newest_file() {
let tmp = tempdir().unwrap();
let log_dir = tmp
.path()
.join(".storkit")
.join("logs")
.join("42_story_foo");
fs::create_dir_all(&log_dir).unwrap();
let old_path = log_dir.join("coder-1-sess-old.log");
fs::write(&old_path, "old content").unwrap();
// Ensure different mtime
std::thread::sleep(std::time::Duration::from_millis(50));
let new_path = log_dir.join("coder-1-sess-new.log");
fs::write(&new_path, "new content").unwrap();
let result = find_most_recent_log(tmp.path(), "42_story_foo").unwrap();
assert!(
result.to_string_lossy().contains("sess-new"),
"Expected newest file, got: {}",
result.display()
);
}
#[tokio::test]
async fn tool_status_returns_error_for_missing_story() {
let tmp = tempdir().unwrap();
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let result = tool_status(&json!({"story_id": "999_story_nonexistent"}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found in work/2_current/"));
}
#[tokio::test]
async fn tool_status_returns_story_data() {
let tmp = tempdir().unwrap();
let current_dir = tmp
.path()
.join(".storkit")
.join("work")
.join("2_current");
fs::create_dir_all(&current_dir).unwrap();
let story_content = "---\nname: My Test Story\nagent: coder-1\n---\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n";
fs::write(current_dir.join("42_story_test.md"), story_content).unwrap();
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let result = tool_status(&json!({"story_id": "42_story_test"}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["story_id"], "42_story_test");
assert_eq!(parsed["front_matter"]["name"], "My Test Story");
assert_eq!(parsed["front_matter"]["agent"], "coder-1");
let ac = parsed["acceptance_criteria"].as_array().unwrap();
assert_eq!(ac.len(), 2);
assert_eq!(ac[0]["text"], "First criterion");
assert_eq!(ac[0]["checked"], false);
assert_eq!(ac[1]["text"], "Second criterion");
assert_eq!(ac[1]["checked"], true);
}
}

View File

@@ -2,6 +2,7 @@ pub mod agents;
pub mod agents_sse;
pub mod anthropic;
pub mod assets;
pub mod bot_command;
pub mod chat;
pub mod context;
pub mod health;
@@ -16,6 +17,7 @@ pub mod ws;
use agents::AgentsApi;
use anthropic::AnthropicApi;
use bot_command::BotCommandApi;
use chat::ChatApi;
use context::AppContext;
use health::HealthApi;
@@ -29,8 +31,8 @@ use settings::SettingsApi;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::slack::SlackWebhookContext;
use crate::whatsapp::WhatsAppWebhookContext;
use crate::chat::transport::slack::SlackWebhookContext;
use crate::chat::transport::whatsapp::WhatsAppWebhookContext;
const DEFAULT_PORT: u16 = 3001;
@@ -83,8 +85,8 @@ pub fn build_routes(
if let Some(wa_ctx) = whatsapp_ctx {
route = route.at(
"/webhook/whatsapp",
get(crate::whatsapp::webhook_verify)
.post(crate::whatsapp::webhook_receive)
get(crate::chat::transport::whatsapp::webhook_verify)
.post(crate::chat::transport::whatsapp::webhook_receive)
.data(wa_ctx),
);
}
@@ -93,11 +95,11 @@ pub fn build_routes(
route = route
.at(
"/webhook/slack",
post(crate::slack::webhook_receive).data(sl_ctx.clone()),
post(crate::chat::transport::slack::webhook_receive).data(sl_ctx.clone()),
)
.at(
"/webhook/slack/command",
post(crate::slack::slash_command_receive).data(sl_ctx),
post(crate::chat::transport::slack::slash_command_receive).data(sl_ctx),
);
}
@@ -113,6 +115,7 @@ type ApiTuple = (
AgentsApi,
SettingsApi,
HealthApi,
BotCommandApi,
);
type ApiService = OpenApiService<ApiTuple, ()>;
@@ -128,6 +131,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx: ctx.clone() },
HealthApi,
BotCommandApi { ctx: ctx.clone() },
);
let api_service =
@@ -140,8 +144,9 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() },
AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx },
SettingsApi { ctx: ctx.clone() },
HealthApi,
BotCommandApi { ctx },
);
let docs_service =

View File

@@ -39,6 +39,7 @@ impl ProjectApi {
payload.0.path,
&self.ctx.state,
self.ctx.store.as_ref(),
self.ctx.agents.port(),
)
.await
.map_err(bad_request)?;

View File

@@ -183,6 +183,18 @@ pub fn add_criterion_to_file(
Ok(())
}
/// Encode a string value as a YAML scalar.
///
/// Booleans (`true`/`false`) and integers are written as native YAML types (unquoted).
/// Everything else is written as a quoted string to avoid ambiguity.
fn yaml_encode_scalar(value: &str) -> String {
match value {
"true" | "false" => value.to_string(),
s if s.parse::<i64>().is_ok() => s.to_string(),
s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")),
}
}
/// Update the user story text and/or description in a story file.
///
/// At least one of `user_story` or `description` must be provided.
@@ -209,7 +221,7 @@ pub fn update_story_in_file(
if let Some(fields) = front_matter {
for (key, value) in fields {
let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', ""));
let yaml_value = yaml_encode_scalar(value);
contents = set_front_matter_field(&contents, key, &yaml_value);
}
}
@@ -589,4 +601,55 @@ mod tests {
let contents = fs::read_to_string(&filepath).unwrap();
assert!(contents.contains("agent: \"dev\""));
}
#[test]
fn update_story_bool_front_matter_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".storkit/work/2_current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("27_test.md");
fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap();
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), "false".to_string());
update_story_in_file(tmp.path(), "27_test", None, None, Some(&fields)).unwrap();
let result = fs::read_to_string(&filepath).unwrap();
assert!(result.contains("blocked: false"), "bool should be unquoted: {result}");
assert!(!result.contains("blocked: \"false\""), "bool must not be quoted: {result}");
}
#[test]
fn update_story_integer_front_matter_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".storkit/work/2_current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("28_test.md");
fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap();
let mut fields = HashMap::new();
fields.insert("retry_count".to_string(), "0".to_string());
update_story_in_file(tmp.path(), "28_test", None, None, Some(&fields)).unwrap();
let result = fs::read_to_string(&filepath).unwrap();
assert!(result.contains("retry_count: 0"), "integer should be unquoted: {result}");
assert!(!result.contains("retry_count: \"0\""), "integer must not be quoted: {result}");
}
#[test]
fn update_story_bool_front_matter_parseable_after_write() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".storkit/work/2_current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("29_test.md");
fs::write(&filepath, "---\nname: My Story\n---\n\nNo sections.\n").unwrap();
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), "false".to_string());
update_story_in_file(tmp.path(), "29_test", None, None, Some(&fields)).unwrap();
let contents = fs::read_to_string(&filepath).unwrap();
let meta = parse_front_matter(&contents).expect("front matter should parse");
assert_eq!(meta.name.as_deref(), Some("My Story"), "name preserved after writing bool field");
}
}

View File

@@ -158,9 +158,10 @@ impl From<WatcherEvent> for Option<WsResponse> {
}),
WatcherEvent::ConfigChanged => Some(WsResponse::AgentConfigChanged),
WatcherEvent::AgentStateChanged => Some(WsResponse::AgentStateChanged),
// MergeFailure is handled by the Matrix notification listener only;
// no WebSocket message is needed for the frontend.
// MergeFailure and RateLimitWarning are handled by the chat notification
// listener only; no WebSocket message is needed for the frontend.
WatcherEvent::MergeFailure { .. } => None,
WatcherEvent::RateLimitWarning { .. } => None,
}
}
}

View File

@@ -11,6 +11,13 @@ const KEY_KNOWN_PROJECTS: &str = "known_projects";
const STORY_KIT_README: &str = include_str!("../../../.storkit/README.md");
const BOT_TOML_MATRIX_EXAMPLE: &str = include_str!("../../../.storkit/bot.toml.matrix.example");
const BOT_TOML_WHATSAPP_META_EXAMPLE: &str =
include_str!("../../../.storkit/bot.toml.whatsapp-meta.example");
const BOT_TOML_WHATSAPP_TWILIO_EXAMPLE: &str =
include_str!("../../../.storkit/bot.toml.whatsapp-twilio.example");
const BOT_TOML_SLACK_EXAMPLE: &str = include_str!("../../../.storkit/bot.toml.slack.example");
const STORY_KIT_CONTEXT: &str = "<!-- storkit:scaffold-template -->\n\
# Project Context\n\
\n\
@@ -110,7 +117,7 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .storkit/README.md to understand the dev process. Follow the workflow through implementation and verification. 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.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits."
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .storkit/README.md to understand the dev process. Follow the workflow through implementation and verification. 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.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits.\n\nIf `script/test` still contains the generic 'No tests configured' stub, update it to run the project's actual test suite before starting implementation."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Commit all your work before finishing. Do not accept stories, move them to archived, or merge to master."
[[agent]]
@@ -184,37 +191,58 @@ pub fn detect_components_toml(root: &Path) -> String {
}
if sections.is_empty() {
// No tech stack markers detected — emit two example components so that
// the scaffold is immediately usable and agents can see the expected
// format. The ONBOARDING_PROMPT instructs the chat agent to inspect
// the project and replace these placeholders with real definitions.
// No tech stack markers detected — emit a single generic component
// with an empty setup list. The ONBOARDING_PROMPT instructs the chat
// agent to inspect the project and replace this with real definitions.
sections.push(
"# EXAMPLE: Replace with your actual backend component.\n\
# Common patterns: \"cargo check\" (Rust), \"go build ./...\" (Go),\n\
# \"python -m pytest\" (Python), \"mvn verify\" (Java)\n\
[[component]]\n\
name = \"backend\"\n\
path = \".\"\n\
setup = [\"cargo check\"]\n\
teardown = []\n"
.to_string(),
);
sections.push(
"# EXAMPLE: Replace with your actual frontend component.\n\
# Common patterns: \"pnpm install\" (pnpm), \"npm install\" (npm),\n\
# \"yarn\" (Yarn), \"bun install\" (Bun)\n\
[[component]]\n\
name = \"frontend\"\n\
path = \".\"\n\
setup = [\"pnpm install\"]\n\
teardown = []\n"
.to_string(),
"[[component]]\nname = \"app\"\npath = \".\"\nsetup = []\n".to_string(),
);
}
sections.join("\n")
}
/// Generate `script/test` content for a new project at `root`.
///
/// Inspects well-known marker files to identify which tech stacks are present
/// and emits the appropriate test commands. Multi-stack projects get combined
/// commands run sequentially. Falls back to the generic stub when no markers
/// are found so the scaffold is always valid.
pub fn detect_script_test(root: &Path) -> String {
let mut commands: Vec<&str> = Vec::new();
if root.join("Cargo.toml").exists() {
commands.push("cargo test");
}
if root.join("package.json").exists() {
if root.join("pnpm-lock.yaml").exists() {
commands.push("pnpm test");
} else {
commands.push("npm test");
}
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
commands.push("pytest");
}
if root.join("go.mod").exists() {
commands.push("go test ./...");
}
if commands.is_empty() {
return STORY_KIT_SCRIPT_TEST.to_string();
}
let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string();
for cmd in commands {
script.push_str(cmd);
script.push('\n');
}
script
}
/// Generate a complete `project.toml` for a new project at `root`.
///
/// Detects the tech stack via [`detect_components_toml`] and prepends the
@@ -329,6 +357,11 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
"worktrees/",
"merge_workspace/",
"coverage/",
"work/2_current/",
"work/3_qa/",
"work/4_merge/",
"logs/",
"token_usage.jsonl",
];
let gitignore_path = root.join(".storkit").join(".gitignore");
@@ -369,7 +402,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
/// the project root and git does not support `../` patterns in `.gitignore`
/// files, so they cannot be expressed in `.storkit/.gitignore`.
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
let entries = [".storkit_port", "store.json"];
let entries = [".storkit_port", "store.json", ".mcp.json"];
let gitignore_path = root.join(".gitignore");
let existing = if gitignore_path.exists() {
@@ -404,7 +437,7 @@ fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
Ok(())
}
fn scaffold_story_kit(root: &Path) -> Result<(), String> {
fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
let story_kit_root = root.join(".storkit");
let specs_root = story_kit_root.join("specs");
let tech_root = specs_root.join("tech");
@@ -437,9 +470,36 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?;
write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?;
write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?;
write_script_if_missing(&script_root.join("test"), STORY_KIT_SCRIPT_TEST)?;
let script_test_content = detect_script_test(root);
write_script_if_missing(&script_root.join("test"), &script_test_content)?;
write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?;
// Write per-transport bot.toml example files so users can see all options.
write_file_if_missing(
&story_kit_root.join("bot.toml.matrix.example"),
BOT_TOML_MATRIX_EXAMPLE,
)?;
write_file_if_missing(
&story_kit_root.join("bot.toml.whatsapp-meta.example"),
BOT_TOML_WHATSAPP_META_EXAMPLE,
)?;
write_file_if_missing(
&story_kit_root.join("bot.toml.whatsapp-twilio.example"),
BOT_TOML_WHATSAPP_TWILIO_EXAMPLE,
)?;
write_file_if_missing(
&story_kit_root.join("bot.toml.slack.example"),
BOT_TOML_SLACK_EXAMPLE,
)?;
// Write .mcp.json at the project root so agents can find the MCP server.
// Only written when missing — never overwrites an existing file, because
// the port is environment-specific and must not clobber a running instance.
let mcp_content = format!(
"{{\n \"mcpServers\": {{\n \"storkit\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n"
);
write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?;
// Create .claude/settings.json with sensible permission defaults so that
// Claude Code (both agents and web UI chat) can operate without constant
// permission prompts.
@@ -505,14 +565,14 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
Ok(())
}
async fn ensure_project_root_with_story_kit(path: PathBuf) -> Result<(), String> {
async fn ensure_project_root_with_story_kit(path: PathBuf, port: u16) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
if !path.exists() {
fs::create_dir_all(&path)
.map_err(|e| format!("Failed to create project directory: {}", e))?;
}
if !path.join(".storkit").is_dir() {
scaffold_story_kit(&path)?;
scaffold_story_kit(&path, port)?;
}
Ok(())
})
@@ -524,10 +584,11 @@ pub async fn open_project(
path: String,
state: &SessionState,
store: &dyn StoreOps,
port: u16,
) -> Result<String, String> {
let p = PathBuf::from(&path);
ensure_project_root_with_story_kit(p.clone()).await?;
ensure_project_root_with_story_kit(p.clone(), port).await?;
validate_project_path(p.clone()).await?;
{
@@ -816,7 +877,7 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
let result = open_project(project_dir.to_string_lossy().to_string(), &state, &store).await;
let result = open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001).await;
assert!(result.is_ok());
let root = state.get_project_root().unwrap();
@@ -824,25 +885,79 @@ mod tests {
}
#[tokio::test]
async fn open_project_does_not_write_mcp_json() {
// open_project must NOT overwrite .mcp.json — test servers started by QA
// agents share the real project root, so writing here would clobber the
// root .mcp.json with the wrong port. .mcp.json is written once during
// worktree creation (worktree.rs) and should not be touched again.
async fn open_project_does_not_overwrite_existing_mcp_json() {
// scaffold must NOT overwrite .mcp.json when it already exists — QA
// test servers share the real project root, and re-writing would
// clobber the file with the wrong port.
let dir = tempdir().unwrap();
let project_dir = dir.path().join("myproject");
fs::create_dir_all(&project_dir).unwrap();
// Pre-write .mcp.json with a different port to simulate an already-configured project.
let mcp_path = project_dir.join(".mcp.json");
fs::write(&mcp_path, "{\"existing\": true}").unwrap();
let store = make_store(&dir);
let state = SessionState::default();
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
.await
.unwrap();
assert_eq!(
fs::read_to_string(&mcp_path).unwrap(),
"{\"existing\": true}",
"open_project must not overwrite an existing .mcp.json"
);
}
#[tokio::test]
async fn open_project_writes_mcp_json_when_missing() {
let dir = tempdir().unwrap();
let project_dir = dir.path().join("myproject");
fs::create_dir_all(&project_dir).unwrap();
let store = make_store(&dir);
let state = SessionState::default();
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
.await
.unwrap();
let mcp_path = project_dir.join(".mcp.json");
assert!(mcp_path.exists(), "open_project should write .mcp.json for new projects");
let content = fs::read_to_string(&mcp_path).unwrap();
assert!(content.contains("3001"), "mcp.json should reference the server port");
assert!(content.contains("localhost"), "mcp.json should reference localhost");
}
/// Regression test for bug 371: no-arg `storkit` in empty directory skips scaffold.
/// `open_project` on a directory without `.storkit/` must create all required scaffold
/// files — the same files that `storkit .` produces.
#[tokio::test]
async fn open_project_on_empty_dir_creates_full_scaffold() {
let dir = tempdir().unwrap();
let project_dir = dir.path().join("myproject");
fs::create_dir_all(&project_dir).unwrap();
let store = make_store(&dir);
let state = SessionState::default();
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
.await
.unwrap();
assert!(
!mcp_path.exists(),
"open_project must not write .mcp.json — that would overwrite the root with the wrong port"
project_dir.join(".storkit/project.toml").exists(),
"open_project must create .storkit/project.toml"
);
assert!(
project_dir.join(".mcp.json").exists(),
"open_project must create .mcp.json"
);
assert!(
project_dir.join("CLAUDE.md").exists(),
"open_project must create CLAUDE.md"
);
assert!(
project_dir.join("script/test").exists(),
"open_project must create script/test"
);
}
@@ -898,7 +1013,7 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
.await
.unwrap();
@@ -1071,7 +1186,7 @@ mod tests {
#[test]
fn scaffold_story_kit_creates_structure() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert!(dir.path().join(".storkit/README.md").exists());
assert!(dir.path().join(".storkit/project.toml").exists());
@@ -1085,7 +1200,7 @@ mod tests {
#[test]
fn scaffold_story_kit_creates_work_pipeline_dirs() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let stages = [
"1_backlog",
@@ -1109,7 +1224,7 @@ mod tests {
#[test]
fn scaffold_story_kit_project_toml_has_coder_qa_mergemaster() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
assert!(content.contains("[[agent]]"));
@@ -1122,7 +1237,7 @@ mod tests {
#[test]
fn scaffold_context_is_blank_template_not_story_kit_content() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/specs/00_CONTEXT.md")).unwrap();
assert!(content.contains("<!-- storkit:scaffold-template -->"));
@@ -1138,7 +1253,7 @@ mod tests {
#[test]
fn scaffold_stack_is_blank_template_not_story_kit_content() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/specs/tech/STACK.md")).unwrap();
assert!(content.contains("<!-- storkit:scaffold-template -->"));
@@ -1157,7 +1272,7 @@ mod tests {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let script_test = dir.path().join("script/test");
assert!(script_test.exists(), "script/test should be created");
@@ -1175,7 +1290,7 @@ mod tests {
fs::create_dir_all(readme.parent().unwrap()).unwrap();
fs::write(&readme, "custom content").unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
}
@@ -1183,13 +1298,13 @@ mod tests {
#[test]
fn scaffold_story_kit_is_idempotent() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let readme_content = fs::read_to_string(dir.path().join(".storkit/README.md")).unwrap();
let toml_content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
// Run again — must not change content or add duplicate .gitignore entries
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join(".storkit/README.md")).unwrap(),
@@ -1237,7 +1352,7 @@ mod tests {
.status()
.unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// Only 1 commit should exist — scaffold must not commit into an existing repo
let log_output = std::process::Command::new("git")
@@ -1256,7 +1371,7 @@ mod tests {
#[test]
fn scaffold_creates_story_kit_gitignore_with_relative_entries() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// .storkit/.gitignore must contain relative patterns for files under .storkit/
let sk_content = fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap();
@@ -1287,7 +1402,7 @@ mod tests {
)
.unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap();
let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count();
@@ -1303,7 +1418,7 @@ mod tests {
#[test]
fn scaffold_creates_claude_md_at_project_root() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let claude_md = dir.path().join("CLAUDE.md");
assert!(
@@ -1332,7 +1447,7 @@ mod tests {
let claude_md = dir.path().join("CLAUDE.md");
fs::write(&claude_md, "custom CLAUDE.md content").unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(&claude_md).unwrap(),
@@ -1341,6 +1456,46 @@ mod tests {
);
}
#[test]
fn scaffold_story_kit_writes_mcp_json_with_port() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 4242).unwrap();
let mcp_path = dir.path().join(".mcp.json");
assert!(mcp_path.exists(), ".mcp.json should be created by scaffold");
let content = fs::read_to_string(&mcp_path).unwrap();
assert!(content.contains("4242"), ".mcp.json should reference the given port");
assert!(content.contains("localhost"), ".mcp.json should reference localhost");
assert!(content.contains("storkit"), ".mcp.json should name the storkit server");
}
#[test]
fn scaffold_story_kit_does_not_overwrite_existing_mcp_json() {
let dir = tempdir().unwrap();
let mcp_path = dir.path().join(".mcp.json");
fs::write(&mcp_path, "{\"custom\": true}").unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(&mcp_path).unwrap(),
"{\"custom\": true}",
"scaffold should not overwrite an existing .mcp.json"
);
}
#[test]
fn scaffold_gitignore_includes_mcp_json() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let root_gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
root_gitignore.contains(".mcp.json"),
"root .gitignore should include .mcp.json (port is environment-specific)"
);
}
// --- open_project scaffolding ---
#[tokio::test]
@@ -1351,7 +1506,7 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
.await
.unwrap();
@@ -1370,7 +1525,7 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
.await
.unwrap();
@@ -1426,10 +1581,19 @@ mod tests {
toml.contains("[[component]]"),
"should always emit at least one component"
);
// The fallback should include example backend and frontend entries
// Fallback should use a generic app component with empty setup
assert!(
toml.contains("name = \"backend\"") || toml.contains("name = \"frontend\""),
"fallback should include example component entries"
toml.contains("name = \"app\""),
"fallback should use generic 'app' component name"
);
assert!(
toml.contains("setup = []"),
"fallback should have empty setup list"
);
// Must not contain Rust-specific commands in a non-Rust project
assert!(
!toml.contains("cargo"),
"fallback must not contain Rust-specific commands"
);
}
@@ -1516,6 +1680,38 @@ mod tests {
assert!(toml.contains("setup = [\"bundle install\"]"));
}
// --- Bug 375: no Rust-specific commands for non-Rust projects ---
#[test]
fn no_rust_commands_in_go_project() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
let toml = detect_components_toml(dir.path());
assert!(!toml.contains("cargo"), "go project must not contain cargo commands");
assert!(toml.contains("go build"), "go project must use Go tooling");
}
#[test]
fn no_rust_commands_in_node_project() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let toml = detect_components_toml(dir.path());
assert!(!toml.contains("cargo"), "node project must not contain cargo commands");
assert!(toml.contains("npm install"), "node project must use npm tooling");
}
#[test]
fn no_rust_commands_when_no_stack_detected() {
let dir = tempdir().unwrap();
let toml = detect_components_toml(dir.path());
assert!(!toml.contains("cargo"), "unknown stack must not contain cargo commands");
// setup list must be empty
assert!(toml.contains("setup = []"), "unknown stack must have empty setup list");
}
#[test]
fn detect_multiple_markers_generates_multiple_components() {
let dir = tempdir().unwrap();
@@ -1544,6 +1740,124 @@ mod tests {
assert!(!toml.contains("name = \"app\""));
}
// --- detect_script_test ---
#[test]
fn detect_script_test_no_markers_returns_stub() {
let dir = tempdir().unwrap();
let script = detect_script_test(dir.path());
assert!(
script.contains("No tests configured"),
"fallback should contain the generic stub message"
);
assert!(script.starts_with("#!/usr/bin/env bash"));
}
#[test]
fn detect_script_test_cargo_toml_adds_cargo_test() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let script = detect_script_test(dir.path());
assert!(script.contains("cargo test"), "Rust project should run cargo test");
assert!(!script.contains("No tests configured"));
}
#[test]
fn detect_script_test_package_json_npm_adds_npm_test() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let script = detect_script_test(dir.path());
assert!(script.contains("npm test"), "Node project without pnpm-lock should run npm test");
assert!(!script.contains("No tests configured"));
}
#[test]
fn detect_script_test_package_json_pnpm_adds_pnpm_test() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
let script = detect_script_test(dir.path());
assert!(script.contains("pnpm test"), "Node project with pnpm-lock should run pnpm test");
// "pnpm test" is a substring of itself; verify there's no bare "npm test" line
assert!(!script.lines().any(|l| l.trim() == "npm test"), "should not use npm when pnpm-lock.yaml is present");
}
#[test]
fn detect_script_test_pyproject_toml_adds_pytest() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("pyproject.toml"), "[project]\nname = \"x\"\n").unwrap();
let script = detect_script_test(dir.path());
assert!(script.contains("pytest"), "Python project should run pytest");
assert!(!script.contains("No tests configured"));
}
#[test]
fn detect_script_test_requirements_txt_adds_pytest() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap();
let script = detect_script_test(dir.path());
assert!(script.contains("pytest"), "Python project (requirements.txt) should run pytest");
}
#[test]
fn detect_script_test_go_mod_adds_go_test() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
let script = detect_script_test(dir.path());
assert!(script.contains("go test ./..."), "Go project should run go test ./...");
assert!(!script.contains("No tests configured"));
}
#[test]
fn detect_script_test_multi_stack_combines_commands() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let script = detect_script_test(dir.path());
assert!(script.contains("go test ./..."), "multi-stack should include Go test command");
assert!(script.contains("npm test"), "multi-stack should include Node test command");
}
#[test]
fn detect_script_test_output_starts_with_shebang() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let script = detect_script_test(dir.path());
assert!(
script.starts_with("#!/usr/bin/env bash\nset -euo pipefail\n"),
"generated script should start with bash shebang and set -euo pipefail"
);
}
#[test]
fn scaffold_script_test_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"myapp\"\n").unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
assert!(content.contains("cargo test"), "Rust project scaffold should set cargo test in script/test");
assert!(!content.contains("No tests configured"), "should not use stub when stack is detected");
}
#[test]
fn scaffold_script_test_fallback_stub_when_no_stack() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
assert!(content.contains("No tests configured"), "unknown stack should use the generic stub");
}
// --- generate_project_toml ---
#[test]
@@ -1572,7 +1886,7 @@ mod tests {
)
.unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
assert!(
@@ -1592,17 +1906,21 @@ mod tests {
#[test]
fn scaffold_project_toml_fallback_when_no_stack_detected() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should always have at least one component"
);
// Fallback emits example components so the scaffold is immediately usable
// Fallback uses generic app component with empty setup — no Rust-specific commands
assert!(
content.contains("name = \"backend\"") || content.contains("name = \"frontend\""),
"fallback should include example component entries"
content.contains("name = \"app\""),
"fallback should use generic 'app' component name"
);
assert!(
!content.contains("cargo"),
"fallback must not contain Rust-specific commands for non-Rust projects"
);
}
@@ -1614,7 +1932,7 @@ mod tests {
let existing = "[[component]]\nname = \"custom\"\npath = \".\"\nsetup = [\"make build\"]\n";
fs::write(sk_dir.join("project.toml"), existing).unwrap();
scaffold_story_kit(dir.path()).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(sk_dir.join("project.toml")).unwrap();
assert_eq!(

View File

@@ -77,10 +77,8 @@ struct FrontMatter {
merge_failure: Option<String>,
agent: Option<String>,
review_hold: Option<bool>,
/// New configurable QA mode field: "human", "server", or "agent".
/// Configurable QA mode field: "human", "server", or "agent".
qa: Option<String>,
/// Legacy boolean field — mapped to `qa: human` (true) or ignored (false/absent).
manual_qa: Option<bool>,
/// Number of times this story has been retried at its current pipeline stage.
retry_count: Option<u32>,
/// When `true`, auto-assign will skip this story (retry limit exceeded).
@@ -113,12 +111,7 @@ pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaErro
}
fn build_metadata(front: FrontMatter) -> StoryMetadata {
// Resolve qa mode: prefer the new `qa` field, fall back to legacy `manual_qa`.
let qa = if let Some(ref qa_str) = front.qa {
QaMode::from_str(qa_str)
} else {
front.manual_qa.and_then(|v| if v { Some(QaMode::Human) } else { None })
};
let qa = front.qa.as_deref().and_then(QaMode::from_str);
StoryMetadata {
name: front.name,
@@ -513,27 +506,6 @@ workflow: tdd
assert_eq!(meta.qa, None);
}
#[test]
fn legacy_manual_qa_true_maps_to_human() {
let input = "---\nname: Story\nmanual_qa: true\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Human));
}
#[test]
fn legacy_manual_qa_false_maps_to_none() {
let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, None);
}
#[test]
fn qa_field_takes_precedence_over_manual_qa() {
let input = "---\nname: Story\nqa: server\nmanual_qa: true\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Server));
}
#[test]
fn resolve_qa_mode_uses_file_value() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -59,6 +59,14 @@ pub enum WatcherEvent {
/// Human-readable description of the failure.
reason: String,
},
/// An agent hit an API rate limit.
/// Triggers a warning notification to configured chat rooms.
RateLimitWarning {
/// Work item ID the agent is working on.
story_id: String,
/// Name of the agent that hit the rate limit.
agent_name: String,
},
}
/// Return `true` if `path` is the root-level `.storkit/project.toml`, i.e.

View File

@@ -9,13 +9,10 @@ mod http;
mod io;
mod llm;
pub mod log_buffer;
mod matrix;
mod chat;
pub mod rebuild;
pub mod slack;
mod state;
mod store;
pub mod transport;
pub mod whatsapp;
mod workflow;
mod worktree;
@@ -24,6 +21,7 @@ use crate::http::build_routes;
use crate::http::context::AppContext;
use crate::http::{remove_port_file, resolve_port, write_port_file};
use crate::io::fs::find_story_kit_root;
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
use crate::state::SessionState;
use crate::store::JsonFileStore;
use crate::workflow::WorkflowState;
@@ -33,6 +31,32 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::broadcast;
/// What the first CLI argument means.
#[derive(Debug, PartialEq)]
enum CliDirective {
/// `--help` / `-h`
Help,
/// `--version` / `-V`
Version,
/// An unrecognised flag (starts with `-`).
UnknownFlag(String),
/// A positional path argument.
Path,
/// No arguments at all.
None,
}
/// Inspect the raw CLI arguments and return the directive they imply.
fn classify_cli_args(args: &[String]) -> CliDirective {
match args.first().map(String::as_str) {
None => CliDirective::None,
Some("--help" | "-h") => CliDirective::Help,
Some("--version" | "-V") => CliDirective::Version,
Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()),
Some(_) => CliDirective::Path,
}
}
/// Resolve the optional positional path argument (everything after the binary
/// name) into an absolute `PathBuf`. Returns `None` when no argument was
/// supplied so that the caller can fall back to the auto-detect behaviour.
@@ -52,8 +76,61 @@ async fn main() -> Result<(), std::io::Error> {
// Collect CLI args, skipping the binary name (argv[0]).
let cli_args: Vec<String> = std::env::args().skip(1).collect();
// Handle CLI flags before treating anything as a project path.
match classify_cli_args(&cli_args) {
CliDirective::Help => {
println!("storkit [PATH]");
println!();
println!("Serve a storkit project.");
println!();
println!("USAGE:");
println!(" storkit [PATH]");
println!();
println!("ARGS:");
println!(
" PATH Path to an existing project directory. \
If omitted, storkit searches parent directories for a .storkit/ root."
);
println!();
println!("OPTIONS:");
println!(" -h, --help Print this help and exit");
println!(" -V, --version Print the version and exit");
std::process::exit(0);
}
CliDirective::Version => {
println!("storkit {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
CliDirective::UnknownFlag(flag) => {
eprintln!("error: unknown option: {flag}");
eprintln!("Run 'storkit --help' for usage.");
std::process::exit(1);
}
CliDirective::Path | CliDirective::None => {}
}
let explicit_path = parse_project_path_arg(&cli_args, &cwd);
// When a path is given explicitly on the CLI, it must already exist as a
// directory. We do not create directories from the command line.
if let Some(ref path) = explicit_path {
if !path.exists() {
eprintln!(
"error: path does not exist: {}",
path.display()
);
std::process::exit(1);
}
if !path.is_dir() {
eprintln!(
"error: path is not a directory: {}",
path.display()
);
std::process::exit(1);
}
}
if let Some(explicit_root) = explicit_path {
// An explicit path was given on the command line.
// Open it directly — scaffold .storkit/ if it is missing — and
@@ -62,6 +139,7 @@ async fn main() -> Result<(), std::io::Error> {
explicit_root.to_string_lossy().to_string(),
&app_state,
store.as_ref(),
port,
)
.await
{
@@ -84,6 +162,7 @@ async fn main() -> Result<(), std::io::Error> {
project_root.to_string_lossy().to_string(),
&app_state,
store.as_ref(),
port,
)
.await
.unwrap_or_else(|e| {
@@ -95,13 +174,19 @@ async fn main() -> Result<(), std::io::Error> {
config::ProjectConfig::load(&project_root)
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
} else {
// No .storkit/ found — fall back to cwd so existing behaviour is preserved.
// TRACE:MERGE-DEBUG — remove once root cause is found
slog!(
"[MERGE-DEBUG] main: no .storkit/ found, falling back to cwd {:?}",
cwd
);
*app_state.project_root.lock().unwrap() = Some(cwd.clone());
// No .storkit/ found in cwd or parents — scaffold cwd as a new
// project, exactly like `storkit .` does.
io::fs::open_project(
cwd.to_string_lossy().to_string(),
&app_state,
store.as_ref(),
port,
)
.await
.unwrap_or_else(|e| {
slog!("Warning: failed to scaffold project at {cwd:?}: {e}");
cwd.to_string_lossy().to_string()
});
}
}
@@ -177,41 +262,41 @@ async fn main() -> Result<(), std::io::Error> {
let startup_reconciliation_tx = reconciliation_tx.clone();
// Clone for shutdown cleanup — kill orphaned PTY children before exiting.
let agents_for_shutdown = Arc::clone(&agents);
let ctx = AppContext {
state: app_state,
store,
workflow,
agents,
watcher_tx,
reconciliation_tx,
perm_tx,
perm_rx,
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
};
// Build WhatsApp webhook context if bot.toml configures transport = "whatsapp".
let whatsapp_ctx: Option<Arc<whatsapp::WhatsAppWebhookContext>> = startup_root
let whatsapp_ctx: Option<Arc<chat::transport::whatsapp::WhatsAppWebhookContext>> = startup_root
.as_ref()
.and_then(|root| matrix::BotConfig::load(root))
.and_then(|root| chat::transport::matrix::BotConfig::load(root))
.filter(|cfg| cfg.transport == "whatsapp")
.map(|cfg| {
let provider = cfg.whatsapp_provider.clone();
let transport: Arc<dyn crate::chat::ChatTransport> =
if provider == "twilio" {
Arc::new(chat::transport::whatsapp::TwilioWhatsAppTransport::new(
cfg.twilio_account_sid.clone().unwrap_or_default(),
cfg.twilio_auth_token.clone().unwrap_or_default(),
cfg.twilio_whatsapp_number.clone().unwrap_or_default(),
))
} else {
let template_name = cfg
.whatsapp_notification_template
.clone()
.unwrap_or_else(|| "pipeline_notification".to_string());
let transport = Arc::new(whatsapp::WhatsAppTransport::new(
Arc::new(chat::transport::whatsapp::WhatsAppTransport::new(
cfg.whatsapp_phone_number_id.clone().unwrap_or_default(),
cfg.whatsapp_access_token.clone().unwrap_or_default(),
template_name,
));
))
};
let bot_name = cfg
.display_name
.clone()
.unwrap_or_else(|| "Assistant".to_string());
let root = startup_root.clone().unwrap();
let history = whatsapp::load_whatsapp_history(&root);
Arc::new(whatsapp::WhatsAppWebhookContext {
let history = chat::transport::whatsapp::load_whatsapp_history(&root);
Arc::new(chat::transport::whatsapp::WhatsAppWebhookContext {
verify_token: cfg.whatsapp_verify_token.clone().unwrap_or_default(),
provider,
transport,
project_root: root,
agents: Arc::clone(&startup_agents),
@@ -220,17 +305,17 @@ async fn main() -> Result<(), std::io::Error> {
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
history_size: cfg.history_size,
window_tracker: Arc::new(whatsapp::MessagingWindowTracker::new()),
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
})
});
// Build Slack webhook context if bot.toml configures transport = "slack".
let slack_ctx: Option<Arc<slack::SlackWebhookContext>> = startup_root
let slack_ctx: Option<Arc<chat::transport::slack::SlackWebhookContext>> = startup_root
.as_ref()
.and_then(|root| matrix::BotConfig::load(root))
.and_then(|root| chat::transport::matrix::BotConfig::load(root))
.filter(|cfg| cfg.transport == "slack")
.map(|cfg| {
let transport = Arc::new(slack::SlackTransport::new(
let transport = Arc::new(chat::transport::slack::SlackTransport::new(
cfg.slack_bot_token.clone().unwrap_or_default(),
));
let bot_name = cfg
@@ -238,10 +323,10 @@ async fn main() -> Result<(), std::io::Error> {
.clone()
.unwrap_or_else(|| "Assistant".to_string());
let root = startup_root.clone().unwrap();
let history = slack::load_slack_history(&root);
let history = chat::transport::slack::load_slack_history(&root);
let channel_ids: std::collections::HashSet<String> =
cfg.slack_channel_ids.iter().cloned().collect();
Arc::new(slack::SlackWebhookContext {
Arc::new(chat::transport::slack::SlackWebhookContext {
signing_secret: cfg.slack_signing_secret.clone().unwrap_or_default(),
transport,
project_root: root,
@@ -255,17 +340,64 @@ async fn main() -> Result<(), std::io::Error> {
})
});
let app = build_routes(ctx, whatsapp_ctx, slack_ctx);
// Build a best-effort shutdown notifier for webhook-based transports.
//
// • Slack: channels are fixed at startup (channel_ids from bot.toml).
// • WhatsApp: active senders are tracked at runtime in ambient_rooms.
// We keep the WhatsApp context Arc so we can read the rooms at shutdown.
// • Matrix: the bot task manages its own announcement via matrix_shutdown_tx.
let bot_shutdown_notifier: Option<Arc<BotShutdownNotifier>> =
if let Some(ref ctx) = slack_ctx {
let channels: Vec<String> = ctx.channel_ids.iter().cloned().collect();
Some(Arc::new(BotShutdownNotifier::new(
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
channels,
ctx.bot_name.clone(),
)))
} else {
None
};
// Retain a reference to the WhatsApp context for shutdown notifications.
// At shutdown time we read ambient_rooms to get the current set of active senders.
let whatsapp_ctx_for_shutdown: Option<Arc<chat::transport::whatsapp::WhatsAppWebhookContext>> =
whatsapp_ctx.clone();
// Watch channel: signals the Matrix bot task to send a shutdown announcement.
// `None` initial value means "server is running".
let (matrix_shutdown_tx, matrix_shutdown_rx) =
tokio::sync::watch::channel::<Option<ShutdownReason>>(None);
let matrix_shutdown_tx = Arc::new(matrix_shutdown_tx);
let matrix_shutdown_tx_for_rebuild = Arc::clone(&matrix_shutdown_tx);
let ctx = AppContext {
state: app_state,
store,
workflow,
agents,
watcher_tx,
reconciliation_tx,
perm_tx,
perm_rx,
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
bot_shutdown: bot_shutdown_notifier.clone(),
matrix_shutdown_tx: Some(Arc::clone(&matrix_shutdown_tx)),
};
let app = build_routes(ctx, whatsapp_ctx.clone(), slack_ctx.clone());
// Optional Matrix bot: connect to the homeserver and start listening for
// messages if `.storkit/bot.toml` is present and enabled.
if let Some(ref root) = startup_root {
matrix::spawn_bot(
chat::transport::matrix::spawn_bot(
root,
watcher_tx_for_bot,
perm_rx_for_bot,
Arc::clone(&startup_agents),
matrix_shutdown_rx,
);
} else {
// Keep the receiver alive (drop it) so the sender never errors.
drop(matrix_shutdown_rx);
}
// On startup:
@@ -282,7 +414,8 @@ async fn main() -> Result<(), std::io::Error> {
startup_agents.auto_assign_available_work(&root).await;
});
}
let addr = format!("127.0.0.1:{port}");
let host = std::env::var("STORKIT_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let addr = format!("{host}:{port}");
println!(
"\x1b[95;1m ____ _ _ ___ _ \n / ___|| |_ ___ _ __| | _|_ _| |_ \n \\___ \\| __/ _ \\| '__| |/ /| || __|\n ___) | || (_) | | | < | || |_ \n |____/ \\__\\___/|_| |_|\\_\\___|\\__|\n\x1b[0m"
@@ -295,6 +428,36 @@ async fn main() -> Result<(), std::io::Error> {
let result = Server::new(TcpListener::bind(&addr)).run(app).await;
// ── Shutdown notifications (best-effort) ─────────────────────────────
//
// The server is stopping (SIGINT / SIGTERM). Notify active bot channels
// so participants know the bot is going offline. We do this before killing
// PTY children so network I/O can still complete.
// Slack: notifier holds the fixed channel list.
if let Some(ref notifier) = bot_shutdown_notifier {
notifier.notify(ShutdownReason::Manual).await;
}
// WhatsApp: read the current set of ambient rooms and notify each sender.
if let Some(ref ctx) = whatsapp_ctx_for_shutdown {
let rooms: Vec<String> = ctx.ambient_rooms.lock().unwrap().iter().cloned().collect();
if !rooms.is_empty() {
let wa_notifier = BotShutdownNotifier::new(
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
rooms,
ctx.bot_name.clone(),
);
wa_notifier.notify(ShutdownReason::Manual).await;
}
}
// Matrix: signal the bot task and give it a short window to send its message.
let _ = matrix_shutdown_tx_for_rebuild.send(Some(ShutdownReason::Manual));
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
// ── Cleanup ──────────────────────────────────────────────────────────
// Kill all active PTY child processes before exiting to prevent orphaned
// Claude Code processes from running after the server restarts.
agents_for_shutdown.kill_all_children();
@@ -332,6 +495,61 @@ name = "coder"
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
}
// ── classify_cli_args ─────────────────────────────────────────────────
#[test]
fn classify_none_when_no_args() {
assert_eq!(classify_cli_args(&[]), CliDirective::None);
}
#[test]
fn classify_help_long() {
assert_eq!(
classify_cli_args(&["--help".to_string()]),
CliDirective::Help
);
}
#[test]
fn classify_help_short() {
assert_eq!(
classify_cli_args(&["-h".to_string()]),
CliDirective::Help
);
}
#[test]
fn classify_version_long() {
assert_eq!(
classify_cli_args(&["--version".to_string()]),
CliDirective::Version
);
}
#[test]
fn classify_version_short() {
assert_eq!(
classify_cli_args(&["-V".to_string()]),
CliDirective::Version
);
}
#[test]
fn classify_unknown_flag() {
assert_eq!(
classify_cli_args(&["--serve".to_string()]),
CliDirective::UnknownFlag("--serve".to_string())
);
}
#[test]
fn classify_path() {
assert_eq!(
classify_cli_args(&["/some/path".to_string()]),
CliDirective::Path
);
}
// ── parse_project_path_arg ────────────────────────────────────────────
#[test]

Some files were not shown because too many files have changed in this diff Show More