360 Commits

Author SHA1 Message Date
Dave
0a6de3717a fix: Chat.test.tsx type errors from 271 merge
Cast lastSendChatArgs through unknown to satisfy TypeScript's
strict type narrowing on the nullable union type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:33:54 +00:00
Dave
15645a2a3e Bump version to 0.3.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:31:19 +00:00
Dave
eab65de723 story-kit: create 281_story_matrix_bot_announces_itself_when_it_comes_online 2026-03-18 11:26:29 +00:00
Dave
81a5660f11 story-kit: merge 277 - bot uses configured display_name
Adds system_prompt parameter to chat_stream so the Matrix bot
passes "Your name is {name}" to Claude Code. Reads display_name
from bot.toml config. Resolved conflicts by integrating bot_name
into master's permission-handling code structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:23:50 +00:00
Dave
4bf01c6cca fix: session_id wiped on every trim, breaking --resume
The history trimming logic cleared session_id = None whenever entries
exceeded history_size. Since the history was always at capacity, every
new message wiped the session_id immediately after storing it. This
meant --resume was never passed to Claude Code, making every turn a
fresh conversation.

Fix: preserve session_id on trim. Claude Code's --resume loads the
full conversation from its own session transcript on disk, so trimming
our local tracking entries doesn't invalidate the session.

Also adds debug logging for session_id capture/storage (temporary).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:19:00 +00:00
Dave
a799009720 story-kit: done 279_story_auto_assign_should_respect_agent_stage_when_front_matter_specifies_agent 2026-03-18 11:10:41 +00:00
Dave
549c23bd77 story-kit: done 278_story_auto_assign_agents_to_pipeline_items_on_server_startup 2026-03-18 11:05:14 +00:00
Dave
34be4d1e75 story-kit: done 277_story_matrix_bot_uses_its_configured_name_instead_of_claude 2026-03-18 11:00:39 +00:00
Dave
a390861520 story-kit: done 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response 2026-03-18 10:55:58 +00:00
Dave
ce9bdbbb9d story-kit: done 266_story_matrix_bot_structured_conversation_history 2026-03-18 10:48:55 +00:00
Dave
5f4591f496 fix: update should_commit_stage test to match 5_done in COMMIT_WORTHY_STAGES
The test was asserting 5_done should NOT trigger commits, but commit 74dc42c
added 5_done to COMMIT_WORTHY_STAGES. Updated test to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:46:11 +00:00
Dave
dc7968ffbc story-kit: mergemaster conflict resolution, vite proxy fix, bug 279
- Upgrade mergemaster prompt to resolve complex conflicts itself
  instead of just reporting failure. Includes instructions to check
  git history and story files for context before resolving.
- Add proxy error handler to vite config to prevent crashes on
  backend ECONNREFUSED.
- Fix bug 279: auto-assign now checks that preferred agent's stage
  matches the pipeline stage. Coders won't be assigned to QA/merge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:44:10 +00:00
Dave
5fedd9130a story-kit: create 280_story_long_running_supervisor_agent_with_periodic_pipeline_polling 2026-03-18 10:39:51 +00:00
Dave
c7e371c124 story-kit: create 280_story_long_running_supervisor_agent_with_periodic_pipeline_polling 2026-03-18 10:36:20 +00:00
Dave
8748d7d49a story-kit: create 280_story_long_running_supervisor_agent_with_periodic_pipeline_polling 2026-03-18 10:34:18 +00:00
Dave
825d36c204 fix: remove stale 266 from archived (came from 271 branch history)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:30:47 +00:00
Dave
65a8feff17 story-kit: done 271_story_show_assigned_agent_in_expanded_work_item_view 2026-03-18 10:29:45 +00:00
Dave
60dabae795 story-kit: merge 271_story_show_assigned_agent_in_expanded_work_item_view
Adds assigned agent display to the expanded work item detail panel.
Resolved conflicts by keeping master versions of bot.rs (permission
handling), ChatInput.tsx, and fs.rs. Removed duplicate list_project_files
endpoint and tests from io.rs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:29:37 +00:00
Dave
1bae7bd223 story-kit: create 266_story_matrix_bot_structured_conversation_history 2026-03-18 10:21:55 +00:00
Dave
a0091e81f9 story-kit: create 266_story_matrix_bot_structured_conversation_history 2026-03-18 10:21:33 +00:00
Dave
beb5ea9f53 fix: revert broken auto-merge of 266 in bot.rs
The mergemaster's auto-resolver inserted a duplicate chat_stream call
inside the tokio::select! permission loop, producing mismatched braces.
The 266 merge didn't contribute useful code changes to bot.rs — the
session_id handling was already present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:20:40 +00:00
Dave
89e96dc0a6 story-kit: done 266_story_matrix_bot_structured_conversation_history 2026-03-18 10:18:50 +00:00
Dave
0c686ba170 story-kit: merge 266_story_matrix_bot_structured_conversation_history 2026-03-18 10:18:49 +00:00
Dave
74dc42c1fc story-kit: add 5_done to commit-worthy stages
Keep done stage committed so accepted work is in git history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:13:35 +00:00
Dave
a3d22fd874 story-kit: gitignore intermediate pipeline stages (spike 92 follow-up)
Ignore 2_current/, 3_qa/, and 4_merge/ in git since spike 92 stopped
committing intermediate pipeline moves. Keep 5_done/ tracked so
accepted work is in git history before sweep to archived.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:13:10 +00:00
Dave
8561910cd8 story-kit: start 266_story_matrix_bot_structured_conversation_history 2026-03-18 10:09:26 +00:00
Dave
e569c1bcad story-kit: start 266_story_matrix_bot_structured_conversation_history 2026-03-18 10:08:19 +00:00
Dave
4dcb24d5dd story-kit: queue 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response for merge 2026-03-18 10:07:12 +00:00
Dave
59f37e13b9 story-kit: done 92_spike_stop_auto_committing_intermediate_pipeline_moves 2026-03-18 10:07:05 +00:00
Dave
3a1d7012b4 spike(92): stop auto-committing intermediate pipeline moves
Filter flush_pending() to only git-commit for terminal stages
(1_upcoming and 6_archived) while still broadcasting WatcherEvents
for all stages so the frontend stays in sync.

Reduces pipeline commits from 5+ to 2 per story run. No system
dependencies on intermediate commits were found.

Preserves merge_failure front matter cleanup from master.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:06:58 +00:00
Dave
41b24e4b7a story-kit: queue 266_story_matrix_bot_structured_conversation_history for merge 2026-03-18 10:05:09 +00:00
Dave
06948dae74 story-kit: queue 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response for QA 2026-03-18 10:03:09 +00:00
Dave
bbd4aee828 story-kit: start 271_story_show_assigned_agent_in_expanded_work_item_view 2026-03-18 10:03:06 +00:00
Dave
d40f007818 story-kit: start 278_story_auto_assign_agents_to_pipeline_items_on_server_startup 2026-03-18 10:01:54 +00:00
Dave
3819a02159 story-kit: start 278_story_auto_assign_agents_to_pipeline_items_on_server_startup 2026-03-18 10:01:54 +00:00
Dave
9b65845c90 story-kit: start 277_story_matrix_bot_uses_its_configured_name_instead_of_claude 2026-03-18 10:01:52 +00:00
Dave
28176727d7 story-kit: queue 271_story_show_assigned_agent_in_expanded_work_item_view for merge 2026-03-18 10:00:38 +00:00
Dave
1d59cdcc25 story-kit: start 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response 2026-03-18 10:00:28 +00:00
Dave
edc6b9ea05 story-kit: queue 271_story_show_assigned_agent_in_expanded_work_item_view for merge 2026-03-18 09:57:30 +00:00
Dave
8e4a8ce57a story-kit: queue 279_story_auto_assign_should_respect_agent_stage_when_front_matter_specifies_agent for merge 2026-03-18 09:54:32 +00:00
Dave
c863ee4135 story-kit: queue 279_story_auto_assign_should_respect_agent_stage_when_front_matter_specifies_agent for merge 2026-03-18 09:53:48 +00:00
Dave
dd4a1140fe story-kit: queue 278_story_auto_assign_agents_to_pipeline_items_on_server_startup for merge 2026-03-18 09:49:53 +00:00
Dave
895317330b story-kit: queue 278_story_auto_assign_agents_to_pipeline_items_on_server_startup for merge 2026-03-18 09:48:57 +00:00
Dave
11e32f9802 story-kit: queue 277_story_matrix_bot_uses_its_configured_name_instead_of_claude for merge 2026-03-18 09:44:22 +00:00
Dave
8b7ff6383f story-kit: queue 277_story_matrix_bot_uses_its_configured_name_instead_of_claude for merge 2026-03-18 09:43:30 +00:00
Dave
964a8bfcff story-kit: queue 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response for merge 2026-03-18 09:41:34 +00:00
Dave
978b84893c story-kit: queue 266_story_matrix_bot_structured_conversation_history for merge 2026-03-18 09:39:58 +00:00
Dave
7dd6821dc5 story-kit: queue 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response for merge 2026-03-18 09:38:05 +00:00
Dave
6abf5c87b2 story-kit: done 247_story_human_qa_gate_with_rejection_flow 2026-03-18 09:37:38 +00:00
Dave
b682c67f97 story-kit: queue 278_story_auto_assign_agents_to_pipeline_items_on_server_startup for QA 2026-03-18 09:35:50 +00:00
Dave
81309a5559 story-kit: queue 277_story_matrix_bot_uses_its_configured_name_instead_of_claude for QA 2026-03-18 09:35:49 +00:00
Dave
2006ad6d8c story-kit: queue 279_story_auto_assign_should_respect_agent_stage_when_front_matter_specifies_agent for QA 2026-03-18 09:35:45 +00:00
Dave
41bafb80e4 story-kit: accept 275_story_matrix_bot_surfaces_claude_code_permission_prompts_to_chat 2026-03-18 09:33:21 +00:00
Dave
569380e133 story-kit: done 275_story_matrix_bot_surfaces_claude_code_permission_prompts_to_chat 2026-03-18 09:32:20 +00:00
Dave
10a5bea2b1 story-kit: merge 275_story_matrix_bot_surfaces_claude_code_permission_prompts_to_chat 2026-03-18 09:32:17 +00:00
Dave
110815c1c5 story-kit: queue 247_story_human_qa_gate_with_rejection_flow for merge 2026-03-18 09:31:02 +00:00
Dave
29fc761980 fix: skip frontend tests when frontend dir absent in sparse worktrees
script/test unconditionally cd'd into frontend/ and ran npm test, which
failed in sparse-checkout worktrees that only contain the server/ subtree.
Guard the frontend step with a directory existence check so acceptance
gates pass for worktrees that have no frontend checkout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 09:30:51 +00:00
Dave
d537aceb63 story-kit: start 278_story_auto_assign_agents_to_pipeline_items_on_server_startup 2026-03-18 09:29:43 +00:00
Dave
72b89c8ccc story-kit: start 277_story_matrix_bot_uses_its_configured_name_instead_of_claude 2026-03-18 09:29:27 +00:00
Dave
e19de02967 story-kit: start 279_story_auto_assign_should_respect_agent_stage_when_front_matter_specifies_agent 2026-03-18 09:29:00 +00:00
Dave
1c5f13e7eb story-kit: create 279_story_auto_assign_should_respect_agent_stage_when_front_matter_specifies_agent 2026-03-18 09:28:33 +00:00
Dave
816c771a2a story-kit: queue 275_story_matrix_bot_surfaces_claude_code_permission_prompts_to_chat for merge 2026-03-18 09:28:30 +00:00
Dave
642a8486cd story-kit: queue 266_story_matrix_bot_structured_conversation_history for QA 2026-03-18 09:25:33 +00:00
Dave
605bcadea7 story-kit: done 274_story_mcp_pipeline_status_tool_with_agent_assignments 2026-03-18 09:25:29 +00:00
Dave
ccc1ead8c9 story-kit: remove 274_story_mcp_pipeline_status_tool_with_agent_assignments 2026-03-18 09:25:07 +00:00
Dave
8bbbe8fbdd story-kit: start 274_story_mcp_pipeline_status_tool_with_agent_assignments 2026-03-18 09:25:01 +00:00
Dave
d9775834ed story-kit: queue 275_story_matrix_bot_surfaces_claude_code_permission_prompts_to_chat for QA 2026-03-18 09:22:43 +00:00
Dave
c32f0dce45 story-kit: queue 274_story_mcp_pipeline_status_tool_with_agent_assignments for merge 2026-03-18 09:22:39 +00:00
Dave
d864941665 story-kit: start 266_story_matrix_bot_structured_conversation_history 2026-03-18 09:21:40 +00:00
Dave
9c2d831c65 story-kit: create 266_story_matrix_bot_structured_conversation_history 2026-03-18 09:21:26 +00:00
Dave
2ab91f933f story-kit: create 266_story_matrix_bot_structured_conversation_history 2026-03-18 09:21:00 +00:00
Dave
1fcb8cb332 story-kit: create 266_story_matrix_bot_structured_conversation_history 2026-03-18 09:20:36 +00:00
Dave
3439c16e66 story-kit: create 278_story_auto_assign_agents_to_pipeline_items_on_server_startup 2026-03-18 09:18:23 +00:00
Dave
ce93987da8 story-kit: queue 274_story_mcp_pipeline_status_tool_with_agent_assignments for merge 2026-03-18 09:15:01 +00:00
Dave
bd7b7cc34a story-kit: create 277_story_matrix_bot_uses_its_configured_name_instead_of_claude 2026-03-18 09:09:49 +00:00
Dave
855452b4a2 story-kit: queue 274_story_mcp_pipeline_status_tool_with_agent_assignments for merge 2026-03-18 09:09:06 +00:00
Dave
1fcfa9123f story-kit: queue 274_story_mcp_pipeline_status_tool_with_agent_assignments for merge 2026-03-18 09:07:42 +00:00
Dave
e66b811436 story-kit: queue 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response for merge 2026-03-18 09:06:00 +00:00
Dave
8d5fa85a3a story-kit: accept 264_bug_claude_code_session_id_not_persisted_across_browser_refresh 2026-03-18 09:05:30 +00:00
Dave
a4e7a23ca6 story-kit: queue 274_story_mcp_pipeline_status_tool_with_agent_assignments for QA 2026-03-17 18:43:05 +00:00
Dave
b67eea7b9a story-kit: queue 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response for QA 2026-03-17 18:42:03 +00:00
Dave
4a89b46857 story-kit: start 275_story_matrix_bot_surfaces_claude_code_permission_prompts_to_chat 2026-03-17 18:33:23 +00:00
Dave
047bf83b76 story-kit: start 274_story_mcp_pipeline_status_tool_with_agent_assignments 2026-03-17 18:32:43 +00:00
Dave
62aa142409 story-kit: start 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response 2026-03-17 18:32:37 +00:00
Dave
c93a2e80f9 story-kit: queue 92_spike_stop_auto_committing_intermediate_pipeline_moves for QA 2026-03-17 18:32:30 +00:00
Dave
9176fe3303 story-kit: create 276_story_detect_and_log_when_root_mcp_json_port_is_modified 2026-03-17 18:26:07 +00:00
Dave
296a59def3 story-kit: create 275_story_matrix_bot_surfaces_claude_code_permission_prompts_to_chat 2026-03-17 18:23:52 +00:00
Dave
90bb2fb137 story-kit: done 272_story_clear_merge_error_front_matter_when_story_leaves_merge_stage 2026-03-17 18:21:59 +00:00
Dave
bc0bb91a83 story-kit: merge 272_story_clear_merge_error_front_matter_when_story_leaves_merge_stage 2026-03-17 18:21:56 +00:00
Dave
0b39b2acfc story-kit: create 275_story_matrix_bot_surfaces_claude_code_permission_prompts_to_chat 2026-03-17 18:21:40 +00:00
Dave
75c27f5853 story-kit: create 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response 2026-03-17 18:18:29 +00:00
Dave
349866606c story-kit: done 270_bug_qa_test_server_overwrites_root_mcp_json_with_wrong_port 2026-03-17 18:16:30 +00:00
Dave
901f7a65d3 story-kit: merge 270_bug_qa_test_server_overwrites_root_mcp_json_with_wrong_port 2026-03-17 18:16:13 +00:00
Dave
c52b41b99c story-kit: queue 272_story_clear_merge_error_front_matter_when_story_leaves_merge_stage for merge 2026-03-17 18:15:18 +00:00
Dave
ec76005c63 story-kit: create 274_story_mcp_pipeline_status_tool_with_agent_assignments 2026-03-17 18:10:26 +00:00
Dave
1736f8d924 story-kit: queue 92_spike_stop_auto_committing_intermediate_pipeline_moves for QA 2026-03-17 18:08:42 +00:00
Dave
f8b5e11c27 story-kit: done 269_story_file_references_in_web_ui_chat_input 2026-03-17 18:04:48 +00:00
Dave
12c500ee90 story-kit: remove 269_story_file_references_in_web_ui_chat_input 2026-03-17 18:03:21 +00:00
Dave
81c9cf797f story-kit: done 269_story_file_references_in_web_ui_chat_input 2026-03-17 18:03:10 +00:00
Dave
d18c1105c7 story-kit: start 269_story_file_references_in_web_ui_chat_input 2026-03-17 18:01:09 +00:00
Dave
ca8e6dc51c story-kit: accept 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-17 17:58:30 +00:00
Dave
30ad59c6eb story-kit: queue 269_story_file_references_in_web_ui_chat_input for merge 2026-03-17 17:57:29 +00:00
Dave
123f140244 story-kit: start 269_story_file_references_in_web_ui_chat_input 2026-03-17 17:56:24 +00:00
Dave
8db23f77cd story-kit: start 92_spike_stop_auto_committing_intermediate_pipeline_moves 2026-03-17 17:55:34 +00:00
Dave
6bfa10b0e5 story-kit: queue 269_story_file_references_in_web_ui_chat_input for merge 2026-03-17 17:55:15 +00:00
Dave
65036b2ce7 story-kit: start 92_spike_stop_auto_committing_intermediate_pipeline_moves 2026-03-17 17:54:03 +00:00
Dave
76d73b2d0b story-kit: gitignore matrix_history.json
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:53:33 +00:00
Dave
78618a1b76 story-kit: queue 269_story_file_references_in_web_ui_chat_input for merge 2026-03-17 17:51:06 +00:00
Dave
47e07b23d1 story-kit: queue 272_story_clear_merge_error_front_matter_when_story_leaves_merge_stage for QA 2026-03-17 17:50:17 +00:00
Dave
45ae7b8f01 story-kit: queue 269_story_file_references_in_web_ui_chat_input for merge 2026-03-17 17:49:46 +00:00
Dave
e1c30b5953 story-kit: accept 262_story_bot_error_notifications_for_story_failures 2026-03-17 17:49:07 +00:00
Dave
b0d9fb4f39 story-kit: queue 269_story_file_references_in_web_ui_chat_input for merge 2026-03-17 17:47:07 +00:00
Dave
dcc11c2b0f story-kit: queue 271_story_show_assigned_agent_in_expanded_work_item_view for QA 2026-03-17 17:46:43 +00:00
Dave
7f21454880 story-kit: queue 269_story_file_references_in_web_ui_chat_input for merge 2026-03-17 17:45:44 +00:00
Dave
a893a1cef7 story-kit: merge 266_story_matrix_bot_structured_conversation_history 2026-03-17 17:42:08 +00:00
Dave
3fb48cdf51 story-kit: queue 269_story_file_references_in_web_ui_chat_input for QA 2026-03-17 17:41:55 +00:00
Dave
f1bb1216bf story-kit: done 266_story_matrix_bot_structured_conversation_history 2026-03-17 17:41:29 +00:00
Dave
b3faf7b810 story-kit: queue 247_story_human_qa_gate_with_rejection_flow for merge 2026-03-17 17:40:54 +00:00
Dave
89e4ee1c9c story-kit: queue 270_bug_qa_test_server_overwrites_root_mcp_json_with_wrong_port for merge 2026-03-17 17:37:50 +00:00
Dave
4df39eb1f2 story-kit: create 273_story_matrix_bot_sends_typing_indicator_while_waiting_for_claude_response 2026-03-17 17:37:41 +00:00
Dave
a7d23143ef story-kit: done 267_story_mcp_update_story_tool_should_support_front_matter_fields 2026-03-17 17:36:00 +00:00
Dave
f72666b39e story-kit: merge 267_story_mcp_update_story_tool_should_support_front_matter_fields 2026-03-17 17:35:57 +00:00
Dave
1f8ffee38e story-kit: start 272_story_clear_merge_error_front_matter_when_story_leaves_merge_stage 2026-03-17 17:35:23 +00:00
Dave
798f841b9a story-kit: queue 267_story_mcp_update_story_tool_should_support_front_matter_fields for merge 2026-03-17 17:34:26 +00:00
Dave
25c3dbb3d1 story-kit: start 271_story_show_assigned_agent_in_expanded_work_item_view 2026-03-17 17:33:34 +00:00
Dave
71cbc21b01 story-kit: queue 266_story_matrix_bot_structured_conversation_history for merge 2026-03-17 17:31:57 +00:00
Dave
6deeba81a8 story-kit: queue 266_story_matrix_bot_structured_conversation_history for merge 2026-03-17 17:30:53 +00:00
Dave
b862a7a6d0 story-kit: queue 267_story_mcp_update_story_tool_should_support_front_matter_fields for merge 2026-03-17 17:29:36 +00:00
Dave
fe1f76957d story-kit: queue 267_story_mcp_update_story_tool_should_support_front_matter_fields for merge 2026-03-17 17:28:38 +00:00
Dave
266e676dd4 story-kit: queue 247_story_human_qa_gate_with_rejection_flow for QA 2026-03-17 17:27:04 +00:00
Dave
402159c19a story-kit: queue 270_bug_qa_test_server_overwrites_root_mcp_json_with_wrong_port for QA 2026-03-17 17:26:23 +00:00
Dave
6d1b36e515 story-kit: start 269_story_file_references_in_web_ui_chat_input 2026-03-17 17:25:42 +00:00
Dave
81d4889cee Expand vite watch ignore list to prevent silent crashes on merge
Vite only needs to watch frontend/ sources. Ignore git objects,
Rust source, Cargo files, node_modules, and vendor directories
that change in bulk during squash merges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:17:47 +00:00
Dave
0eb2cd8ec3 Stop tracking .mcp.json — it is generated at runtime
The server writes .mcp.json with its current port on startup and
during worktree creation. Tracking it in git causes QA gate failures
when the worktree copy diverges from master (e.g. different port).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:15:35 +00:00
Dave
b251ed7421 story-kit: create 272_story_clear_merge_error_front_matter_when_story_leaves_merge_stage 2026-03-17 17:14:19 +00:00
Dave
4a600e9954 story-kit: create 271_story_show_assigned_agent_in_expanded_work_item_view 2026-03-17 17:13:08 +00:00
Dave
cfb810b061 story-kit: start 270_bug_qa_test_server_overwrites_root_mcp_json_with_wrong_port 2026-03-17 17:10:45 +00:00
Dave
71bd999586 story-kit: create 270_bug_qa_test_server_overwrites_root_mcp_json_with_wrong_port 2026-03-17 17:10:30 +00:00
Dave
10d0cdeeae story-kit: accept 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 17:09:27 +00:00
Dave
6e375aaab5 story-kit: create 270_bug_qa_test_server_overwrites_root_mcp_json_with_wrong_port 2026-03-17 17:08:26 +00:00
Dave
e7edf9a8d5 story-kit: done 268_refactor_upgrade_tokio_tungstenite_to_0_29_0 2026-03-17 17:02:34 +00:00
Dave
20431f625b story-kit: merge 268_refactor_upgrade_tokio_tungstenite_to_0_29_0 2026-03-17 17:02:31 +00:00
Dave
d35f0f19fb story-kit: queue 268_refactor_upgrade_tokio_tungstenite_to_0_29_0 for merge 2026-03-17 16:59:54 +00:00
Dave
4303b33b90 story-kit: queue 266_story_matrix_bot_structured_conversation_history for QA 2026-03-17 16:58:19 +00:00
Dave
f9c0d24d7a story-kit: accept 259_story_move_story_kit_ignores_into_story_kit_gitignore 2026-03-17 16:57:12 +00:00
Dave
ec3277234c story-kit: queue 267_story_mcp_update_story_tool_should_support_front_matter_fields for QA 2026-03-17 16:53:12 +00:00
Dave
0a28aae041 story-kit: queue 268_refactor_upgrade_tokio_tungstenite_to_0_29_0 for QA 2026-03-17 16:47:46 +00:00
Dave
a7a8358cbb story-kit: create 269_story_file_references_in_web_ui_chat_input 2026-03-17 16:46:55 +00:00
Dave
6b6cb525a7 story-kit: start 268_refactor_upgrade_tokio_tungstenite_to_0_29_0 2026-03-17 16:43:45 +00:00
Dave
27465b1130 story-kit: create 268_refactor_upgrade_tokio_tungstenite_to_0_29_0 2026-03-17 16:43:34 +00:00
Dave
e74c370c7e Improve release changelog and fix MCP port
Generate structured changelogs from completed stories instead of raw
commit messages. Group by features, bug fixes, and refactors. Filter
out story-kit automation commits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:42:48 +00:00
Dave
8defd5c671 story-kit: start 267_story_mcp_update_story_tool_should_support_front_matter_fields 2026-03-17 16:42:08 +00:00
Dave
a5c4fb553a story-kit: create 267_story_mcp_update_story_tool_should_support_front_matter_fields 2026-03-17 16:41:47 +00:00
Dave
a7772d1421 story-kit: start 247_story_human_qa_gate_with_rejection_flow 2026-03-17 16:41:27 +00:00
Dave
ed967403fb story-kit: start 247_story_human_qa_gate_with_rejection_flow 2026-03-17 16:40:09 +00:00
Dave
998b188ac7 story-kit: start 266_story_matrix_bot_structured_conversation_history 2026-03-17 16:39:42 +00:00
Dave
115c9fd6df story-kit: done 265_story_spikes_skip_merge_and_stop_for_human_review 2026-03-17 16:36:03 +00:00
Dave
86694a4383 story-kit: merge 265_story_spikes_skip_merge_and_stop_for_human_review 2026-03-17 16:36:00 +00:00
Dave
7b324ea96e story-kit: accept 257_story_rename_storkit_to_story_kit_in_header 2026-03-17 16:35:37 +00:00
Dave
744a12eeea story-kit: queue 265_story_spikes_skip_merge_and_stop_for_human_review for merge 2026-03-17 16:33:37 +00:00
Dave
cffe63680d Fix MCP server URL to match actual running port (3010)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:17:54 +00:00
Dave
f5fffd64b8 story-kit: create 266_story_matrix_bot_structured_conversation_history 2026-03-17 16:17:34 +00:00
Dave
ad68bc912f story-kit: remove 266_story_matrix_bot_structured_conversation_history 2026-03-17 16:17:20 +00:00
Dave
d02d53d112 story-kit: create 266_story_matrix_bot_structured_conversation_history 2026-03-17 16:14:07 +00:00
Dave
3ce7276e89 Fix TS narrowing errors in Chat.test.tsx
TypeScript control flow analysis can't track reassignment inside vi.mock
callbacks, causing lastSendChatArgs to narrow to never. Use non-null
assertions after the explicit toBeNull() guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:13:29 +00:00
Dave
6d87e64859 story-kit: queue 265_story_spikes_skip_merge_and_stop_for_human_review for QA 2026-03-17 16:11:52 +00:00
Dave
83db282892 Bump version to 0.2.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:08:59 +00:00
Dave
f5d5196bf5 Fix cross-signing bootstrap by passing UIA password auth
The homeserver requires User-Interactive Authentication before accepting
cross-signing keys. Pass the bot's password so bootstrap succeeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:08:39 +00:00
Dave
7ec869baa8 Fixing mcp location 2026-03-17 15:58:40 +00:00
Dave
1a257b3057 story-kit: start 265_story_spikes_skip_merge_and_stop_for_human_review 2026-03-17 15:58:28 +00:00
Dave
b9fd87ed7c story-kit: create 265_story_spikes_skip_merge_and_stop_for_human_review 2026-03-17 15:58:24 +00:00
Dave
fda763d3f0 story-kit: create 260_refactor_upgrade_libsqlite3_sys 2026-03-17 15:47:07 +00:00
Dave
77d89b17e8 story-kit: done 260_refactor_upgrade_libsqlite3_sys 2026-03-17 15:47:01 +00:00
Dave
df0fa46591 Revert "story-kit: merge 260_refactor_upgrade_libsqlite3_sys"
This reverts commit ea062400e5.
2026-03-17 15:45:52 +00:00
Dave
1f5d70ce0d story-kit: done 264_bug_claude_code_session_id_not_persisted_across_browser_refresh 2026-03-17 15:41:43 +00:00
Dave
0d46c86469 story-kit: merge 264_bug_claude_code_session_id_not_persisted_across_browser_refresh 2026-03-17 15:41:41 +00:00
Dave
a439f8fdcb story-kit: queue 264_bug_claude_code_session_id_not_persisted_across_browser_refresh for merge 2026-03-17 15:39:34 +00:00
Dave
1adddf4e4c story-kit: create 247_story_human_qa_gate_with_rejection_flow 2026-03-17 15:37:25 +00:00
Dave
23484716e2 story-kit: create 265_story_spikes_skip_merge_and_stop_for_human_review 2026-03-17 15:36:52 +00:00
Dave
92085f9071 story-kit: done 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption 2026-03-17 15:35:01 +00:00
Dave
ce899b569e story-kit: merge 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption 2026-03-17 15:34:58 +00:00
Dave
da7216630b story-kit: queue 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption for merge 2026-03-17 15:33:11 +00:00
Dave
b57c270144 story-kit: queue 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption for merge 2026-03-17 15:31:25 +00:00
Dave
230b8fdc35 story-kit: queue 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption for merge 2026-03-17 15:28:49 +00:00
Dave
75b2446801 story-kit: done 262_story_bot_error_notifications_for_story_failures 2026-03-17 15:27:51 +00:00
Dave
96779c9caf story-kit: merge 262_story_bot_error_notifications_for_story_failures 2026-03-17 15:27:48 +00:00
Dave
bf5d9ff6b1 story-kit: queue 264_bug_claude_code_session_id_not_persisted_across_browser_refresh for QA 2026-03-17 15:25:47 +00:00
Dave
c551faeea3 story-kit: queue 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption for merge 2026-03-17 15:19:41 +00:00
Dave
3f38f90a50 story-kit: queue 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption for merge 2026-03-17 15:19:33 +00:00
Dave
26a1328c89 story-kit: queue 262_story_bot_error_notifications_for_story_failures for merge 2026-03-17 15:18:57 +00:00
Dave
21b45b8dd7 story-kit: queue 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption for merge 2026-03-17 15:18:32 +00:00
Dave
3a860bd2d5 story-kit: start 264_bug_claude_code_session_id_not_persisted_across_browser_refresh 2026-03-17 15:16:14 +00:00
Dave
c2c95c18b4 story-kit: create 264_bug_claude_code_session_id_not_persisted_across_browser_refresh 2026-03-17 15:15:51 +00:00
Dave
e3a301009b story-kit: queue 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption for QA 2026-03-17 15:08:23 +00:00
Dave
c90bdc8907 story-kit: queue 262_story_bot_error_notifications_for_story_failures for QA 2026-03-17 15:07:17 +00:00
Dave
dba12a38c2 story-kit: start 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption 2026-03-17 15:00:34 +00:00
Dave
4b60452b27 story-kit: start 262_story_bot_error_notifications_for_story_failures 2026-03-17 14:57:27 +00:00
Dave
d2f677ae0c story-kit: create 263_story_matrix_bot_self_signs_device_keys_at_startup_for_verified_encryption 2026-03-17 14:56:59 +00:00
Dave
427bb6929a Ignoring some folders to keep vite running across merges 2026-03-17 14:48:37 +00:00
Dave
78c04ee576 story-kit: create 262_story_bot_error_notifications_for_story_failures 2026-03-17 14:44:04 +00:00
Dave
3309d26142 story-kit: done 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-17 14:38:08 +00:00
Dave
5a4a2aaa17 story-kit: merge 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-17 14:38:05 +00:00
Dave
d3786253ef story-kit: queue 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression for merge 2026-03-17 14:35:34 +00:00
Dave
76db12a53e story-kit: queue 262_story_bot_error_notifications_for_story_failures for QA 2026-03-17 14:25:37 +00:00
Dave
4eb5a01774 story-kit: queue 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression for QA 2026-03-17 14:20:40 +00:00
Dave
198f9ff5bf story-kit: start 262_story_bot_error_notifications_for_story_failures 2026-03-17 14:20:39 +00:00
Dave
e30773d088 story-kit: done 261_story_bot_notifications_when_stories_move_between_stages 2026-03-17 14:05:04 +00:00
Dave
a4affca9be story-kit: merge 261_story_bot_notifications_when_stories_move_between_stages 2026-03-17 14:05:02 +00:00
Dave
a067091354 story-kit: queue 261_story_bot_notifications_when_stories_move_between_stages for merge 2026-03-17 14:02:47 +00:00
Dave
da423d9c97 story-kit: queue 261_story_bot_notifications_when_stories_move_between_stages for QA 2026-03-17 13:58:47 +00:00
Dave
d6d080e30a story-kit: start 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-17 13:57:34 +00:00
Dave
9098c1ba9d story-kit: done 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-17 13:57:28 +00:00
Dave
511c5809f2 story-kit: remove 263_refactor_extract_common_matrix_messaging_for_story_notifications 2026-03-17 13:48:54 +00:00
Dave
ace8e59536 story-kit: create 262_story_bot_error_notifications_for_story_failures 2026-03-17 13:48:49 +00:00
Dave
fa128c52d9 story-kit: queue 262_story_bot_error_notifications_for_story_failures for merge 2026-03-17 13:48:41 +00:00
Dave
621cdea6df story-kit: start 261_story_bot_notifications_when_stories_move_between_stages 2026-03-17 13:48:31 +00:00
Dave
68233e3355 story-kit: queue 261_story_bot_notifications_when_stories_move_between_stages for merge 2026-03-17 13:48:17 +00:00
Dave
99d298035b story-kit: queue 262_story_bot_error_notifications_for_story_failures for merge 2026-03-17 13:45:56 +00:00
Dave
73b41d1c6c story-kit: queue 262_story_bot_error_notifications_for_story_failures for merge 2026-03-17 13:44:49 +00:00
Dave
1a56844661 story-kit: queue 261_story_bot_notifications_when_stories_move_between_stages for merge 2026-03-17 13:42:50 +00:00
Dave
48ff0ba205 story-kit: queue 261_story_bot_notifications_when_stories_move_between_stages for merge 2026-03-17 13:41:49 +00:00
Dave
50b29e0bed story-kit: done 260_refactor_upgrade_libsqlite3_sys 2026-03-17 13:41:35 +00:00
Dave
ea062400e5 story-kit: merge 260_refactor_upgrade_libsqlite3_sys 2026-03-17 13:41:33 +00:00
Dave
b0e4e04c9d story-kit: done 258_bug_auto_assign_not_called_after_merge_failure 2026-03-17 13:38:45 +00:00
Dave
02fe364349 story-kit: create 263_refactor_extract_common_matrix_messaging_for_story_notifications 2026-03-17 13:38:43 +00:00
Dave
3602f882d2 story-kit: merge 258_bug_auto_assign_not_called_after_merge_failure 2026-03-17 13:38:42 +00:00
Dave
730e7324ea story-kit: queue 260_refactor_upgrade_libsqlite3_sys for merge 2026-03-17 13:38:24 +00:00
Dave
ae73d95d50 story-kit: queue 262_story_bot_error_notifications_for_story_failures for QA 2026-03-17 13:36:34 +00:00
Dave
ae6dd3217b story-kit: queue 258_bug_auto_assign_not_called_after_merge_failure for merge 2026-03-17 13:35:27 +00:00
Dave
9a6f63b591 story-kit: queue 258_bug_auto_assign_not_called_after_merge_failure for QA 2026-03-17 13:32:08 +00:00
Dave
421eaec7ba story-kit: start 262_story_bot_error_notifications_for_story_failures 2026-03-17 13:27:28 +00:00
Dave
2c4e376054 story-kit: start 261_story_bot_notifications_when_stories_move_between_stages 2026-03-17 13:27:24 +00:00
Dave
1896a0ac49 story-kit: start 258_bug_auto_assign_not_called_after_merge_failure 2026-03-17 13:23:57 +00:00
Dave
b8d3978a54 story-kit: queue 260_refactor_upgrade_libsqlite3_sys for QA 2026-03-17 13:22:31 +00:00
Dave
72c50b6ffc story-kit: create 262_story_bot_error_notifications_for_story_failures 2026-03-17 13:20:14 +00:00
Dave
bab77fe105 story-kit: create 261_story_bot_notifications_when_stories_move_between_stages 2026-03-17 13:20:12 +00:00
Dave
1d935192e1 story-kit: done 259_story_move_story_kit_ignores_into_story_kit_gitignore 2026-03-17 13:15:04 +00:00
Dave
f89f78d77d story-kit: merge 259_story_move_story_kit_ignores_into_story_kit_gitignore 2026-03-17 13:15:02 +00:00
Dave
09a71b4515 story-kit: queue 259_story_move_story_kit_ignores_into_story_kit_gitignore for merge 2026-03-17 13:12:12 +00:00
Dave
988562fc82 story-kit: done 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 13:10:55 +00:00
Dave
ed0d5d9253 story-kit: merge 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 13:10:51 +00:00
Dave
bb265d7bd5 story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for merge 2026-03-17 13:08:13 +00:00
Dave
126a6f8dc3 story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for merge 2026-03-17 13:07:33 +00:00
Dave
3b66b89c90 story-kit: remove 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 13:07:03 +00:00
Dave
e9879ce1c7 story-kit: create 92_spike_stop_auto_committing_intermediate_pipeline_moves 2026-03-17 13:06:37 +00:00
Dave
d30192b6a3 story-kit: create 92_spike_stop_auto_committing_intermediate_pipeline_moves 2026-03-17 13:06:26 +00:00
Dave
93c4f06818 story-kit: queue 259_story_move_story_kit_ignores_into_story_kit_gitignore for QA 2026-03-17 13:06:24 +00:00
Dave
7dab810572 story-kit: start 260_refactor_upgrade_libsqlite3_sys 2026-03-17 13:01:20 +00:00
Dave
cb7dde9fc1 story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for merge 2026-03-17 13:01:15 +00:00
Dave
7f70d1118f story-kit: create 260_refactor_upgrade_libsqlite3_sys 2026-03-17 13:01:10 +00:00
Dave
5638402745 story-kit: create 260_refactor_upgrade_libsqlite3_sys 2026-03-17 13:01:09 +00:00
Dave
e90bf38fa2 story-kit: create 260_refactor_upgrade_libsqlite3_sys 2026-03-17 13:00:48 +00:00
Dave
46ab4cdd8a story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for merge 2026-03-17 13:00:18 +00:00
Dave
7341fca72e story-kit: start 259_story_move_story_kit_ignores_into_story_kit_gitignore 2026-03-17 12:57:20 +00:00
Dave
fdb4a4fb62 story-kit: create 259_story_move_story_kit_ignores_into_story_kit_gitignore 2026-03-17 12:56:39 +00:00
Dave
87791c755e story-kit: create 259_story_move_story_kit_ignores_into_story_kit_gitignore 2026-03-17 12:53:52 +00:00
Dave
a4ce5f8f7c story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for QA 2026-03-17 12:52:04 +00:00
Dave
a9a84bee6d story-kit: start 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 12:52:01 +00:00
Dave
34755d3f63 story-kit: start 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 12:50:00 +00:00
Dave
ec553a5b8a story-kit: start 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 12:49:40 +00:00
Dave
076324c470 Fix pipe buffer deadlock in quality gate test runner
run_command_with_timeout piped stdout/stderr but only read them after
the child exited. When test output exceeded the 64KB OS pipe buffer,
the child blocked on write() while the parent blocked on waitpid() —
a permanent deadlock that caused every merge pipeline to hang.

Drain both pipes in background threads so the buffers never fill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:49:12 +00:00
Dave
5ed2737edc story-kit: accept 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch 2026-03-17 12:48:11 +00:00
Dave
0eafddd186 story-kit: done 257_story_rename_storkit_to_story_kit_in_header 2026-03-17 12:46:33 +00:00
Dave
7d4f722942 story-kit: merge 257_story_rename_storkit_to_story_kit_in_header 2026-03-17 12:46:30 +00:00
Dave
5d80d289c4 story-kit: create 258_bug_auto_assign_not_called_after_merge_failure 2026-03-17 12:35:08 +00:00
Dave
7c6e1b445d Make merge_agent_work async to avoid MCP 60-second tool timeout
The merge pipeline (squash merge + quality gates) takes well over 60
seconds. Claude Code's MCP HTTP transport times out at 60s, causing
"completed with no output" — the mergemaster retries fruitlessly.

merge_agent_work now starts the pipeline as a background task and
returns immediately. A new get_merge_status tool lets the mergemaster
poll until the job reaches a terminal state. Also adds a double-start
guard so concurrent calls for the same story are rejected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:15:42 +00:00
Dave
a85d1a1170 story-kit: queue 257_story_rename_storkit_to_story_kit_in_header for merge 2026-03-17 12:14:53 +00:00
Dave
afc1ab5e0e story-kit: queue 257_story_rename_storkit_to_story_kit_in_header for merge 2026-03-17 11:51:29 +00:00
Dave
32b6439f2f story-kit: create 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 11:46:28 +00:00
Dave
85e56e0ea8 story-kit: queue 257_story_rename_storkit_to_story_kit_in_header for merge 2026-03-17 11:45:29 +00:00
Dave
b63fa6be4f story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for merge 2026-03-17 11:43:23 +00:00
Dave
f012311303 story-kit: queue 257_story_rename_storkit_to_story_kit_in_header for merge 2026-03-17 11:39:28 +00:00
Dave
af0aa007ca story-kit: queue 257_story_rename_storkit_to_story_kit_in_header for merge 2026-03-17 11:36:05 +00:00
Dave
b2aec94d4c story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for QA 2026-03-17 11:35:03 +00:00
Dave
2ac550008a story-kit: start 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 11:32:49 +00:00
Dave
ebbbfed1d9 Add 10-minute timeout to test commands and disable e2e in merge pipeline
Test commands in run_project_tests now use wait-timeout to enforce a
600-second ceiling, preventing hung processes (e.g. Playwright with no
server) from blocking the merge pipeline indefinitely. Also disables
e2e tests in script/test until the merge workspace can run them safely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:32:44 +00:00
Dave
fd6ef83f76 story-kit: queue 257_story_rename_storkit_to_story_kit_in_header for QA 2026-03-17 11:29:57 +00:00
Dave
473461b65d story-kit: start 257_story_rename_storkit_to_story_kit_in_header 2026-03-17 11:24:55 +00:00
Dave
dc8d639d02 story-kit: create 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 11:24:20 +00:00
Dave
594fc500cf story-kit: create 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch 2026-03-17 11:24:15 +00:00
Dave
5448a99759 story-kit: done 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-17 11:24:09 +00:00
Dave
f5524b3ae1 story-kit: accept 255_story_show_agent_logs_in_expanded_story_popup 2026-03-17 11:23:36 +00:00
Dave
4585537dd8 story-kit: create 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch 2026-03-17 11:23:19 +00:00
Dave
57911fd9e7 story-kit: start 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-17 11:23:11 +00:00
Dave
b6f5169b56 story-kit: start 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch 2026-03-17 11:23:07 +00:00
Dave
a4b99c68da story-kit: done 255_story_show_agent_logs_in_expanded_story_popup 2026-03-17 11:23:04 +00:00
Dave
85062c338f story-kit: done 254_story_add_refactor_work_item_type 2026-03-17 11:23:01 +00:00
Dave
a7f3d283ec story-kit: done 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-17 11:22:57 +00:00
Dave
6cc9d1bde9 story-kit: queue 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression for merge 2026-03-17 11:14:16 +00:00
Dave
a82fa37730 story-kit: accept 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes 2026-03-17 03:26:24 +00:00
Dave
06ceab3e22 story-kit: merge 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-17 01:02:34 +00:00
Dave
58438f3ab6 story-kit: queue 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression for merge 2026-03-17 01:02:04 +00:00
Dave
59bb7dbc3a story-kit: queue 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression for merge 2026-03-17 00:59:38 +00:00
Dave
9c2471fbcc story-kit: queue 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch for merge 2026-03-17 00:52:34 +00:00
Dave
f383d0cb4f story-kit: queue 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression for QA 2026-03-17 00:52:02 +00:00
Dave
be61803af0 story-kit: merge 255_story_show_agent_logs_in_expanded_story_popup 2026-03-17 00:49:43 +00:00
Dave
c132d4f5c0 story-kit: queue 255_story_show_agent_logs_in_expanded_story_popup for merge 2026-03-17 00:49:20 +00:00
Dave
263ba440dc story-kit: queue 255_story_show_agent_logs_in_expanded_story_popup for merge 2026-03-17 00:46:33 +00:00
Dave
2fae9066e2 story-kit: merge 254_story_add_refactor_work_item_type 2026-03-17 00:40:37 +00:00
Dave
3553f59078 story-kit: queue 254_story_add_refactor_work_item_type for merge 2026-03-17 00:39:55 +00:00
Dave
78ea96d0a9 story-kit: queue 255_story_show_agent_logs_in_expanded_story_popup for QA 2026-03-17 00:36:40 +00:00
Dave
79d3eccc46 story-kit: queue 254_story_add_refactor_work_item_type for merge 2026-03-17 00:34:56 +00:00
Dave
c21a087399 story-kit: queue 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch for merge 2026-03-17 00:30:47 +00:00
Dave
67942d466c story-kit: done 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes 2026-03-17 00:30:26 +00:00
Dave
1d6a4fa8c6 story-kit: queue 254_story_add_refactor_work_item_type for QA 2026-03-17 00:29:53 +00:00
Dave
250f3ff819 story-kit: accept 242_story_status_slash_command 2026-03-17 00:26:51 +00:00
Dave
a02ea3c292 story-kit: queue 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes for merge 2026-03-17 00:25:50 +00:00
Dave
bbc5d9c90c story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for merge 2026-03-17 00:21:03 +00:00
Dave
24f6a5c7cc story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for merge 2026-03-17 00:13:44 +00:00
Dave
ab3420fa90 story-kit: queue 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch for merge 2026-03-17 00:13:12 +00:00
Dave
4c6228abee story-kit: queue 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes for QA 2026-03-17 00:12:25 +00:00
Dave
6df28d5393 story-kit: queue 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch for merge 2026-03-17 00:04:39 +00:00
Dave
2ad59ba155 story-kit: accept 249_story_agent_assignment_via_story_front_matter 2026-03-17 00:04:13 +00:00
Dave
319fc3823a story-kit: queue 250_bug_merge_pipeline_cherry_pick_fails_with_bad_revision_on_merge_queue_branch for merge 2026-03-17 00:04:12 +00:00
Dave
b9f3449021 story-kit: support agent assignment via story front matter (story 249)
Adds an optional `agent:` field to story file front matter so that a
specific agent can be requested for a story. The auto-assign loop now:

1. Reads the front-matter `agent` field for each story before picking
   a free agent.
2. If a preferred agent is named, uses it when free; skips the story
   (without falling back) when that agent is busy.
3. Falls back to the existing `find_free_agent_for_stage` behaviour
   when no preference is specified.

Ported from feature branch that predated the agents.rs module refactoring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 00:03:49 +00:00
Dave
cd7444ac5c story-kit: done 249_story_agent_assignment_via_story_front_matter 2026-03-17 00:03:21 +00:00
Dave
f5d9c98e74 story-kit: queue 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification for QA 2026-03-16 23:59:22 +00:00
Dave
7cd19e248c story-kit: start 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression 2026-03-16 23:57:48 +00:00
Dave
ec5024a089 story-kit: start 255_story_show_agent_logs_in_expanded_story_popup 2026-03-16 23:57:05 +00:00
Dave
9041cd1d16 story-kit: start 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-16 23:53:53 +00:00
Dave
0a0624795c story-kit: create 256_story_bot_must_verify_other_users_cross_signing_identity_before_checking_device_verification 2026-03-16 23:53:26 +00:00
Dave
d8d0d7936c story-kit: queue 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes for QA 2026-03-16 23:46:47 +00:00
Dave
55ea8e6aaf story-kit: accept 251_bug_archive_sweep_not_moving_stories_from_done_to_archived 2026-03-16 23:44:13 +00:00
Dave
1598d2a453 story-kit: done 251_bug_archive_sweep_not_moving_stories_from_done_to_archived 2026-03-16 23:44:09 +00:00
Dave
0120de5f00 story-kit: queue 249_story_agent_assignment_via_story_front_matter for QA 2026-03-16 23:44:00 +00:00
Dave
21835bc37d story-kit: queue 254_story_add_refactor_work_item_type for QA 2026-03-16 23:42:04 +00:00
Dave
f01fa6c527 story-kit: start 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes 2026-03-16 23:41:05 +00:00
Dave
a51488a0ce story-kit: create 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes 2026-03-16 23:40:02 +00:00
Dave
9054ac013e story-kit: accept 252_story_coder_agents_must_find_root_causes_for_bugs 2026-03-16 23:38:24 +00:00
Dave
95eea3a624 story-kit: start 254_story_add_refactor_work_item_type 2026-03-16 23:35:18 +00:00
Dave
6b9390b243 story-kit: start 255_story_show_agent_logs_in_expanded_story_popup 2026-03-16 23:34:24 +00:00
Dave
3ed9b7a185 story-kit: create 255_story_show_agent_logs_in_expanded_story_popup 2026-03-16 23:29:20 +00:00
Dave
bd7426131f story-kit: create 255_story_show_agent_logs_in_expanded_story_popup 2026-03-16 23:29:05 +00:00
Dave
e0132a7807 story-kit: create 255_bug_agent_logs_tab_shows_no_output_in_expanded_story_popup 2026-03-16 23:28:24 +00:00
Dave
b829783a84 story-kit: queue 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes for QA 2026-03-16 23:27:28 +00:00
Dave
2f0c54150a story-kit: start 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes 2026-03-16 23:25:56 +00:00
Dave
a716ca312a story-kit: start 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes 2026-03-16 23:25:33 +00:00
Dave
8ff6e3963b story-kit: accept 241_story_help_slash_command 2026-03-16 23:23:45 +00:00
Dave
2e25e2a46b story-kit: queue 253_bug_watcher_and_auto_assign_do_not_reinitialize_when_project_root_changes for merge 2026-03-16 23:23:45 +00:00
Dave
7c3a756a5c Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates

All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
Dave
225137fbdc story-kit: instruct coder agents to find root causes for bugs (story 252)
Add "Bug Workflow: Root Cause First" guidance to all coder agent prompts
and system prompts. Adds a test ensuring all coder-stage agents include
root cause, git bisect/log, and anti-workaround instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:33:54 +00:00
Dave
cce3ceb55b story-kit: merge 249_story_agent_assignment_via_story_front_matter 2026-03-14 21:00:52 +00:00
Dave
b54f16b945 story-kit: commit work pipeline files and package-lock
Commit untracked work pipeline files (stories, bugs in various stages)
and package-lock.json that were present on the filesystem but not yet
tracked by git.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:04:43 +00:00
Dave
93d5dbd92a story-kit: fix chat auto-scroll to bottom on new messages (bug 248)
Correct lastScrollTopRef assignment in scrollToBottom to read back the
browser-capped scrollTop value instead of using scrollHeight, which was
causing handleScroll to incorrectly detect upward scrolling and disable
auto-scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:56:55 +00:00
Dave
ec652a6fe8 story-kit: enforce cryptographic identity verification for Matrix commands (story 246)
Remove the require_verified_devices config toggle. The bot now always requires
encrypted rooms and cross-signing-verified devices before executing any command.
Messages from unencrypted rooms or unverified devices are rejected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:56:38 +00:00
Dave
568207687d story-kit: merge 241_story_help_slash_command 2026-03-14 18:55:30 +00:00
Dave
3abea68f9e story-kit: merge 243_bug_replace_pnpm_with_npm 2026-03-14 18:12:00 +00:00
Dave
3a430dfaa2 story-kit: done 240_story_btw_side_question_slash_command
Implement /btw side question slash command — lets users ask quick
questions from conversation context without disrupting the main chat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:09:30 +00:00
Dave
6a7baa4a15 Reverting sqlite upgrade 2026-03-14 13:28:33 +00:00
Dave
5f7647cbda Dependencies upgrade 2026-03-14 11:44:51 +00:00
Dave
1dcf043c53 story-kit: accept 233_story_auto_assign_backoff_for_failed_merge_items 2026-03-13 18:38:53 +00:00
Dave
523553197d story-kit: done 233_story_auto_assign_backoff_for_failed_merge_items 2026-03-13 18:38:45 +00:00
Dave
8d0c74c7d0 story-kit: done 227_bug_thinking_traces_visible_in_agents_panel_in_release_builds_only 2026-03-13 18:38:08 +00:00
Dave
ae115599a8 story-kit: queue 233_story_auto_assign_backoff_for_failed_merge_items for merge 2026-03-13 18:36:40 +00:00
Dave
6a477de2e1 story-kit: merge 227_bug_thinking_traces_visible_in_agents_panel_in_release_builds_only 2026-03-13 18:36:29 +00:00
Dave
20a4f0c492 story-kit: queue 227_bug_thinking_traces_visible_in_agents_panel_in_release_builds_only for merge 2026-03-13 18:34:04 +00:00
Dave
9fc7aebe22 story-kit: queue 233_story_auto_assign_backoff_for_failed_merge_items for QA 2026-03-13 18:32:45 +00:00
Dave
f681d978b5 story-kit: queue 227_bug_thinking_traces_visible_in_agents_panel_in_release_builds_only for QA 2026-03-13 18:29:29 +00:00
Dave
57a02d9a2b story-kit: start 233_story_auto_assign_backoff_for_failed_merge_items 2026-03-13 18:17:26 +00:00
Dave
210d3924ff story-kit: start 227_bug_thinking_traces_visible_in_agents_panel_in_release_builds_only 2026-03-13 18:14:57 +00:00
105 changed files with 10491 additions and 11353 deletions

View File

@@ -1,5 +1,7 @@
{
"enabledMcpjsonServers": ["story-kit"],
"enabledMcpjsonServers": [
"story-kit"
],
"permissions": {
"allow": [
"Bash(./server/target/debug/story-kit:*)",
@@ -56,7 +58,9 @@
"WebSearch",
"mcp__story-kit__*",
"Edit",
"Write"
"Write",
"Bash(find *)",
"Bash(sqlite3 *)"
]
}
}
}

17
.gitignore vendored
View File

@@ -1,26 +1,14 @@
# Claude Code
.claude/settings.local.json
.mcp.json
# Local environment (secrets)
.env
# App specific
# App specific (root-level; story-kit subdirectory patterns live in .story_kit/.gitignore)
store.json
.story_kit_port
# Bot config (contains credentials)
.story_kit/bot.toml
# Matrix SDK state store
.story_kit/matrix_store/
# Agent worktrees and merge workspace (managed by the server, not tracked in git)
.story_kit/worktrees/
.story_kit/merge_workspace/
# Coverage reports (generated by cargo-llvm-cov, not tracked in git)
.story_kit/coverage/
# Rust stuff
target
@@ -38,6 +26,7 @@ frontend/node_modules
frontend/dist
frontend/dist-ssr
frontend/test-results
frontend/serve
frontend/*.local
server/target

19
.story_kit/.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Bot config (contains credentials)
bot.toml
# Matrix SDK state store
matrix_store/
matrix_device_id
matrix_history.json
# Agent worktrees and merge workspace (managed by the server, not tracked in git)
worktrees/
merge_workspace/
# Intermediate pipeline stages (transient, not committed per spike 92)
work/2_current/
work/3_qa/
work/4_merge/
# Coverage reports (generated by cargo-llvm-cov, not tracked in git)
coverage/

7
.story_kit/problems.md Normal file
View File

@@ -0,0 +1,7 @@
# Problems
Recurring issues observed during pipeline operation. Review periodically and create stories for systemic problems.
## 2026-03-18: Agent committed directly to master instead of worktree
Commit `5f4591f` ("fix: update should_commit_stage test to match 5_done") was made directly on master by an agent (likely mergemaster). Agents should only commit to their feature branch or merge-queue branch, never to master directly. The commit content was correct but the target branch was wrong. Suspect the agent ran `git commit` in the project root instead of the merge worktree directory.

View File

@@ -1,7 +1,7 @@
[[component]]
name = "frontend"
path = "frontend"
setup = ["pnpm install", "pnpm run build"]
setup = ["npm install", "npm run build"]
teardown = []
[[component]]
@@ -56,8 +56,8 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits."
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "coder-2"
@@ -66,8 +66,8 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits."
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "qa-2"
@@ -87,12 +87,12 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
- Run `git diff master...HEAD` to review the actual changes for obvious coding mistakes (unused imports, dead code, unhandled errors, hardcoded values)
- Run `cargo clippy --all-targets --all-features` and note any warnings
- If a `frontend/` directory exists:
- Run `pnpm run build` and note any TypeScript errors
- Run `npm run build` and note any TypeScript errors
- Run `npx @biomejs/biome check src/` and note any linting issues
### 2. Test Verification
- Run `cargo test` and verify all tests pass
- If `frontend/` exists: run `pnpm test --run` and verify all frontend tests pass
- If `frontend/` exists: run `npm test` and verify all frontend tests pass
- Review test quality: look for tests that are trivial or don't assert meaningful behavior
### 3. Manual Testing Support
@@ -118,7 +118,7 @@ Print your QA report to stdout before your process exits. The server will automa
### Test Verification
- cargo test: PASS/FAIL (N tests)
- pnpm test: PASS/FAIL/SKIP (N tests)
- npm test: PASS/FAIL/SKIP (N tests)
- Test quality issues: (list any trivial/weak tests, or "None")
### Manual Testing Plan
@@ -143,8 +143,8 @@ role = "Senior full-stack engineer for complex tasks. Implements features across
model = "opus"
max_turns = 80
max_budget_usd = 20.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results."
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits."
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "qa"
@@ -164,12 +164,12 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
- Run `git diff master...HEAD` to review the actual changes for obvious coding mistakes (unused imports, dead code, unhandled errors, hardcoded values)
- Run `cargo clippy --all-targets --all-features` and note any warnings
- If a `frontend/` directory exists:
- Run `pnpm run build` and note any TypeScript errors
- Run `npm run build` and note any TypeScript errors
- Run `npx @biomejs/biome check src/` and note any linting issues
### 2. Test Verification
- Run `cargo test` and verify all tests pass
- If `frontend/` exists: run `pnpm test --run` and verify all frontend tests pass
- If `frontend/` exists: run `npm test` and verify all frontend tests pass
- Review test quality: look for tests that are trivial or don't assert meaningful behavior
### 3. Manual Testing Support
@@ -195,7 +195,7 @@ Print your QA report to stdout before your process exits. The server will automa
### Test Verification
- cargo test: PASS/FAIL (N tests)
- pnpm test: PASS/FAIL/SKIP (N tests)
- npm test: PASS/FAIL/SKIP (N tests)
- Test quality issues: (list any trivial/weak tests, or "None")
### Manual Testing Plan
@@ -220,7 +220,7 @@ role = "Merges completed coder work into master, runs quality gates, archives st
model = "opus"
max_turns = 30
max_budget_usd = 5.00
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master using the merge_agent_work MCP tool.
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
@@ -229,20 +229,43 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
2. Review the result: check success, had_conflicts, conflicts_resolved, gates_passed, and gate_output
3. If merge succeeded and gates passed: report success to the human
4. If conflicts were auto-resolved (conflicts_resolved=true) and gates passed: report success, noting which conflicts were resolved
5. If conflicts could not be auto-resolved: call report_merge_failure(story_id='{{story_id}}', reason='<conflict details>') and report to the human. Master is untouched.
6. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and report to the human.
7. If gates failed after merge: attempt to fix minor issues (see below), then re-trigger merge_agent_work. After 2 fix attempts, call report_merge_failure and stop.
5. If conflicts could not be auto-resolved: **resolve them yourself** in the merge worktree (see below)
6. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and report to the human
7. If gates failed after merge: attempt to fix the issues yourself in the merge worktree, then re-trigger merge_agent_work. After 3 fix attempts, call report_merge_failure and stop.
## How Conflict Resolution Works
The merge pipeline uses a temporary merge-queue branch and worktree to isolate merges from master. Simple additive conflicts (both branches adding code at the same location) are resolved automatically by keeping both additions. Complex conflicts (modifying the same lines differently) are reported without touching master.
## Resolving Complex Conflicts Yourself
## Fixing Minor Gate Failures
If quality gates fail (cargo clippy, cargo test, pnpm build, pnpm test), attempt to fix minor issues yourself before reporting to the human.
When the auto-resolver fails, you have access to the merge worktree at `.story_kit/merge_workspace/`. Go in there and resolve the conflicts manually:
**Fix yourself (up to 2 attempts total):**
1. Run `git diff --name-only --diff-filter=U` in the merge worktree to list conflicted files
2. **Build context before touching code.** Run `git log --oneline master...HEAD` on the feature branch to see its commits. Then run `git log --oneline --since="$(git log -1 --format=%ci <feature-branch-base-commit>)" master` to see what landed on master since the branch was created. Read the story files in `.story_kit/work/` for any recently merged stories that touch the same files — this tells you WHY master changed and what must be preserved.
3. Read each conflicted file and understand both sides of the conflict
4. **Understand intent, not just syntax.** The feature branch may be behind master — master's version of shared infrastructure is almost always correct. The feature branch's contribution is the NEW functionality it adds. Your job is to integrate the new into master's structure, not pick one side.
5. Resolve by integrating the feature's new functionality into master's code structure
5. Stage resolved files with `git add`
6. Run `cargo check` (and `npm run build` if frontend changed) to verify compilation
7. If it compiles, commit and re-trigger merge_agent_work
### Common conflict patterns in this project:
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in `work/2_current/`, `work/3_qa/`, `work/4_merge/` are gitignored and don't need to be committed.
**bot.rs tokio::select! conflicts:** Master has a `tokio::select!` loop in `handle_message()` that handles permission forwarding (story 275). Feature branches created before story 275 have a simpler direct `provider.chat_stream().await` call. Resolution: KEEP master's tokio::select! loop. Integrate only the feature's new logic (e.g. typing indicators, new callbacks) into the existing loop structure. Do NOT replace the loop with the old direct call.
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
## Fixing Gate Failures
If quality gates fail (cargo clippy, cargo test, npm run build, npm test), attempt to fix issues yourself in the merge worktree.
**Fix yourself (up to 3 attempts total):**
- Syntax errors (missing semicolons, brackets, commas)
- Duplicate definitions from merge artifacts
- Simple type annotation errors
- Unused import warnings flagged by clippy
- Mismatched braces from bad conflict resolution
- Trivial formatting issues that block compilation or linting
**Report to human without attempting a fix:**
@@ -250,17 +273,14 @@ If quality gates fail (cargo clippy, cargo test, pnpm build, pnpm test), attempt
- Missing function implementations
- Architectural changes required
- Non-trivial refactoring needed
- Anything requiring understanding of broader system context
**Max retry limit:** If gates still fail after 2 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human. Do not retry further.
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
## CRITICAL Rules
- NEVER manually move story files between pipeline stages (e.g. from 4_merge/ to 5_done/)
- NEVER call accept_story — only merge_agent_work can move stories to done after a successful merge
- When merge fails, ALWAYS call report_merge_failure to record the failure — do NOT improvise with file moves
- Only use MCP tools (merge_agent_work, report_merge_failure) to drive the merge process
- Only attempt fixes that are clearly minor and low-risk
- When merge fails after exhausting your fix attempts, ALWAYS call report_merge_failure
- Report conflict resolution outcomes clearly
- Report gate failures with full output so the human can act if needed
- The server automatically runs acceptance gates when your process exits"""
system_prompt = "You are the mergemaster agent. Your primary responsibility is to trigger the merge_agent_work MCP tool and report the results. CRITICAL: Never manually move story files or call accept_story. When merge fails, call report_merge_failure to record the failure. For minor gate failures (syntax errors, unused imports, missing semicolons), attempt to fix them yourself — but stop after 2 attempts, call report_merge_failure, and report to the human. For complex failures or unresolvable conflicts, call report_merge_failure and report clearly so the human can act. The merge pipeline automatically resolves simple additive conflicts."
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge worktree — you are an opus-class agent capable of understanding both sides of a conflict and producing correct merged code. Common patterns: keep master's tokio::select! permission loop in bot.rs, discard story file rename conflicts (gitignored), remove duplicate definitions. After resolving, verify compilation before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."

View File

@@ -9,7 +9,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
* **Frontend:** TypeScript + React
* **Build Tool:** Vite
* **Package Manager:** pnpm (required)
* **Package Manager:** npm
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
* **State Management:** React Context / Hooks
* **Chat UI:** Rendered Markdown with syntax highlighting.
@@ -91,8 +91,8 @@ To support both Remote and Local models, the system implements a `ModelProvider`
* **Quality Gates:**
* `npx @biomejs/biome check src/` must show 0 errors, 0 warnings
* `npm run build` must succeed
* `npx vitest run` must pass
* `npx playwright test` must pass
* `npm test` must pass
* `npm run test:e2e` must pass
* No `any` types allowed (use proper types or `unknown`)
* React keys must use stable IDs, not array indices
* All buttons must have explicit `type` attribute
@@ -119,7 +119,7 @@ To support both Remote and Local models, the system implements a `ModelProvider`
Multiple instances can run simultaneously in different worktrees. To avoid port conflicts:
- **Backend:** Set `STORYKIT_PORT` to a unique port (default is 3001). Example: `STORYKIT_PORT=3002 cargo run`
- **Frontend:** Run `pnpm dev` from `frontend/`. It auto-selects the next unused port. It reads `STORYKIT_PORT` to know which backend to talk to, so export it before running: `export STORYKIT_PORT=3002 && cd frontend && pnpm dev`
- **Frontend:** Run `npm run dev` from `frontend/`. It auto-selects the next unused port. It reads `STORYKIT_PORT` to know which backend to talk to, so export it before running: `export STORYKIT_PORT=3002 && cd frontend && npm run dev`
When running in a worktree, use a port that won't conflict with the main instance (3001). Ports 3002+ are good choices.

View File

@@ -0,0 +1,24 @@
---
name: "Upgrade libsqlite3-sys"
---
# Refactor 260: Upgrade libsqlite3-sys
## Description
Upgrade the `libsqlite3-sys` dependency from `0.35.0` to `0.37.0`. The crate is used with `features = ["bundled"]` for static builds.
## Version Notes
- Current: `libsqlite3-sys 0.35.0` (pinned transitively by `matrix-sdk 0.16.0``matrix-sdk-sqlite``rusqlite 0.37.x`)
- Target: `libsqlite3-sys 0.37.0`
- Latest upstream rusqlite: `0.39.0`
- **Blocker**: `matrix-sdk 0.16.0` pins `rusqlite 0.37.x` which pins `libsqlite3-sys 0.35.0`. A clean upgrade requires either waiting for matrix-sdk to bump their rusqlite dep, or upgrading matrix-sdk itself.
- **Reverted 2026-03-17**: A previous coder vendored the entire rusqlite crate with a fake `0.37.99` version and patched its libsqlite3-sys dep. This was too hacky — reverted to clean `0.35.0`.
## Acceptance Criteria
- [ ] `libsqlite3-sys` is upgraded to `0.37.0` via a clean dependency path (no vendored forks)
- [ ] `cargo build` succeeds
- [ ] All tests pass
- [ ] No `[patch.crates-io]` hacks or vendored crates

View File

@@ -0,0 +1,28 @@
---
name: "Long-running supervisor agent with periodic pipeline polling"
agent: coder-opus
---
# Story 280: Long-running supervisor agent with periodic pipeline polling
## User Story
As a project owner, I want a long-running supervisor agent (opus) that automatically monitors the pipeline, assigns agents, resolves stuck items, and handles routine operational tasks, so that I don't have to manually check status, kick agents, or babysit the pipeline in every conversation.
## Acceptance Criteria
- [ ] Server can start a persistent supervisor agent that stays alive across the session (not per-story)
- [ ] Server prods the supervisor periodically (default 30s, configurable in project.toml) with a pipeline status update
- [ ] Supervisor auto-assigns agents to unassigned items in current/qa/merge stages
- [ ] Supervisor detects stuck agents (no progress for configurable timeout) and restarts them
- [ ] Supervisor detects merge failures and sends stories back to current for rebase when appropriate
- [ ] Supervisor can be chatted with via Matrix (timmy relays to supervisor) or via the web UI
- [ ] Supervisor logs its decisions so the human can review what it did and why
- [ ] Polling interval is configurable in project.toml (e.g. supervisor_poll_interval_secs = 30)
- [ ] Supervisor logs persistent/recurring problems to `.story_kit/problems.md` with timestamp, description, and frequency — humans review this file periodically to create stories for systemic issues
## Out of Scope
- Supervisor accepting or merging stories to master (human job)
- Supervisor making architectural decisions
- Replacing the existing per-story agent spawning — supervisor coordinates on top of it

View File

@@ -0,0 +1,20 @@
---
name: "Matrix bot announces itself when it comes online"
---
# Story 281: Matrix bot announces itself when it comes online
## User Story
As a user in the Matrix room, I want Timmy to post a message when he starts up, so that I know the bot is online and ready to accept commands.
## Acceptance Criteria
- [ ] Bot sends a brief greeting message to each configured room on startup (e.g. 'Timmy is online.')
- [ ] Message uses the configured display_name, not a hardcoded name
- [ ] Message is only sent once per startup, not on reconnects or sync resumptions
- [ ] Bot does not announce if it was already running (e.g. after a brief network blip)
## Out of Scope
- TBD

View File

@@ -1,55 +0,0 @@
---
name: "Stop auto-committing intermediate pipeline moves"
---
# Spike 92: Stop auto-committing intermediate pipeline moves
## Goal
Determine how to stop the filesystem watcher from auto-committing every pipeline stage move (upcoming -> current -> qa -> merge) while still committing at terminal states (creation in upcoming, acceptance in archived). This keeps git history clean while preserving cross-machine portability for completed work.
## Context
The watcher in `server/src/io/watcher.rs` currently auto-commits every file change in `.story_kit/work/`. A single story run generates 5+ commits just from pipeline moves:
- `story-kit: create 42_story_foo`
- `story-kit: start 42_story_foo`
- `story-kit: queue 42_story_foo for QA`
- `story-kit: queue 42_story_foo for merge`
- `story-kit: accept 42_story_foo`
Since story runs complete relatively quickly, the intermediate state (current/qa/merge) is transient and doesn't need to be committed. Only creation and archival are meaningful checkpoints.
## Questions to Answer
1. Can we filter `stage_metadata()` to only commit for `1_upcoming` and `5_archived` stages while still broadcasting `WatcherEvent`s for all stages (so the frontend stays in sync)?
2. Should we keep `git add -A .story_kit/work/` for the committed stages, or narrow it to only the specific file?
3. What happens if the server crashes mid-pipeline? Uncommitted moves are lost — is this acceptable given the story can just be re-run?
4. Should intermediate moves be `.gitignore`d at the directory level, or is filtering in the watcher sufficient?
5. Do any other parts of the system (agent worktree setup, merge_agent_work, sparse checkout) depend on intermediate pipeline files being committed to master?
## Approach to Investigate
### Option A: Filter in `flush_pending()`
- In `flush_pending()`, still broadcast the `WatcherEvent` for all stages
- Only call `git_add_work_and_commit()` for stages `1_upcoming` and `5_archived`
- Simplest change — ~5 lines modified in `watcher.rs`
### Option B: Two-tier watcher
- Split into "commit-worthy" events (create, archive) and "notify-only" events (start, qa, merge)
- Commit-worthy events go through git
- Notify-only events just broadcast to WebSocket clients
- More explicit but same end result as Option A
### Option C: .gitignore intermediate directories
- Add `2_current/`, `3_qa/`, `4_merge/` to `.gitignore`
- Watcher still sees events (gitignore doesn't affect filesystem watching)
- Git naturally ignores them
- Risk: harder to debug, `git status` won't show pipeline state
## Acceptance Criteria
- [ ] Spike document updated with findings and recommendation
- [ ] If Option A is viable: prototype the change and verify git log is clean during a full story run
- [ ] Confirm frontend still receives real-time pipeline updates for all stages
- [ ] Confirm no other system depends on intermediate pipeline commits being on master
- [ ] Identify any edge cases (server crash, manual git operations, multi-machine sync)

View File

@@ -0,0 +1,25 @@
---
name: "Human QA gate with rejection flow"
---
# Story 247: Human QA gate with rejection flow
## User Story
As the project owner, I want stories to require my manual approval after machine QA before they can be merged, so that features that compile and pass tests but do not actually work correctly are caught before reaching master.
## Acceptance Criteria
- [ ] Story files support a manual_qa front matter field (defaults to true)
- [ ] After machine QA passes in 3_qa, stories with manual_qa: true wait for human approval before moving to 4_merge
- [ ] The UI shows a clear way to launch the app from the worktree for manual testing (single button click), with automatic port conflict handling via .story_kit_port
- [ ] Frontend and backend are pre-compiled during machine QA so the app is ready to run instantly for manual testing
- [ ] Only one QA app instance runs at a time — do not automatically spin up multiple instances
- [ ] Human can approve a story from 3_qa to move it to 4_merge
- [ ] Human can reject a story from 3_qa back to 2_current with notes about what is broken
- [ ] Rejection notes are written into the story file so the coder can see what needs fixing
- [ ] Stories with manual_qa: false skip the human gate and proceed directly from machine QA to 4_merge
## Out of Scope
- TBD

View File

@@ -0,0 +1,60 @@
---
name: "Matrix bot structured conversation history"
agent: coder-opus
---
# Story 266: Matrix bot structured conversation history
## User Story
As a user chatting with the Matrix bot, I want it to remember and own its prior responses naturally, so that conversations feel like talking to one continuous entity rather than a new instance each message.
## Acceptance Criteria
- [ ] Conversation history is passed as structured API messages (user/assistant turns) rather than a flattened text prefix
- [ ] Claude recognises its prior responses as its own, maintaining consistent personality across a conversation
- [ ] Per-room history survives server restarts (persisted to disk or database)
- [ ] Rolling window trimming still applies to keep context bounded
- [ ] Multi-user rooms still attribute messages to the correct sender
## Investigation Notes (2026-03-18)
The current implementation attempts session resumption via `--resume <session_id>` but it's not working:
### Code path: how session resumption is supposed to work
1. `server/src/matrix/bot.rs:671-676``handle_message()` reads `conv.session_id` from the per-room `RoomConversation` to get the resume ID.
2. `server/src/matrix/bot.rs:717` — passes `resume_session_id` to `provider.chat_stream()`.
3. `server/src/llm/providers/claude_code.rs:57``chat_stream()` stores it as `resume_id`.
4. `server/src/llm/providers/claude_code.rs:170-173` — if `resume_session_id` is `Some`, appends `--resume <id>` to the `claude -p` command.
5. `server/src/llm/providers/claude_code.rs:348``process_json_event()` looks for `json["session_id"]` in each streamed NDJSON event and sends it via a oneshot channel (`sid_tx`).
6. `server/src/llm/providers/claude_code.rs:122` — after the PTY exits, `sid_rx.await.ok()` captures the session ID (or `None` if never sent).
7. `server/src/matrix/bot.rs:785-787` — stores `new_session_id` back into `conv.session_id` and persists via `save_history()`.
### What's broken
- **No session_id captured:** `.story_kit/matrix_history.json` contains conversation entries but no `session_id`. `RoomConversation.session_id` is always `None`.
- **Root cause:** `claude -p --output-format stream-json` may not emit a `session_id` in its NDJSON events, or the parser at step 5 isn't matching the actual event shape. The oneshot channel never fires.
- **Effect:** Every message spawns a fresh Claude Code process with no `--resume` flag. Each turn is a blank slate.
- **History persistence works fine** — serialization round-trips correctly (test at `bot.rs:1335-1339`). The problem is purely that `--resume` is never invoked.
### Debugging steps
1. Run `claude -p "hello" --output-format stream-json --verbose 2>/dev/null` manually and inspect the NDJSON for a `session_id` field. Check what event type carries it and whether the key name matches what `process_json_event()` expects.
2. If `session_id` is present but nested differently (e.g. inside an `event` wrapper), fix the JSON path at `claude_code.rs:348`.
3. If `-p` mode doesn't emit `session_id` at all, consider an alternative: pass conversation history as a structured prompt prefix, or switch to the Claude API directly.
### Previous attempt failed (2026-03-18)
A sonnet coder attempted this story but did NOT fix the root cause. It rewrote the `chat_stream()` call in `bot.rs` to look identical to what was already there — it never investigated why `session_id` isn't being captured. The merge auto-resolver then jammed the duplicate call inside the `tokio::select!` permission loop, producing mismatched braces. The broken merge was reverted.
**What the coder must actually do:**
1. **Do NOT rewrite the `chat_stream()` call or the `tokio::select!` loop in `bot.rs`.** That code is correct and handles permission forwarding (story 275). Do not touch it.
2. **The bug is in `claude_code.rs`, not `bot.rs`.** The `process_json_event()` function at line ~348 looks for `json["session_id"]` but it's likely never finding it. Start by running step 1 above to see what the actual NDJSON output looks like.
3. **If `claude -p` doesn't emit `session_id` at all**, the `--resume` approach won't work. In that case, the fix is to pass conversation history as a prompt prefix (prepend prior turns to the user message) or use `--continue` instead of `--resume`, or call the Claude API directly instead of shelling out to the CLI.
4. **Rebase onto current master before starting.** Master has changed significantly (spike 92, story 275 permission handling, gitignore changes).
## Out of Scope
- TBD

View File

@@ -0,0 +1,19 @@
---
name: "Show assigned agent in expanded work item view"
---
# Story 271: Show assigned agent in expanded work item view
## User Story
As a project owner viewing an expanded work item in the web UI, I want to see which agent (e.g. coder-opus) has been assigned via front matter, so that I know which coder is working on or will pick up the story.
## Acceptance Criteria
- [ ] Expanded work item view displays the agent front matter field if set
- [ ] Shows the specific agent name (e.g. 'coder-opus') not just 'assigned'
- [ ] If no agent is set in front matter, the field is omitted or shows unassigned
## Out of Scope
- TBD

View File

@@ -0,0 +1,20 @@
---
name: "Matrix bot sends typing indicator while waiting for Claude response"
---
# Story 273: Matrix bot sends typing indicator while waiting for Claude response
## User Story
As a user chatting with the Matrix bot, I want to see a typing indicator in Element while the bot is processing my message, so that I know it received my request and is working on a response.
## Acceptance Criteria
- [ ] Bot sets m.typing on the room as soon as it starts the Claude API call
- [ ] Typing indicator is cleared when the first response chunk is sent to the room
- [ ] Typing indicator is cleared on error so it doesn't get stuck
- [ ] No visible delay between sending a message and seeing the typing indicator
## Out of Scope
- TBD

View File

@@ -0,0 +1,20 @@
---
name: "MCP pipeline status tool with agent assignments"
---
# Story 274: MCP pipeline status tool with agent assignments
## User Story
As a user checking pipeline status, I want an MCP tool that returns a structured status report including which agent is assigned to each work item, so that I can quickly see what's active and spot stuck items.
## Acceptance Criteria
- [ ] New MCP tool (e.g. `get_pipeline_status`) returns all work items across all active pipeline stages (current, qa, merge, done) with their stage, name, and assigned agent
- [ ] Upcoming backlog items are included with count or listing
- [ ] Agent assignment info comes from story front matter (`agent` field) and/or the running agent list
- [ ] Response is structured/deterministic (not free-form prose)
## Out of Scope
- TBD

View File

@@ -0,0 +1,19 @@
---
name: "Matrix bot uses its configured name instead of \"Claude\""
---
# Story 277: Matrix bot uses its configured name instead of "Claude"
## User Story
As a Matrix user, I want the bot to identify itself by its configured name (e.g., "Timmy") rather than "Claude", so that the bot feels like a distinct personality in the chat.
## Acceptance Criteria
- [ ] The Matrix bot refers to itself by its configured display name (e.g., 'Timmy') in conversations, not 'Claude'
- [ ] The bot's self-referencing name is derived from configuration, not hardcoded
- [ ] If no custom name is configured, the bot falls back to a sensible default
## Out of Scope
- TBD

View File

@@ -0,0 +1,17 @@
---
name: "Auto-assign agents to pipeline items on server startup"
---
# Story 278: Auto-assign agents to pipeline items on server startup
## User Story
As a ..., I want ..., so that ...
## Acceptance Criteria
- [ ] TODO
## Out of Scope
- TBD

View File

@@ -0,0 +1,20 @@
---
name: "Auto-assign should respect agent stage when front matter specifies agent"
---
# Story 279: Auto-assign should respect agent stage when front matter specifies agent
## User Story
As a project operator, I want auto-assign to respect the pipeline stage when a story's front matter specifies a preferred agent, so that a coder agent isn't assigned to do QA work just because the story originally requested that coder.
## Acceptance Criteria
- [ ] When a story in `3_qa/` has `agent: coder-opus` in front matter, auto-assign skips the preferred agent (stage mismatch) and assigns a free QA-stage agent instead
- [ ] When a story in `2_current/` has `agent: coder-opus` in front matter, auto-assign still respects the preference (stage matches)
- [ ] When the preferred agent's stage mismatches, auto-assign logs a message indicating the stage mismatch and fallback
## Out of Scope
- Changing the front matter `agent` field automatically when a story advances stages
- Adding per-stage agent preferences to front matter

View File

@@ -0,0 +1,119 @@
---
name: "Stop auto-committing intermediate pipeline moves"
agent: "coder-opus"
review_hold: true
---
# Spike 92: Stop auto-committing intermediate pipeline moves
## Goal
Determine how to stop the filesystem watcher from auto-committing every pipeline stage move (upcoming -> current -> qa -> merge -> done -> archive) while still committing at terminal states (creation in upcoming, acceptance in done and archived). This keeps git history clean while preserving cross-machine portability for completed work.
## Context
The watcher in `server/src/io/watcher.rs` currently auto-commits every file change in `.story_kit/work/`. A single story run generates 5+ commits just from pipeline moves:
- `story-kit: create 42_story_foo`
- `story-kit: start 42_story_foo`
- `story-kit: queue 42_story_foo for QA`
- `story-kit: queue 42_story_foo for merge`
- `story-kit: accept 42_story_foo`
Since story runs complete relatively quickly, the intermediate state (current/qa/merge) is transient and doesn't need to be committed. Only creation and archival are meaningful checkpoints.
## Questions to Answer
1. Can we filter `stage_metadata()` to only commit for `1_upcoming` and `6_archived` stages while still broadcasting `WatcherEvent`s for all stages (so the frontend stays in sync)?
2. Should we keep `git add -A .story_kit/work/` for the committed stages, or narrow it to only the specific file?
3. What happens if the server crashes mid-pipeline? Uncommitted moves are lost — is this acceptable given the story can just be re-run?
4. Should intermediate moves be `.gitignore`d at the directory level, or is filtering in the watcher sufficient?
5. Do any other parts of the system (agent worktree setup, merge_agent_work, sparse checkout) depend on intermediate pipeline files being committed to master?
## Findings
### Q1: Can we filter to only commit terminal stages?
**Yes.** The fix is in `flush_pending()`, not `stage_metadata()`. We add a `should_commit_stage()` predicate that returns `true` only for `1_upcoming` and `6_archived`. The event broadcast path is decoupled from the commit path — `flush_pending()` always broadcasts a `WatcherEvent` regardless of whether it commits.
Prototype implemented: added `COMMIT_WORTHY_STAGES` constant and `should_commit_stage()` function. The change is ~15 lines including the constant, predicate, and conditional in `flush_pending()`.
### Q2: Keep `git add -A .story_kit/work/` or narrow to specific file?
**Keep `git add -A .story_kit/work/`.** When committing a terminal stage (e.g. `6_archived`), the file has been moved from a previous stage (e.g. `5_done`). Using `-A` on the whole work directory captures both the addition in the new stage and the deletion from the old stage in a single commit. Narrowing to the specific file would miss the deletion side of the move.
### Q3: Server crash mid-pipeline — acceptable?
**Yes.** If the server crashes while a story is in `2_current`, `3_qa`, or `4_merge`, the file is lost from git but:
- The story file still exists on the filesystem (it's just not committed)
- The agent's work is in its own feature branch/worktree (independent of pipeline file state)
- The story can be re-queued from `1_upcoming` which IS committed
- Pipeline state is transient by nature — it reflects "what's happening right now", not permanent record
### Q4: `.gitignore` vs watcher filtering?
**Watcher filtering is sufficient.** `.gitignore` approach (Option C) has downsides:
- `git status` won't show pipeline state, making debugging harder
- If you ever need to commit an intermediate state (e.g. for a new feature), you'd have to fight `.gitignore`
- Watcher filtering is explicit and easy to understand — a constant lists the commit-worthy stages
- No risk of accidentally ignoring files that should be tracked
### Q5: Dependencies on intermediate pipeline commits?
**None found.** Thorough investigation confirmed:
1. **`merge_agent_work`** (`agents/merge.rs`): Creates a temporary `merge-queue/` branch and worktree. Reads the feature branch, not pipeline files. After merge, calls `move_story_to_archived()` which is a filesystem operation.
2. **Agent worktree setup** (`worktree.rs`): Creates worktrees from feature branches. Sparse checkout is a no-op (disabled). Does not read pipeline file state from git.
3. **MCP tool handlers** (`agents/lifecycle.rs`): `move_story_to_current()`, `move_story_to_merge()`, `move_story_to_qa()`, `move_story_to_archived()` — all pure filesystem `fs::rename()` operations. None perform git commits.
4. **Frontend** (`http/workflow.rs`): `load_pipeline_state()` reads directories from the filesystem directly via `fs::read_dir()`. Never calls git. WebSocket events keep the frontend in sync.
5. **No git inspection commands** reference pipeline stage directories anywhere in the codebase.
### Edge Cases
- **Multi-machine sync:** Only `1_upcoming` and `6_archived` are committed. If you push/pull, you'll see story creation and archival but not intermediate pipeline state. This is correct — intermediate state is machine-local runtime state.
- **Manual git operations:** `git status` will show uncommitted files in intermediate stages. This is actually helpful for debugging — you can see what's in the pipeline without grepping git log.
- **Sweep (5_done → 6_archived):** The sweep moves files to `6_archived`, which triggers a watcher event that WILL commit (since `6_archived` is a terminal stage). This naturally captures the final state.
## Approach to Investigate
### Option A: Filter in `flush_pending()` ← **RECOMMENDED**
- In `flush_pending()`, still broadcast the `WatcherEvent` for all stages
- Only call `git_add_work_and_commit()` for stages `1_upcoming` and `6_archived`
- Simplest change — ~15 lines modified in `watcher.rs`
### Option B: Two-tier watcher
- Split into "commit-worthy" events (create, archive) and "notify-only" events (start, qa, merge)
- Commit-worthy events go through git
- Notify-only events just broadcast to WebSocket clients
- More explicit but same end result as Option A
### Option C: .gitignore intermediate directories
- Add `2_current/`, `3_qa/`, `4_merge/` to `.gitignore`
- Watcher still sees events (gitignore doesn't affect filesystem watching)
- Git naturally ignores them
- Risk: harder to debug, `git status` won't show pipeline state
## Recommendation
**Option A is viable and implemented.** The prototype is in `server/src/io/watcher.rs`:
- Added `COMMIT_WORTHY_STAGES` constant: `["1_upcoming", "6_archived"]`
- Added `should_commit_stage()` predicate
- Modified `flush_pending()` to conditionally commit based on stage, while always broadcasting events
- All 872 tests pass, clippy clean
A full story run will now produce only 2 pipeline commits instead of 5+:
- `story-kit: create 42_story_foo` (creation in `1_upcoming`)
- `story-kit: accept 42_story_foo` (archival in `6_archived`)
The intermediate moves (`start`, `queue for QA`, `queue for merge`, `done`) are still broadcast to WebSocket clients for real-time frontend updates, but no longer clutter git history.
## Acceptance Criteria
- [x] Spike document updated with findings and recommendation
- [x] If Option A is viable: prototype the change and verify git log is clean during a full story run
- [x] Confirm frontend still receives real-time pipeline updates for all stages
- [x] Confirm no other system depends on intermediate pipeline commits being on master
- [x] Identify any edge cases (server crash, manual git operations, multi-machine sync)

View File

@@ -0,0 +1,28 @@
---
name: /btw Side Question Slash Command
---
## User Story
As a user, I want to ask the agent a quick side question using `/btw` so that I can get a fast answer from the current conversation context without disrupting the main chat thread.
## Acceptance Criteria
- [ ] User can type `/btw <question>` in the chat input
- [ ] The agent answers using the full conversation history as context
- [ ] The question and response are displayed in a dismissible overlay, not in the main chat thread
- [ ] The question and response are not added to the conversation history
- [ ] No tool calls are made when answering a `/btw` question — the agent responds only from what is already in context
- [ ] The overlay can be dismissed with Escape, Enter, or Space
- [ ] `/btw` can be invoked while the agent is actively processing a response without interrupting it
- [ ] The slash command detection and dispatch mechanism must be reusable — build a shared parser/router so future slash commands (e.g. /help, /status) can plug in without duplicating detection logic
## Out of Scope
- Follow-up turns within the side question
- Tool usage in side question responses
- Persisting side question history
## Rejection Notes
**2026-03-14:** Previous implementation was rejected. The frontend did nothing when the user typed `/btw` — the slash command was not wired up in the UI at all. The backend may have had changes but the feature was non-functional from the user's perspective. Ensure the full end-to-end flow works: typing `/btw <question>` in the chat input must visibly trigger the overlay with a response.

View File

@@ -0,0 +1,24 @@
---
name: "/help Slash Command"
---
# Story 241: /help Slash Command
## User Story
As a user, I want to type /help in the chat input so that I can see a list of available slash commands and what they do.
## Acceptance Criteria
- [ ] User can type /help in the chat input
- [ ] A help overlay or panel displays all available slash commands with brief descriptions
- [ ] The overlay can be dismissed with Escape, Enter, or Space
- [ ] The slash command detection and dispatch mechanism is shared across all slash commands (reuse the same parser/router used by /btw and other slash commands — do not duplicate detection logic)
## Out of Scope
- TBD
## Rejection Notes
**2026-03-14:** Previous implementation was rejected. The frontend did nothing when the user typed `/help` — the slash command was not wired up in the UI at all. Ensure the full end-to-end flow works: typing `/help` in the chat input must visibly display the help overlay with slash command descriptions.

View File

@@ -0,0 +1,20 @@
---
name: "/status Slash Command"
---
# Story 242: /status Slash Command
## User Story
As a user, I want to type /status in the chat input so that I can see the current state of the agent, active story, pipeline stage, and any running processes at a glance.
## Acceptance Criteria
- [ ] User can type /status in the chat input
- [ ] A status overlay or panel shows: current story (if any), pipeline stage, agent status, and running processes
- [ ] The overlay can be dismissed with Escape, Enter, or Space
- [ ] The slash command detection and dispatch mechanism is shared across all slash commands (reuse the same parser/router used by /btw and other slash commands — do not duplicate detection logic)
## Out of Scope
- TBD

View File

@@ -0,0 +1,28 @@
---
name: "Replace pnpm with npm"
---
# Bug 243: Replace pnpm with npm
## Description
pnpm's reflink-based package import frequently fails with ERR_PNPM_ENOENT when running in git worktrees (.story_kit/merge_workspace), causing merge quality gates to fail repeatedly. No pnpm-specific features are in use.
## How to Reproduce
Move any story to merge. The mergemaster runs pnpm install in the merge worktree and it fails with ERR_PNPM_ENOENT reflink errors.
## Actual Result
pnpm install fails in merge worktrees, blocking all merges.
## Expected Result
Package installation works reliably in all worktree contexts.
## Acceptance Criteria
- [ ] pnpm-lock.yaml is removed and package-lock.json is generated
- [ ] All pnpm references in project.toml are replaced with npm equivalents
- [ ] npm install and npm run build succeed in a clean worktree
- [ ] No other pnpm references remain in project configuration

View File

@@ -0,0 +1,68 @@
---
name: "Chat history persistence lost on page refresh (story 145 regression)"
agent: coder-opus
---
## Rejection Notes
**2026-03-16:** Previous coder produced zero code changes — feature branch had no diff against master. The coder must actually use `git bisect` to find the breaking commit and produce a surgical fix. Do not submit with no code changes.
**2026-03-17:** Re-opened. Multiple fix attempts have failed. See investigation notes below for the actual root cause.
# Bug 245: Chat history persistence lost on page refresh (story 145 regression)
## Description
Story 145 implemented localStorage persistence for chat history across page reloads. This is no longer working — refreshing the page loses all conversation context. This is a regression of the feature delivered in story 145.
## How to Reproduce
1. Open the web UI and have a conversation with the agent
2. Refresh the page (F5 or Cmd+R)
3. Send a new message
4. The LLM has no knowledge of the prior conversation
## Actual Result
Chat history is gone after refresh — the UI shows a blank conversation. Even if messages appear in the UI (loaded from localStorage), the LLM does not receive them as context on the next exchange.
## Expected Result
Chat history is restored from localStorage on page load, as implemented in story 145. The LLM should receive the full conversation history when the user sends a new message after refresh.
## Acceptance Criteria
- [ ] Chat messages survive a full page refresh (visible in UI)
- [ ] Chat messages are restored from localStorage on component mount
- [ ] After refresh, the LLM receives full prior conversation history as context when the user sends the next message
- [ ] Behaviour matches the original acceptance criteria from story 145
## Investigation Notes (2026-03-17)
### Root cause analysis
The frontend correctly:
1. Persists messages to localStorage in `useChatHistory.ts` (key: `storykit-chat-history:{projectPath}`)
2. Loads them on mount
3. Sends the FULL history array to the backend via `wsRef.current?.sendChat(newHistory, config)` in `Chat.tsx` line ~558
The backend bug is in `server/src/llm/chat.rs`:
- The `chat()` function receives the full `messages: Vec<Message>` from the client
- Line ~283: `let mut current_history = messages.clone()` — correctly clones full history
- Lines ~299-318: Adds 2 system prompts at position 0 and 1
- Lines ~323-404: Main LLM loop generates new assistant/tool messages
- **Line ~407: `ChatResult { messages: new_messages }` — BUG: returns ONLY the newly generated turn, not the full `current_history`**
During streaming, the `on_update()` callbacks DO send `current_history[2..]` (full history minus system prompts), which is correct. But there may be a reconciliation issue on the frontend where the final state doesn't include the full history.
### Key files
- `frontend/src/hooks/useChatHistory.ts` — localStorage persistence
- `frontend/src/components/Chat.tsx` — sends full history, handles `onUpdate` callbacks
- `frontend/src/api/client.ts` — WebSocket client
- `server/src/http/ws.rs` — WebSocket handler, passes messages to chat()
- `server/src/llm/chat.rs`**THE BUG** at line ~407, ChatResult returns only new_messages
### What NOT to do
- Do NOT layer on a new localStorage implementation. The localStorage code works fine.
- Do NOT add server-side persistence. The "dumb pipe" architecture is correct.
- The fix should be surgical — ensure the full conversation history round-trips correctly through the backend.

View File

@@ -0,0 +1,21 @@
---
name: "Enforce cryptographic identity verification for Matrix commands"
---
# Story 246: Enforce cryptographic identity verification for Matrix commands
## User Story
As the operator of a Story Kit instance, I want the Matrix bot to always require cryptographic device verification before executing commands, so that a compromised homeserver cannot be used to execute unauthorized commands.
## Acceptance Criteria
- [ ] Bot refuses to process commands from unencrypted rooms
- [ ] Bot always verifies the sending device is cross-signing-verified by a trusted identity before executing any command
- [ ] The require_verified_devices config option is removed — verification is always on with no way to disable it
- [ ] Messages from unverified devices are rejected with a clear log message
- [ ] Existing allowed_users check remains as a first-pass filter before the cryptographic check
## Out of Scope
- TBD

View File

@@ -0,0 +1,27 @@
---
name: "Chat does not auto-scroll to new messages"
---
# Bug 248: Chat does not auto-scroll to new messages
## Description
The chat UI does not automatically scroll to the bottom when new assistant messages stream in. The user has to manually scroll down to see the response, making it appear as if the bot stopped responding.
## How to Reproduce
1. Send a message in the chat UI
2. Wait for the assistant to respond with a long message or multi-turn tool use
3. Observe that the viewport does not scroll to follow the new content
## Actual Result
The viewport stays at the current scroll position. New messages appear below the fold, invisible to the user.
## Expected Result
The chat viewport should auto-scroll to the bottom as new content streams in, keeping the latest message visible.
## Acceptance Criteria
- [ ] Bug is fixed and verified

View File

@@ -0,0 +1,25 @@
---
name: "Agent assignment via story front matter"
---
## Rejection Notes
**2026-03-16:** Previous coder only updated the serve submodule pointer — no actual implementation. Feature branch also reverted changes from stories 246 and 248. The agent front matter parsing and pipeline assignment logic was never written. Start fresh on a clean branch from master.
# Story 249: Agent assignment via story front matter
## User Story
As a project owner, I want to specify which agent should work on a story via a front matter field (e.g. agent: coder-opus) so that complex stories get assigned to the right coder automatically.
## Acceptance Criteria
- [ ] Story files support an optional agent front matter field (e.g. agent: coder-opus)
- [ ] When the pipeline auto-assigns a coder to a story, it uses the agent specified in front matter if present
- [ ] If the specified agent is busy, the story waits rather than falling back to a different coder
- [ ] If no agent is specified in front matter, the existing default assignment behaviour is used
- [ ] The supervisor agent respects the front matter assignment when starting coders
## Out of Scope
- TBD

View File

@@ -0,0 +1,34 @@
---
name: "Merge pipeline cherry-pick fails with bad revision on merge-queue branch"
---
## Rejection Notes
**2026-03-16:** Previous coder (coder-opus) produced zero code changes. The feature branch had no diff against master. Actually fix the bug this time.
# Bug 250: Merge pipeline cherry-pick fails with bad revision on merge-queue branch
## Description
The mergemaster merge pipeline consistently fails at the cherry-pick step with: fatal: bad revision merge-queue/{story_id}. The merge-queue branch is created and the squash commit succeeds, but the branch reference is not accessible during the subsequent cherry-pick onto master. This affects every story that reaches the merge stage — no stories can be automatically merged. The issue is in the git reference handling within the merge pipeline, not a code conflict.
## How to Reproduce
1. Have a completed story in 4_merge/ with a feature branch containing commits ahead of master
2. Trigger merge_agent_work via MCP or let the mergemaster agent run
3. Observe the cherry-pick failure
## Actual Result
Cherry-pick fails with fatal: bad revision merge-queue/{story_id}. The merge-queue branch was created and squash commit succeeded, but the branch reference is not found during cherry-pick. Master is untouched.
## Expected Result
The merge pipeline should successfully squash-merge the feature branch into master, run quality gates, move the story to done, and clean up the worktree and branch.
## Acceptance Criteria
- [ ] Use git bisect or git log to find when the merge pipeline broke
- [ ] Fix the root cause — do not layer on a workaround
- [ ] Merge pipeline successfully merges a story from 4_merge to master end-to-end
- [ ] Quality gates run and pass before the merge commits to master

View File

@@ -0,0 +1,32 @@
---
name: "Archive sweep not moving stories from done to archived"
---
# Bug 251: Archive sweep not moving stories from done to archived
## Description
Stories that have been in `5_done/` for well over the configured retention period (default 4 hours) are not being automatically swept to `6_archived/`. Items from March 14 are still sitting in `5_done/` as of March 16 — over 2 days past the threshold. The last items that successfully reached `6_archived/` date from Feb 23-24.
Additionally, story file moves (e.g. from one pipeline stage to another) are sometimes not being auto-committed, which used to work.
## How to Reproduce
1. Start the Story Kit server
2. Move a story to `5_done/`
3. Wait longer than `done_retention_secs` (default 14400 seconds / 4 hours)
4. Observe that the story is never moved to `6_archived/`
## Actual Result
Stories remain in `5_done/` indefinitely. No sweep log messages appear in the server output.
## Expected Result
Stories older than `done_retention_secs` are automatically moved to `6_archived/` and the move is auto-committed.
## Investigation Notes
The sweep logic lives in `server/src/io/watcher.rs` around line 208 (`sweep_done_to_archived()`). The watcher runs on a dedicated OS thread (line 310) with a timer-based sweep interval (line 441, default 60s).
**Do NOT layer new code on top of this.** Use `git bisect` or `git log` to find when the sweep stopped working. The code looks structurally correct — the watcher thread may be dying silently with no restart mechanism, or something changed in how/when the sweep is triggered. Find the root cause and fix it there.

View File

@@ -0,0 +1,22 @@
---
name: "Coder agents must find root causes for bugs"
---
# Story 252: Coder agents must find root causes for bugs
## User Story
As a project owner, I want coder agents to always investigate and fix the root cause of bugs rather than layering new code on top, so that fixes are surgical, minimal, and don't introduce unnecessary complexity.
## Acceptance Criteria
- [ ] When a coder agent picks up a bug, it must first investigate to find the root cause (e.g. using `git bisect`, `git log`, reading the relevant code history)
- [ ] The coder's commit message must explain what broke and why, not just what was changed
- [ ] Coders must not add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible
- [ ] The system prompt or agent instructions for coder agents include clear guidance: "For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible."
- [ ] If a coder cannot determine the root cause, it must document what it tried and why it was inconclusive, rather than guessing and shipping a speculative fix
## Out of Scope
- Changes to how stories (non-bugs) are handled
- Automated enforcement (this is guidance/instruction, not a gate)

View File

@@ -0,0 +1,35 @@
---
name: "Watcher and auto-assign do not reinitialize when project root changes"
---
# Bug 253: Watcher and auto-assign do not reinitialize when project root changes
## Description
When the server starts, if the frontend opens the project at the wrong path (e.g. server/ subdirectory instead of project root), the filesystem watcher and auto-assign run against that wrong path. When the frontend corrects itself by calling DELETE /project then open_project with the right path, the watcher and auto-assign do not reinitialize. This means:
1. The filesystem watcher watches the wrong directory for the entire session
2. Auto-assign only runs once at startup (against the wrong root) and never re-runs
3. Stories placed in 2_current/ are never auto-assigned to coders
4. The archive sweep never fires (same watcher thread)
This is likely the root cause of bug 251 (archive sweep not working) and explains why coders are not being auto-assigned.
## How to Reproduce
1. Start the Story Kit server\n2. Open a project in the frontend — note the first open_project sets project_root to the wrong subdirectory\n3. Frontend corrects by calling DELETE /project then open_project with the correct path\n4. Move a story into 2_current/\n5. Observe that no coder is auto-assigned
## Actual Result
Watcher and auto-assign remain bound to the initial (wrong) project root. No filesystem events are detected for the correct project directory. Stories in 2_current/ are never picked up.
## Expected Result
When project_root changes via open_project, the watcher thread should be stopped and restarted against the new root, and auto_assign_available_work() should re-run.
## Acceptance Criteria
- [ ] When open_project is called with a new path, the filesystem watcher restarts watching the new project root
- [ ] auto_assign_available_work() re-runs after a project root change
- [ ] If DELETE /project is called, the watcher stops (no zombie watcher on a stale path)
- [ ] Stories in 2_current/ are auto-assigned after the project root is corrected

View File

@@ -0,0 +1,23 @@
---
name: "Add refactor work item type"
merge_failure: "merge_agent_work tool returned empty output on two attempts. The merge-queue branch (merge-queue/254_story_add_refactor_work_item_type) was created with squash merge commit 27d24b2, and the merge workspace worktree exists at .story_kit/merge_workspace, but the pipeline never completed (no success/failure logged after MERGE-DEBUG calls). The stale merge workspace worktree may be blocking completion. Possibly related to bug 250 (merge pipeline cherry-pick fails with bad revision on merge-queue branch). Human intervention needed to: 1) clean up the merge-queue worktree and branch, 2) investigate why the merge pipeline hangs after creating the squash merge commit, 3) retry the merge."
---
# Story 254: Add refactor work item type
## User Story
As a project owner, I want a refactor work item type so that I can track and assign code restructuring tasks separately from features and bugs.
## Acceptance Criteria
- [ ] create_refactor MCP tool creates a refactor file in work/1_upcoming/ with deterministic filename (e.g. 254_refactor_split_agents_rs.md)
- [ ] Refactor files use the naming convention {id}_refactor_{slug}.md
- [ ] Refactor items flow through the same pipeline as stories and bugs (upcoming → current → qa → merge → done → archived)
- [ ] list_refactors MCP tool lists open refactors in upcoming
- [ ] Frontend displays refactor items distinctly from stories and bugs (different label/color)
- [ ] Watcher recognizes refactor files and auto-commits moves like stories and bugs
## Out of Scope
- TBD

View File

@@ -0,0 +1,16 @@
---
name: "Show agent logs in expanded story popup"
merge_failure: "merge_agent_work tool returned empty output. The merge pipeline created the merge-queue branch (merge-queue/255_story_show_agent_logs_in_expanded_story_popup) and merge workspace worktree at .story_kit/merge_workspace, but hung without completing. This is the same issue that affected story 254 — likely related to bug 250 (merge pipeline cherry-pick fails with bad revision on merge-queue branch). The stale merge workspace worktree on the merge-queue branch may be blocking completion. Human intervention needed to: 1) clean up the merge workspace worktree and merge-queue branch, 2) investigate the root cause in the merge pipeline (possibly the cherry-pick/fast-forward step after squash merge), 3) retry the merge."
---
# Story 255: Show agent logs in expanded story popup
## Description
The expanded story popup has an "Agent Logs" tab that currently shows "No output". Implement the frontend and any necessary API wiring to display agent output in this tab. This is new functionality — agent logs have never been shown here before.
## Acceptance Criteria
- [ ] Agent Logs tab shows real-time output from running agents
- [ ] Agent Logs tab shows historical output from completed/failed agents
- [ ] Logs are associated with the correct story

View File

@@ -0,0 +1,34 @@
---
name: "Bot must verify other users' cross-signing identity before checking device verification"
agent: mergemaster
---
# Story 256: Bot must verify other users' cross-signing identity before checking device verification
## User Story
As a Matrix user messaging the bot, I want the bot to correctly recognize my cross-signing-verified devices, so that my messages are not rejected when I have a valid verified identity.
## Acceptance Criteria
- [ ] The bot's `check_sender_verified` function (or equivalent) verifies the sender's identity trust status, not just individual device verification
- [ ] When @yossarian:crashlabs.io (who has valid cross-signing keys) sends a message in an encrypted room, the bot accepts it instead of rejecting with 'no cross-signing-verified device found'
- [ ] The bot still rejects messages from users who genuinely have no cross-signing setup
- [ ] Existing tests (if any) continue to pass after the change
## Out of Scope
- TBD
## Test Results
<!-- story-kit-test-results: {"unit":[{"name":"sender_with_cross_signing_identity_is_accepted","status":"pass","details":"Verifies get_user_identity Some(_) → accepted"},{"name":"sender_without_cross_signing_identity_is_rejected","status":"pass","details":"Verifies get_user_identity None → rejected"}],"integration":[]} -->
### Unit Tests (2 passed, 0 failed)
- ✅ sender_with_cross_signing_identity_is_accepted — Verifies get_user_identity Some(_) → accepted
- ✅ sender_without_cross_signing_identity_is_rejected — Verifies get_user_identity None → rejected
### Integration Tests (0 passed, 0 failed)
*No integration tests recorded.*

View File

@@ -0,0 +1,15 @@
---
name: "Rename StorkIt to Story Kit in the header"
---
# Story 257: Rename "StorkIt" to "Story Kit" in the header
## Description
The ChatHeader component displays "StorkIt" as the app title. It should say "Story Kit" instead.
## Acceptance Criteria
- [ ] The header in `ChatHeader.tsx` displays "Story Kit" instead of "StorkIt"
- [ ] The test in `ChatHeader.test.tsx` is updated to match
- [ ] All existing tests pass

View File

@@ -0,0 +1,26 @@
---
name: "Auto-assign not called after merge failure"
---
# Bug 258: Auto-assign not called after merge failure
## Description
When the background merge pipeline fails (e.g. quality gate timeout), `auto_assign_available_work` is never called. The story stays in `4_merge/` with no agent assigned, requiring manual intervention.
### Root cause
In `pool.rs`, `start_merge_agent_work` spawns a tokio task that calls `run_merge_pipeline`. On failure, the task updates the job status to `Failed` but does NOT call `auto_assign_available_work`. The only call to `auto_assign` in the merge pipeline is inside `run_merge_pipeline` on the success path (line ~1251).
The `spawn_pipeline_advance` completion handler does call `auto_assign` after the mergemaster agent exits, but only on the success path (post-merge tests pass → move to done → auto_assign). On failure, it returns early without triggering auto-assign.
There is no periodic sweep — auto-assign is purely reactive (watcher events, agent completions, startup).
### Impact
After a merge failure, the story is permanently stuck in `4_merge/` with no agent. The only way to unstick it is to restart the server or manually trigger a watcher event.
## Acceptance Criteria
- [ ] After a merge pipeline failure, `auto_assign_available_work` is called so the mergemaster can retry
- [ ] Stories in `4_merge/` do not get permanently stuck after transient merge failures

View File

@@ -0,0 +1,20 @@
---
name: "Move story-kit ignores into .story_kit/.gitignore"
---
# Story 259: Move story-kit ignores into .story_kit/.gitignore
## User Story
As a developer using story-kit, I want story-kit-specific gitignore patterns to live inside .story_kit/.gitignore, so that the host project's root .gitignore stays clean and story-kit concerns are self-contained.
## Acceptance Criteria
- [ ] A .gitignore file exists at .story_kit/.gitignore containing all story-kit-specific ignore patterns
- [ ] The root .gitignore no longer contains story-kit-specific ignore patterns
- [ ] The deterministic project scaffold process creates .story_kit/.gitignore when initialising a new project
- [ ] Existing repos continue to work correctly after the change (no previously-ignored files become tracked)
## Out of Scope
- TBD

View File

@@ -0,0 +1,19 @@
---
name: "Bot notifications when stories move between stages"
agent: coder-opus
---
# Story 261: Bot notifications when stories move between stages
## User Story
As a user, I want to receive bot notifications in the channel whenever a story moves between pipeline stages, so that I can track progress without manually checking status.
## Acceptance Criteria
- [ ] Bot sends a notification to the channel each time a story transitions between stages (e.g. upcoming → current, current → QA, QA → merge, merge → done)
- [ ] Notification includes the story number, name, and the stage transition (from → to)
## Out of Scope
- TBD

View File

@@ -0,0 +1,24 @@
---
name: "Bot error notifications for story failures (with shared messaging)"
---
# Story 262: Bot error notifications for story failures
## User Story
As a user, I want to receive bot notifications with an error icon in the channel whenever a story errors out (e.g. merge failure), so that I'm immediately aware of problems.
## Design Constraint
Story 261 adds stage-transition notifications using the same Matrix messaging path. Extract a shared utility/module for sending Matrix messages so that both error notifications (this story) and stage-transition notifications (261) use the same code path. Do not duplicate Matrix message-sending logic.
## Acceptance Criteria
- [ ] Bot sends an error notification to the channel when a story encounters a failure (e.g. merge failure)
- [ ] Notification includes an error icon to distinguish it from normal stage-transition notifications
- [ ] Notification includes the story number, name, and a description of the error
- [ ] Matrix message-sending logic is in a shared module usable by both error and stage-transition notifications
## Out of Scope
- Stage-transition notifications (covered by story 261)

View File

@@ -0,0 +1,21 @@
---
name: "Matrix bot self-signs device keys at startup for verified encryption"
agent: mergemaster
---
# Story 263: Matrix bot self-signs device keys at startup for verified encryption
## User Story
As a Matrix room participant, I want the bot's messages to not show "encrypted by a device not verified by its owner" warnings, so that I have confidence the bot's encryption is fully verified.
## Acceptance Criteria
- [ ] At startup the bot checks whether its own device keys have been self-signed (cross-signed by its own user identity)
- [ ] If the device keys are not self-signed, the bot signs them automatically
- [ ] After signing, the bot uploads the new signatures to the homeserver
- [ ] After a clean start (fresh matrix_store / device_id) the bot's messages no longer show the 'encrypted by a device not verified by its owner' warning
## Out of Scope
- TBD

View File

@@ -0,0 +1,43 @@
---
name: "Claude Code session ID not persisted across browser refresh"
---
# Bug 264: Claude Code session ID not persisted across browser refresh
## Description
The Claude Code provider uses a session_id to resume conversations via `--resume <id>`. This session_id is stored in React state (`claudeSessionId`) but is NOT persisted to localStorage. After a browser refresh, the session_id is lost (`null`), so Claude Code cannot resume the prior session.
A fallback exists (`build_claude_code_context_prompt` in `server/src/llm/chat.rs:188`) that injects prior messages as flattened text inside a `<conversation_history>` block, but this loses structure (tool calls, tool results, reasoning) and Claude Code treats it as informational text rather than actual conversation turns. In practice, the LLM does not retain meaningful context after refresh.
This is the root cause behind bug 245 (chat history persistence regression). The localStorage message persistence from story 145 works correctly for the UI, but the LLM context is not properly restored because the session cannot be resumed.
Key files:
- `frontend/src/components/Chat.tsx:174``claudeSessionId` is ephemeral React state
- `frontend/src/components/Chat.tsx:553` — session_id only sent when non-null
- `server/src/llm/chat.rs:278` — backend branches on session_id presence
- `server/src/llm/providers/claude_code.rs:44``--resume` flag passed to Claude CLI
## How to Reproduce
1. Open the Story Kit web UI and select claude-code-pty as the model
2. Have a multi-turn conversation with the agent
3. Refresh the browser (F5 or Cmd+R)
4. Send a new message referencing the prior conversation
5. The LLM has no knowledge of the prior conversation
## Actual Result
After refresh, claudeSessionId is null. Claude Code spawns a fresh session without --resume. The fallback text injection is too lossy to provide meaningful context. The LLM behaves as if the conversation never happened.
## Expected Result
After refresh, the Claude Code session is resumed via --resume, giving the LLM full context of the prior conversation including tool calls, reasoning, and all turns.
## Acceptance Criteria
- [ ] claudeSessionId is persisted to localStorage (scoped by project path) and restored on component mount
- [ ] After browser refresh, the next chat message includes session_id in the ProviderConfig
- [ ] Claude Code receives --resume with the persisted session_id after refresh
- [ ] Clearing the session (clear button) also clears the persisted session_id
- [ ] After server restart with session files intact on disk, conversation resumes correctly

View File

@@ -0,0 +1,33 @@
---
name: "Spikes skip merge and stop for human review"
agent: coder-opus
---
# Story 265: Spikes skip merge and stop for human review
## User Story
As a user, I want spike work items to stop after QA instead of auto-advancing to the merge stage, so that I can review the spike's findings and prototype code in the worktree before deciding what to do with them.
## Context
Spikes are investigative — their value is the findings and any prototype code, not a merge to master. The user needs to:
- Read the spike document with findings
- Review prototype code in the worktree
- Optionally build and run the prototype to validate the approach
- Then manually decide: archive the spike and create follow-up stories, or reject and re-investigate
Currently all work items follow the same pipeline: coder → QA → merge → done. Spikes should diverge after QA and wait for human review instead of auto-advancing to merge.
## Acceptance Criteria
- [ ] Items with `_spike_` in the filename skip the merge stage after QA passes
- [ ] After QA, spike items remain accessible for human review (worktree preserved, not cleaned up)
- [ ] Spikes do not auto-advance to `4_merge/` — they stay in `3_qa/` or move to a review-hold state
- [ ] The human can manually archive the spike when done reviewing
- [ ] Non-spike items (stories, bugs, refactors) continue through the full pipeline as before
## Out of Scope
- New UI for spike review (manual file inspection is fine)
- Changes to the spike creation flow

View File

@@ -0,0 +1,19 @@
---
name: "MCP update_story tool should support front matter fields"
---
# Story 267: MCP update_story tool should support front matter fields
## User Story
As an operator using the MCP tools, I want update_story to accept optional front matter fields (like agent, manual_qa, etc.) so that I can update story metadata without editing files by hand.
## Acceptance Criteria
- [ ] update_story MCP tool accepts optional agent parameter to set/change the agent front matter field
- [ ] update_story MCP tool accepts optional arbitrary front matter key-value pairs
- [ ] Front matter updates are auto-committed via the filesystem watcher like other story mutations
## Out of Scope
- TBD

View File

@@ -0,0 +1,23 @@
---
name: "Upgrade tokio-tungstenite to 0.29.0"
---
# Refactor 268: Upgrade tokio-tungstenite to 0.29.0
## Current State
- TBD
## Desired State
Upgrade tokio-tungstenite from 0.28.0 to 0.29.0 in workspace Cargo.toml and fix any breaking API changes.
## Acceptance Criteria
- [ ] tokio-tungstenite = "0.29.0" in workspace Cargo.toml
- [ ] All code compiles without errors
- [ ] All tests pass
## Out of Scope
- TBD

View File

@@ -0,0 +1,22 @@
---
name: "@ file references in web UI chat input"
---
# Story 269: @ file references in web UI chat input
## User Story
As a user chatting in the web UI, I want to type @ to get an autocomplete overlay listing project files, so that I can reference specific files in my messages the same way Zed and Claude Code do.
## Acceptance Criteria
- [ ] Typing @ in the chat input triggers a file picker overlay
- [ ] Overlay searches project files with fuzzy matching as the user types after @
- [ ] Selecting a file inserts a reference into the message (e.g. @path/to/file.rs)
- [ ] The referenced file contents are included as context when the message is sent to the LLM
- [ ] Overlay is dismissable with Escape
- [ ] Multiple @ references can be used in a single message
## Out of Scope
- TBD

View File

@@ -0,0 +1,31 @@
---
name: "QA test server overwrites root .mcp.json with wrong port"
---
# Bug 270: QA test server overwrites root .mcp.json with wrong port
## Description
When the QA agent starts a test server in a worktree (e.g. on port 3012), that server auto-detects the shared project root and calls open_project, which writes .mcp.json with the test server's port. This clobbers the root .mcp.json that should always point to the main server (port 3001).
Root cause: open_project in server/src/io/fs.rs:527 unconditionally calls write_mcp_json(&p, port) with its own port. Because worktrees share .story_kit/ with the real project, the test server resolves to the real project root and overwrites the root .mcp.json instead of writing to its own worktree directory.
Fix: Remove the write_mcp_json call from open_project entirely. Worktree .mcp.json files are already written correctly during worktree creation (worktree.rs:81,97), and the root .mcp.json is committed in git. open_project should not touch it.
## How to Reproduce
1. QA agent starts on a story\n2. QA agent starts a test server in the worktree on a non-default port (e.g. 3012)\n3. Test server auto-opens the project root\n4. Root .mcp.json is overwritten with test port
## Actual Result
Root .mcp.json contains the QA test server's port (e.g. 3012) instead of the main server's port (3001). Interactive Claude sessions lose MCP connectivity.
## Expected Result
Root .mcp.json always points to the primary server's port. Test servers started by QA agents should not overwrite it.
## Acceptance Criteria
- [ ] QA test servers do not overwrite root .mcp.json
- [ ] Root .mcp.json always reflects the primary server's port
- [ ] Worktree .mcp.json files are only written during worktree creation

View File

@@ -0,0 +1,19 @@
---
name: "Clear merge error front matter when story leaves merge stage"
---
# Story 272: Clear merge error front matter when story leaves merge stage
## User Story
As an operator, I want merge error front matter to be automatically removed when a story is moved out of the merge stage via MCP, so that stale error metadata doesn't persist when the story is retried.
## Acceptance Criteria
- [ ] When a story with merge_error front matter is moved out of 4_merge via MCP, the merge_error field is automatically stripped
- [ ] Works for all destinations: back to 2_current, back to 1_upcoming, or forward to 5_done
- [ ] Stories without merge_error front matter are unaffected
## Out of Scope
- TBD

View File

@@ -0,0 +1,22 @@
---
name: "Matrix bot surfaces Claude Code permission prompts to chat"
agent: coder-opus
---
# Story 275: Matrix bot surfaces Claude Code permission prompts to chat
## User Story
As a user chatting with the Matrix bot, I want to see permission prompts from Claude Code in the chat and be able to approve or deny them, so that headless Claude Code sessions don't silently hang when they need authorization to proceed.
## Acceptance Criteria
- [ ] When Claude Code hits a permission prompt during a bot-initiated session, the bot sends the prompt text to the Matrix room as a message
- [ ] The user can approve or deny the permission by replying in chat (e.g. yes/no or a reaction)
- [ ] The bot relays the user decision back to the Claude Code subprocess so execution continues
- [ ] If the user does not respond within a configurable timeout, the permission is denied (fail-closed)
- [ ] The bot does not hang or timeout silently when a permission prompt is pending - the user always sees what is happening
## Out of Scope
- TBD

37
Cargo.lock generated
View File

@@ -1952,6 +1952,7 @@ version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
@@ -3996,7 +3997,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "story-kit"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-stream",
"async-trait",
@@ -4007,6 +4008,7 @@ dependencies = [
"futures",
"homedir",
"ignore",
"libsqlite3-sys",
"matrix-sdk",
"mime_guess",
"mockito",
@@ -4023,9 +4025,10 @@ dependencies = [
"strip-ansi-escapes",
"tempfile",
"tokio",
"tokio-tungstenite 0.28.0",
"toml 1.0.3+spec-1.1.0",
"tokio-tungstenite 0.29.0",
"toml 1.0.6+spec-1.1.0",
"uuid",
"wait-timeout",
"walkdir",
]
@@ -4330,14 +4333,14 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.28.0",
"tungstenite 0.29.0",
]
[[package]]
@@ -4369,9 +4372,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.3+spec-1.1.0"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap",
"serde_core",
@@ -4559,9 +4562,9 @@ dependencies = [
[[package]]
name = "tungstenite"
version = "0.28.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes 1.11.1",
"data-encoding",
@@ -4571,7 +4574,6 @@ dependencies = [
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
@@ -4702,9 +4704,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.21.0"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.1",
"js-sys",
@@ -4769,6 +4771,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"

View File

@@ -24,9 +24,9 @@ serde_yaml = "0.9"
strip-ansi-escapes = "0.2"
tempfile = "3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
toml = "1.0.3"
uuid = { version = "1.21.0", features = ["v4", "serde"] }
tokio-tungstenite = "0.28.0"
toml = "1.0.6"
uuid = { version = "1.22.0", features = ["v4", "serde"] }
tokio-tungstenite = "0.29.0"
walkdir = "2.5.0"
filetime = "0.2"
matrix-sdk = { version = "0.16.0", default-features = false, features = [
@@ -37,6 +37,3 @@ matrix-sdk = { version = "0.16.0", default-features = false, features = [
pulldown-cmark = { version = "0.13.1", default-features = false, features = [
"html",
] }
# Force bundled SQLite so static musl builds don't need a system libsqlite3
libsqlite3-sys = { version = "*", features = ["bundled"] }

View File

@@ -8,7 +8,7 @@ help:
@echo " make release V=x.y.z Build both targets and publish a Gitea release"
@echo ""
@echo "Prerequisites:"
@echo " build-macos: Rust stable toolchain, pnpm"
@echo " build-macos: Rust stable toolchain, npm"
@echo " build-linux: cargo install cross AND Docker Desktop running"
@echo ""
@echo "Output:"
@@ -16,7 +16,7 @@ help:
@echo " Linux : target/x86_64-unknown-linux-musl/release/story-kit"
## Build a native macOS release binary.
## The frontend is compiled by build.rs (pnpm build) and embedded via rust-embed.
## The frontend is compiled by build.rs (npm run build) and embedded via rust-embed.
## Verify dynamic deps afterwards: otool -L target/release/story-kit
build-macos:
cargo build --release

View File

@@ -10,10 +10,10 @@ You can also run the frontend and backend separately in development (Vite dev se
```bash
# Build the frontend
cd frontend
pnpm install
pnpm dev
npm install
npm run dev
# Run the server (serves embedded frontend/dist/)
# In another terminal - run the server (serves embedded frontend/dist/)
cargo run
```
@@ -35,7 +35,7 @@ Story Kit ships as a **single self-contained binary** with the React frontend em
### macOS
```bash
# Native build no extra tools required beyond Rust + pnpm
# Native build no extra tools required beyond Rust + npm
make build-macos
# Output: target/release/story-kit
@@ -109,10 +109,10 @@ The frontend uses **Vitest** for unit tests and **Playwright** for end-to-end te
cd frontend
# Run unit tests
pnpm test
npm test
# Run end-to-end tests
pnpm test:e2e
npm run test:e2e
```
### Backend Tests

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
"@biomejs/biome": "^2.4.2",
"@playwright/test": "^1.47.2",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/node": "^25.0.0",
"@types/react": "^19.1.8",

5673
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -108,6 +108,14 @@ export const agentsApi = {
baseUrl,
);
},
getAgentOutput(storyId: string, agentName: string, baseUrl?: string) {
return requestJson<{ output: string }>(
`/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/output`,
{},
baseUrl,
);
},
};
/**

View File

@@ -13,7 +13,13 @@ export type WsRequest =
approved: boolean;
always_allow: boolean;
}
| { type: "ping" };
| { type: "ping" }
| {
type: "side_question";
question: string;
context_messages: Message[];
config: ProviderConfig;
};
export interface AgentAssignment {
agent_name: string;
@@ -73,7 +79,11 @@ export type WsResponse =
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
| { type: "onboarding_status"; needs_onboarding: boolean }
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
| { type: "thinking_token"; content: string };
| { type: "thinking_token"; content: string }
/** Streaming token from a /btw side question response. */
| { type: "side_question_token"; content: string }
/** Final signal that the /btw side question has been fully answered. */
| { type: "side_question_done"; response: string };
export interface ProviderConfig {
provider: string;
@@ -105,6 +115,7 @@ export interface WorkItemContent {
content: string;
stage: string;
name: string | null;
agent: string | null;
}
export interface TestCaseResult {
@@ -267,6 +278,9 @@ export const api = {
getHomeDirectory(baseUrl?: string) {
return requestJson<string>("/io/fs/home", {}, baseUrl);
},
listProjectFiles(baseUrl?: string) {
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
},
searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>(
"/fs/search",
@@ -324,6 +338,8 @@ export class ChatWebSocket {
private onAgentConfigChanged?: () => void;
private onAgentStateChanged?: () => void;
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
private onSideQuestionToken?: (content: string) => void;
private onSideQuestionDone?: (response: string) => void;
private connected = false;
private closeTimer?: number;
private wsPath = DEFAULT_WS_PATH;
@@ -405,6 +421,10 @@ export class ChatWebSocket {
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
if (data.type === "onboarding_status")
this.onOnboardingStatus?.(data.needs_onboarding);
if (data.type === "side_question_token")
this.onSideQuestionToken?.(data.content);
if (data.type === "side_question_done")
this.onSideQuestionDone?.(data.response);
if (data.type === "pong") {
window.clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = undefined;
@@ -458,6 +478,8 @@ export class ChatWebSocket {
onAgentConfigChanged?: () => void;
onAgentStateChanged?: () => void;
onOnboardingStatus?: (needsOnboarding: boolean) => void;
onSideQuestionToken?: (content: string) => void;
onSideQuestionDone?: (response: string) => void;
},
wsPath = DEFAULT_WS_PATH,
) {
@@ -473,6 +495,8 @@ export class ChatWebSocket {
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
this.onAgentStateChanged = handlers.onAgentStateChanged;
this.onOnboardingStatus = handlers.onOnboardingStatus;
this.onSideQuestionToken = handlers.onSideQuestionToken;
this.onSideQuestionDone = handlers.onSideQuestionDone;
this.wsPath = wsPath;
this.shouldReconnect = true;
@@ -498,6 +522,19 @@ export class ChatWebSocket {
this.send({ type: "chat", messages, config });
}
sendSideQuestion(
question: string,
contextMessages: Message[],
config: ProviderConfig,
) {
this.send({
type: "side_question",
question,
context_messages: contextMessages,
config,
});
}
cancel() {
this.send({ type: "cancel" });
}

View File

@@ -312,10 +312,11 @@ describe("Thinking traces hidden from agent stream UI", () => {
// AC1: no thinking block
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
// AC2+AC3: output area renders the text
// AC2+AC3: output area renders the text but NOT thinking text
const outputArea = screen.getByTestId("agent-output-coder-1");
expect(outputArea).toBeInTheDocument();
expect(outputArea.textContent).toContain("Here is the result.");
expect(outputArea.textContent).not.toContain("thinking deeply");
});
// AC3: output-only event stream (no thinking) still works

View File

@@ -175,6 +175,9 @@ export function AgentPanel({
terminalAt: current.terminalAt ?? Date.now(),
},
};
case "thinking":
// Thinking traces are internal model state — never display them.
return prev;
default:
return prev;
}

View File

@@ -26,6 +26,8 @@ type WsHandlers = {
) => void;
};
let capturedWsHandlers: WsHandlers | null = null;
// Captures the last sendChat call's arguments for assertion.
let lastSendChatArgs: { messages: Message[]; config: unknown } | null = null;
vi.mock("../api/client", () => {
const api = {
@@ -36,13 +38,17 @@ vi.mock("../api/client", () => {
setModelPreference: vi.fn(),
cancelChat: vi.fn(),
setAnthropicApiKey: vi.fn(),
readFile: vi.fn(),
listProjectFiles: vi.fn(),
};
class ChatWebSocket {
connect(handlers: WsHandlers) {
capturedWsHandlers = handlers;
}
close() {}
sendChat() {}
sendChat(messages: Message[], config: unknown) {
lastSendChatArgs = { messages, config };
}
cancel() {}
}
return { api, ChatWebSocket };
@@ -56,6 +62,8 @@ const mockedApi = {
setModelPreference: vi.mocked(api.setModelPreference),
cancelChat: vi.mocked(api.cancelChat),
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
readFile: vi.mocked(api.readFile),
listProjectFiles: vi.mocked(api.listProjectFiles),
};
function setupMocks() {
@@ -64,6 +72,8 @@ function setupMocks() {
mockedApi.getAnthropicModels.mockResolvedValue([]);
mockedApi.getModelPreference.mockResolvedValue(null);
mockedApi.setModelPreference.mockResolvedValue(true);
mockedApi.readFile.mockResolvedValue("");
mockedApi.listProjectFiles.mockResolvedValue([]);
mockedApi.cancelChat.mockResolvedValue(true);
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
}
@@ -529,6 +539,114 @@ describe("Chat localStorage persistence (Story 145)", () => {
confirmSpy.mockRestore();
});
it("Bug 245: messages survive unmount/remount cycle (page refresh)", async () => {
// Step 1: Render Chat and populate messages via WebSocket onUpdate
const { unmount } = render(
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const history: Message[] = [
{ role: "user", content: "Persist me across refresh" },
{ role: "assistant", content: "I should survive a reload" },
];
act(() => {
capturedWsHandlers?.onUpdate(history);
});
// Verify messages are persisted to localStorage
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull();
const storedBefore = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
expect(storedBefore).toEqual(history);
// Step 2: Unmount the Chat component (simulates page unload)
unmount();
// Verify localStorage was NOT cleared by unmount
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull();
const storedAfterUnmount = JSON.parse(
localStorage.getItem(STORAGE_KEY) ?? "[]",
);
expect(storedAfterUnmount).toEqual(history);
// Step 3: Remount the Chat component (simulates page reload)
capturedWsHandlers = null;
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
// Verify messages are restored from localStorage
expect(
await screen.findByText("Persist me across refresh"),
).toBeInTheDocument();
expect(
await screen.findByText("I should survive a reload"),
).toBeInTheDocument();
// Verify localStorage still has the messages
const storedAfterRemount = JSON.parse(
localStorage.getItem(STORAGE_KEY) ?? "[]",
);
expect(storedAfterRemount).toEqual(history);
});
it("Bug 245: after refresh, sendChat includes full prior history", async () => {
// Step 1: Render, populate messages via onUpdate, then unmount (simulate refresh)
const { unmount } = render(
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const priorHistory: Message[] = [
{ role: "user", content: "What is Rust?" },
{ role: "assistant", content: "Rust is a systems programming language." },
];
act(() => {
capturedWsHandlers?.onUpdate(priorHistory);
});
// Verify localStorage has the prior history
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
expect(stored).toEqual(priorHistory);
unmount();
// Step 2: Remount (simulates page reload) — messages load from localStorage
capturedWsHandlers = null;
lastSendChatArgs = null;
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
// Verify prior messages are displayed
expect(await screen.findByText("What is Rust?")).toBeInTheDocument();
// Step 3: Send a new message — sendChat should include the full prior history
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "Tell me more" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// Verify sendChat was called with ALL prior messages + the new one
expect(lastSendChatArgs).not.toBeNull();
const args = lastSendChatArgs as unknown as { messages: Message[]; config: unknown };
expect(args.messages).toHaveLength(3);
expect(args.messages[0]).toEqual({
role: "user",
content: "What is Rust?",
});
expect(args.messages[1]).toEqual({
role: "assistant",
content: "Rust is a systems programming language.",
});
expect(args.messages[2]).toEqual({
role: "user",
content: "Tell me more",
});
});
it("AC5: uses project-scoped storage key", async () => {
const otherKey = "storykit-chat-history:/other/project";
localStorage.setItem(
@@ -1164,3 +1282,168 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => {
expect(styleAttr).not.toContain("background: transparent");
});
});
describe("Bug 264: Claude Code session ID persisted across browser refresh", () => {
const PROJECT_PATH = "/tmp/project";
const SESSION_KEY = `storykit-claude-session-id:${PROJECT_PATH}`;
const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`;
beforeEach(() => {
capturedWsHandlers = null;
lastSendChatArgs = null;
localStorage.clear();
setupMocks();
});
afterEach(() => {
localStorage.clear();
});
it("AC1: session_id is persisted to localStorage when onSessionId fires", async () => {
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
act(() => {
capturedWsHandlers?.onSessionId("test-session-abc");
});
await waitFor(() => {
expect(localStorage.getItem(SESSION_KEY)).toBe("test-session-abc");
});
});
it("AC2: after remount, next sendChat includes session_id from localStorage", async () => {
// Step 1: Render, receive a session ID, then unmount (simulate refresh)
localStorage.setItem(SESSION_KEY, "persisted-session-xyz");
localStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{ role: "user", content: "Prior message" },
{ role: "assistant", content: "Prior reply" },
]),
);
const { unmount } = render(
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
unmount();
// Step 2: Remount (simulates page reload)
capturedWsHandlers = null;
lastSendChatArgs = null;
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
// Prior messages should be visible
expect(await screen.findByText("Prior message")).toBeInTheDocument();
// Step 3: Send a new message — config should include session_id
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "Continue" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(lastSendChatArgs).not.toBeNull();
expect(
((lastSendChatArgs as unknown as { messages: Message[]; config: unknown })?.config as Record<string, unknown>).session_id,
).toBe("persisted-session-xyz");
});
it("AC3: clearing the session also clears the persisted session_id", async () => {
localStorage.setItem(SESSION_KEY, "session-to-clear");
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const newSessionBtn = screen.getByText(/New Session/);
await act(async () => {
fireEvent.click(newSessionBtn);
});
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
confirmSpy.mockRestore();
});
it("AC1: storage key is scoped to project path", async () => {
const otherPath = "/other/project";
const otherKey = `storykit-claude-session-id:${otherPath}`;
localStorage.setItem(otherKey, "other-session");
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
act(() => {
capturedWsHandlers?.onSessionId("my-session");
});
await waitFor(() => {
expect(localStorage.getItem(SESSION_KEY)).toBe("my-session");
});
// Other project's session should be untouched
expect(localStorage.getItem(otherKey)).toBe("other-session");
});
});
describe("File reference expansion (Story 269 AC4)", () => {
beforeEach(() => {
vi.clearAllMocks();
capturedWsHandlers = null;
lastSendChatArgs = null;
setupMocks();
});
it("includes file contents as context when message contains @file reference", async () => {
mockedApi.readFile.mockResolvedValue('fn main() { println!("hello"); }');
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: "explain @src/main.rs" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
const sentMessages = (
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
).messages;
const userMsg = sentMessages[sentMessages.length - 1];
expect(userMsg.content).toContain("explain @src/main.rs");
expect(userMsg.content).toContain("[File: src/main.rs]");
expect(userMsg.content).toContain("fn main()");
});
it("sends message without modification when no @file references are present", 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: "hello world" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
const sentMessages = (
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
).messages;
const userMsg = sentMessages[sentMessages.length - 1];
expect(userMsg.content).toBe("hello world");
expect(mockedApi.readFile).not.toHaveBeenCalled();
});
});

View File

@@ -10,8 +10,10 @@ import { AgentPanel } from "./AgentPanel";
import { ChatHeader } from "./ChatHeader";
import type { ChatInputHandle } from "./ChatInput";
import { ChatInput } from "./ChatInput";
import { HelpOverlay } from "./HelpOverlay";
import { LozengeFlyProvider } from "./LozengeFlyContext";
import { MessageItem } from "./MessageItem";
import { SideQuestionOverlay } from "./SideQuestionOverlay";
import { StagePanel } from "./StagePanel";
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
@@ -169,7 +171,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
merge: [],
done: [],
});
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(() => {
try {
return (
localStorage.getItem(`storykit-claude-session-id:${projectPath}`) ??
null
);
} catch {
return null;
}
});
const [activityStatus, setActivityStatus] = useState<string | null>(null);
const [permissionQueue, setPermissionQueue] = useState<
{
@@ -197,6 +208,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [queuedMessages, setQueuedMessages] = useState<
{ id: string; text: string }[]
>([]);
const [sideQuestion, setSideQuestion] = useState<{
question: string;
response: string;
loading: boolean;
} | null>(null);
const [showHelp, setShowHelp] = useState(false);
// Ref so stale WebSocket callbacks can read the current queued messages
const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]);
const queueIdCounterRef = useRef(0);
@@ -239,6 +256,21 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
};
}, [messages, streamingContent, model]);
useEffect(() => {
try {
if (claudeSessionId !== null) {
localStorage.setItem(
`storykit-claude-session-id:${projectPath}`,
claudeSessionId,
);
} else {
localStorage.removeItem(`storykit-claude-session-id:${projectPath}`);
}
} catch {
// Ignore — quota or security errors.
}
}, [claudeSessionId, projectPath]);
useEffect(() => {
api
.getOllamaModels()
@@ -360,6 +392,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
onOnboardingStatus: (onboarding: boolean) => {
setNeedsOnboarding(onboarding);
},
onSideQuestionToken: (content) => {
setSideQuestion((prev) =>
prev ? { ...prev, response: prev.response + content } : prev,
);
},
onSideQuestionDone: (response) => {
setSideQuestion((prev) =>
prev ? { ...prev, response, loading: false } : prev,
);
},
});
return () => {
@@ -372,7 +414,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const element = scrollContainerRef.current;
if (element) {
element.scrollTop = element.scrollHeight;
lastScrollTopRef.current = element.scrollHeight;
// Read scrollTop back after assignment: the browser caps it at
// (scrollHeight - clientHeight), so storing scrollHeight would
// make handleScroll incorrectly interpret the next scroll event
// as an upward scroll and disable auto-scrolling.
lastScrollTopRef.current = element.scrollTop;
}
}, []);
@@ -459,6 +505,34 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const sendMessage = async (messageText: string) => {
if (!messageText.trim()) return;
// /help — show available slash commands overlay
if (/^\/help\s*$/i.test(messageText)) {
setShowHelp(true);
return;
}
// /btw <question> — answered from context without disrupting main chat
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
if (btwMatch) {
const question = btwMatch[1].trim();
setSideQuestion({ question, response: "", loading: true });
const isClaudeCode = model === "claude-code-pty";
const provider = isClaudeCode
? "claude-code"
: model.startsWith("claude-")
? "anthropic"
: "ollama";
const config: ProviderConfig = {
provider,
model,
base_url: "http://localhost:11434",
enable_tools: false,
};
wsRef.current?.sendSideQuestion(question, messages, config);
return;
}
// Agent is busy — queue the message instead of dropping it
if (loading) {
const newItem = {
@@ -480,7 +554,26 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}
}
const userMsg: Message = { role: "user", content: messageText };
// Expand @file references: append file contents as context
const fileRefs = [...messageText.matchAll(/(^|[\s\n])@([^\s@]+)/g)].map(
(m) => m[2],
);
let expandedText = messageText;
if (fileRefs.length > 0) {
const expansions = await Promise.allSettled(
fileRefs.map(async (ref) => {
const contents = await api.readFile(ref);
return { ref, contents };
}),
);
for (const result of expansions) {
if (result.status === "fulfilled") {
expandedText += `\n\n[File: ${result.value.ref}]\n\`\`\`\n${result.value.contents}\n\`\`\``;
}
}
}
const userMsg: Message = { role: "user", content: expandedText };
const newHistory = [...messages, userMsg];
setMessages(newHistory);
@@ -614,6 +707,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
setLoading(false);
setActivityStatus(null);
setClaudeSessionId(null);
try {
localStorage.removeItem(`storykit-claude-session-id:${projectPath}`);
} catch {
// Ignore — quota or security errors.
}
}
};
@@ -1154,6 +1252,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
</div>
</div>
)}
{showHelp && <HelpOverlay onDismiss={() => setShowHelp(false)} />}
{sideQuestion && (
<SideQuestionOverlay
question={sideQuestion.question}
response={sideQuestion.response}
loading={sideQuestion.loading}
onDismiss={() => setSideQuestion(null)}
/>
)}
</div>
);
}

View File

@@ -136,9 +136,9 @@ describe("ChatHeader", () => {
expect(screen.getByText("Built: 2026-01-01 00:00")).toBeInTheDocument();
});
it("displays StorkIt branding in the header", () => {
it("displays Story Kit branding in the header", () => {
render(<ChatHeader {...makeProps()} />);
expect(screen.getByText("StorkIt")).toBeInTheDocument();
expect(screen.getByText("Story Kit")).toBeInTheDocument();
});
it("labels the claude-pty optgroup as 'Claude Code'", () => {

View File

@@ -82,7 +82,7 @@ export function ChatHeader({
letterSpacing: "0.02em",
}}
>
StorkIt
Story Kit
</span>
<div
title={projectPath}

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { api } from "../api/client";
const { forwardRef, useEffect, useImperativeHandle, useRef, useState } = React;
const { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } = React;
export interface ChatInputHandle {
appendToInput(text: string): void;
@@ -14,6 +15,97 @@ interface ChatInputProps {
onRemoveQueuedMessage: (id: string) => void;
}
/** Fuzzy-match: returns true if all chars of `query` appear in order in `str`. */
function fuzzyMatch(str: string, query: string): boolean {
if (!query) return true;
const lower = str.toLowerCase();
const q = query.toLowerCase();
let qi = 0;
for (let i = 0; i < lower.length && qi < q.length; i++) {
if (lower[i] === q[qi]) qi++;
}
return qi === q.length;
}
/** Score a fuzzy match: lower is better. Exact prefix match wins, then shorter paths. */
function fuzzyScore(str: string, query: string): number {
const lower = str.toLowerCase();
const q = query.toLowerCase();
// Prefer matches where query appears as a contiguous substring
if (lower.includes(q)) return lower.indexOf(q);
return str.length;
}
interface FilePickerOverlayProps {
query: string;
files: string[];
selectedIndex: number;
onSelect: (file: string) => void;
onDismiss: () => void;
anchorRef: React.RefObject<HTMLTextAreaElement | null>;
}
function FilePickerOverlay({
query,
files,
selectedIndex,
onSelect,
}: FilePickerOverlayProps) {
const filtered = files
.filter((f) => fuzzyMatch(f, query))
.sort((a, b) => fuzzyScore(a, query) - fuzzyScore(b, query))
.slice(0, 10);
if (filtered.length === 0) return null;
return (
<div
data-testid="file-picker-overlay"
style={{
position: "absolute",
bottom: "100%",
left: 0,
right: 0,
background: "#1e1e1e",
border: "1px solid #444",
borderRadius: "8px",
marginBottom: "6px",
overflow: "hidden",
zIndex: 100,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
maxHeight: "240px",
overflowY: "auto",
}}
>
{filtered.map((file, idx) => (
<button
key={file}
type="button"
data-testid={`file-picker-item-${idx}`}
onClick={() => onSelect(file)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "8px 14px",
background: idx === selectedIndex ? "#2d4a6e" : "transparent",
border: "none",
color: idx === selectedIndex ? "#ececec" : "#aaa",
cursor: "pointer",
fontFamily: "monospace",
fontSize: "0.85rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{file}
</button>
))}
</div>
);
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput(
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
@@ -22,6 +114,12 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
const [input, setInput] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
// File picker state
const [projectFiles, setProjectFiles] = useState<string[]>([]);
const [pickerQuery, setPickerQuery] = useState<string | null>(null);
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
const [pickerAtStart, setPickerAtStart] = useState(0);
useImperativeHandle(ref, () => ({
appendToInput(text: string) {
setInput((prev) => (prev ? `${prev}\n${text}` : text));
@@ -32,10 +130,104 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
inputRef.current?.focus();
}, []);
// Compute filtered files for current picker query
const filteredFiles = pickerQuery !== null
? projectFiles
.filter((f) => fuzzyMatch(f, pickerQuery))
.sort((a, b) => fuzzyScore(a, pickerQuery) - fuzzyScore(b, pickerQuery))
.slice(0, 10)
: [];
const dismissPicker = useCallback(() => {
setPickerQuery(null);
setPickerSelectedIndex(0);
}, []);
const selectFile = useCallback(
(file: string) => {
// Replace the @query portion with @file
const before = input.slice(0, pickerAtStart);
const cursorPos = inputRef.current?.selectionStart ?? input.length;
const after = input.slice(cursorPos);
setInput(`${before}@${file}${after}`);
dismissPicker();
// Restore focus after state update
setTimeout(() => inputRef.current?.focus(), 0);
},
[input, pickerAtStart, dismissPicker],
);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setInput(val);
const cursor = e.target.selectionStart ?? val.length;
// Find the last @ before the cursor that starts a reference token
const textUpToCursor = val.slice(0, cursor);
// Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline)
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
if (atMatch) {
const query = atMatch[2];
const atPos = textUpToCursor.lastIndexOf("@");
setPickerAtStart(atPos);
setPickerQuery(query);
setPickerSelectedIndex(0);
// Lazily load files on first trigger
if (projectFiles.length === 0) {
api.listProjectFiles().then(setProjectFiles).catch(() => {});
}
} else {
if (pickerQuery !== null) dismissPicker();
}
},
[projectFiles.length, pickerQuery, dismissPicker],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (pickerQuery !== null && filteredFiles.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPickerSelectedIndex((i) => Math.min(i + 1, filteredFiles.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setPickerSelectedIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
selectFile(filteredFiles[pickerSelectedIndex] ?? filteredFiles[0]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
dismissPicker();
return;
}
} else if (e.key === "Escape" && pickerQuery !== null) {
e.preventDefault();
dismissPicker();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[pickerQuery, filteredFiles, pickerSelectedIndex, selectFile, dismissPicker],
);
const handleSubmit = () => {
if (!input.trim()) return;
onSubmit(input);
setInput("");
dismissPicker();
};
return (
@@ -135,24 +327,30 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
</button>
</div>
))}
{/* Input row */}
{/* Input row with file picker overlay */}
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
position: "relative",
}}
>
{pickerQuery !== null && (
<FilePickerOverlay
query={pickerQuery}
files={projectFiles}
selectedIndex={pickerSelectedIndex}
onSelect={selectFile}
onDismiss={dismissPicker}
anchorRef={inputRef}
/>
)}
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Send a message..."
rows={1}
style={{

View File

@@ -0,0 +1,194 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "../api/client";
import { ChatInput } from "./ChatInput";
vi.mock("../api/client", () => ({
api: {
listProjectFiles: vi.fn(),
},
}));
const mockedListProjectFiles = vi.mocked(api.listProjectFiles);
const defaultProps = {
loading: false,
queuedMessages: [],
onSubmit: vi.fn(),
onCancel: vi.fn(),
onRemoveQueuedMessage: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockedListProjectFiles.mockResolvedValue([
"src/main.rs",
"src/lib.rs",
"frontend/index.html",
"README.md",
]);
});
describe("File picker overlay (Story 269 AC1)", () => {
it("shows file picker overlay when @ is typed", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
});
it("does not show file picker overlay for text without @", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "hello world" } });
});
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
});
});
describe("File picker fuzzy matching (Story 269 AC2)", () => {
it("filters files by query typed after @", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@main" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
// main.rs should be visible, README.md should not
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
expect(screen.queryByText("README.md")).not.toBeInTheDocument();
});
it("shows all files when @ is typed with no query", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
// All 4 files should be visible
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
expect(screen.getByText("src/lib.rs")).toBeInTheDocument();
expect(screen.getByText("README.md")).toBeInTheDocument();
});
});
describe("File picker selection (Story 269 AC3)", () => {
it("clicking a file inserts @path into the message", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-item-0")).toBeInTheDocument();
});
await act(async () => {
fireEvent.click(screen.getByTestId("file-picker-item-0"));
});
// Picker should be dismissed and the file reference inserted
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
expect((textarea as HTMLTextAreaElement).value).toMatch(/^@\S+/);
});
it("Enter key selects highlighted file and inserts it into message", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@main" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter" });
});
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
expect((textarea as HTMLTextAreaElement).value).toContain("@src/main.rs");
});
});
describe("File picker dismiss (Story 269 AC5)", () => {
it("Escape key dismisses the file picker", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Escape" });
});
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
});
});
describe("Multiple @ references (Story 269 AC6)", () => {
it("typing @ after a completed reference triggers picker again", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
// First reference
await act(async () => {
fireEvent.change(textarea, { target: { value: "@main" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
// Select file
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter" });
});
// Type a second @
await act(async () => {
const current = (textarea as HTMLTextAreaElement).value;
fireEvent.change(textarea, { target: { value: `${current} @` } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,158 @@
import * as React from "react";
const { useEffect, useRef } = React;
interface SlashCommand {
name: string;
description: string;
}
const SLASH_COMMANDS: SlashCommand[] = [
{
name: "/help",
description: "Show this list of available slash commands.",
},
{
name: "/btw <question>",
description:
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
},
];
interface HelpOverlayProps {
onDismiss: () => void;
}
/**
* Dismissible overlay that lists all available slash commands.
* Dismiss with Escape, Enter, or Space.
*/
export function HelpOverlay({ onDismiss }: HelpOverlayProps) {
const dismissRef = useRef(onDismiss);
dismissRef.current = onDismiss;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
dismissRef.current();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener
<div
data-testid="help-overlay"
onClick={onDismiss}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */}
<div
data-testid="help-panel"
onClick={(e) => e.stopPropagation()}
style={{
background: "#2f2f2f",
border: "1px solid #444",
borderRadius: "12px",
padding: "24px",
maxWidth: "560px",
width: "90vw",
display: "flex",
flexDirection: "column",
gap: "16px",
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span
style={{
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "#a0d4a0",
}}
>
Slash Commands
</span>
<button
type="button"
onClick={onDismiss}
title="Dismiss (Escape, Enter, or Space)"
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
fontSize: "1.1rem",
padding: "2px 6px",
borderRadius: "4px",
}}
>
</button>
</div>
{/* Command list */}
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{SLASH_COMMANDS.map((cmd) => (
<div
key={cmd.name}
style={{ display: "flex", flexDirection: "column", gap: "2px" }}
>
<code
style={{
fontSize: "0.88rem",
color: "#e0e0e0",
fontFamily: "monospace",
}}
>
{cmd.name}
</code>
<span
style={{
fontSize: "0.85rem",
color: "#999",
lineHeight: "1.5",
}}
>
{cmd.description}
</span>
</div>
))}
</div>
{/* Footer hint */}
<div
style={{
fontSize: "0.75rem",
color: "#555",
textAlign: "center",
}}
>
Press Escape, Enter, or Space to dismiss
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import * as React from "react";
import Markdown from "react-markdown";
const { useEffect, useRef } = React;
interface SideQuestionOverlayProps {
question: string;
/** Streaming response text. Empty while loading. */
response: string;
loading: boolean;
onDismiss: () => void;
}
/**
* Dismissible overlay that shows a /btw side question and its streamed response.
* The question and response are NOT part of the main conversation history.
* Dismiss with Escape, Enter, or Space.
*/
export function SideQuestionOverlay({
question,
response,
loading,
onDismiss,
}: SideQuestionOverlayProps) {
const dismissRef = useRef(onDismiss);
dismissRef.current = onDismiss;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
dismissRef.current();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener
<div
data-testid="side-question-overlay"
onClick={onDismiss}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */}
<div
data-testid="side-question-panel"
onClick={(e) => e.stopPropagation()}
style={{
background: "#2f2f2f",
border: "1px solid #444",
borderRadius: "12px",
padding: "24px",
maxWidth: "640px",
width: "90vw",
maxHeight: "60vh",
display: "flex",
flexDirection: "column",
gap: "16px",
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: "12px",
}}
>
<div>
<span
style={{
display: "block",
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "#a0d4a0",
marginBottom: "4px",
}}
>
/btw
</span>
<span
style={{
fontSize: "1rem",
color: "#ececec",
fontWeight: 500,
}}
>
{question}
</span>
</div>
<button
type="button"
onClick={onDismiss}
title="Dismiss (Escape, Enter, or Space)"
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
fontSize: "1.1rem",
padding: "2px 6px",
borderRadius: "4px",
flexShrink: 0,
}}
>
</button>
</div>
{/* Response area */}
<div
style={{
overflowY: "auto",
flex: 1,
color: "#ccc",
fontSize: "0.95rem",
lineHeight: "1.6",
}}
>
{loading && !response && (
<span style={{ color: "#666", fontStyle: "italic" }}>
Thinking
</span>
)}
{response && <Markdown>{response}</Markdown>}
</div>
{/* Footer hint */}
{!loading && (
<div
style={{
fontSize: "0.75rem",
color: "#555",
textAlign: "center",
}}
>
Press Escape, Enter, or Space to dismiss
</div>
)}
</div>
</div>
);
}

View File

@@ -4,12 +4,13 @@ import { useLozengeFly } from "./LozengeFlyContext";
const { useLayoutEffect, useRef } = React;
type WorkItemType = "story" | "bug" | "spike" | "unknown";
type WorkItemType = "story" | "bug" | "spike" | "refactor" | "unknown";
const TYPE_COLORS: Record<WorkItemType, string> = {
story: "#3fb950",
bug: "#f85149",
spike: "#58a6ff",
refactor: "#a371f7",
unknown: "#444",
};
@@ -17,6 +18,7 @@ const TYPE_LABELS: Record<WorkItemType, string | null> = {
story: "STORY",
bug: "BUG",
spike: "SPIKE",
refactor: "REFACTOR",
unknown: null,
};
@@ -24,7 +26,12 @@ function getWorkItemType(storyId: string): WorkItemType {
const match = storyId.match(/^\d+_([a-z]+)_/);
if (!match) return "unknown";
const segment = match[1];
if (segment === "story" || segment === "bug" || segment === "spike") {
if (
segment === "story" ||
segment === "bug" ||
segment === "spike" ||
segment === "refactor"
) {
return segment;
}
return "unknown";

View File

@@ -25,6 +25,7 @@ vi.mock("../api/agents", () => ({
import { agentsApi, subscribeAgentStream } from "../api/agents";
import { api } from "../api/client";
const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
@@ -36,6 +37,7 @@ const DEFAULT_CONTENT = {
content: "# Big Title\n\nSome content here.",
stage: "current",
name: "Big Title Story",
agent: null,
};
const sampleTestResults: TestResultsResponse = {
@@ -435,6 +437,60 @@ describe("WorkItemDetailPanel - Agent Logs", () => {
});
});
describe("WorkItemDetailPanel - Assigned Agent", () => {
it("shows assigned agent name when agent front matter field is set", async () => {
mockedGetWorkItemContent.mockResolvedValue({
...DEFAULT_CONTENT,
agent: "coder-opus",
});
render(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
expect(agentEl).toHaveTextContent("coder-opus");
});
it("omits assigned agent field when no agent is set in front matter", async () => {
render(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await screen.findByTestId("detail-panel-content");
expect(
screen.queryByTestId("detail-panel-assigned-agent"),
).not.toBeInTheDocument();
});
it("shows the specific agent name not just 'assigned'", async () => {
mockedGetWorkItemContent.mockResolvedValue({
...DEFAULT_CONTENT,
agent: "coder-haiku",
});
render(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
expect(agentEl).toHaveTextContent("coder-haiku");
expect(agentEl).not.toHaveTextContent("assigned");
});
});
describe("WorkItemDetailPanel - Test Results", () => {
it("shows empty test results message when no results exist", async () => {
mockedGetTestResults.mockResolvedValue(null);

View File

@@ -113,6 +113,7 @@ export function WorkItemDetailPanel({
const [content, setContent] = useState<string | null>(null);
const [stage, setStage] = useState<string>("");
const [name, setName] = useState<string | null>(null);
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
@@ -133,6 +134,7 @@ export function WorkItemDetailPanel({
setContent(data.content);
setStage(data.stage);
setName(data.name);
setAssignedAgent(data.agent);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to load content");
@@ -278,6 +280,14 @@ export function WorkItemDetailPanel({
{stageLabel}
</div>
)}
{assignedAgent ? (
<div
data-testid="detail-panel-assigned-agent"
style={{ fontSize: "0.75em", color: "#888" }}
>
Agent: {assignedAgent}
</div>
) : null}
</div>
<button
type="button"
@@ -482,9 +492,10 @@ export function WorkItemDetailPanel({
{/* Placeholder sections for future content */}
{(
[
{ id: "coverage", label: "Coverage" },
] as { id: string; label: string }[]
[{ id: "coverage", label: "Coverage" }] as {
id: string;
label: string;
}[]
).map(({ id, label }) => (
<div
key={id}

View File

@@ -16,8 +16,25 @@ export default defineConfig(() => {
"/api": {
target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000,
configure: (proxy) => {
proxy.on("error", (_err) => {
// Swallow proxy errors (e.g. ECONNREFUSED during backend restart)
// so the vite dev server doesn't crash.
});
},
},
},
watch: {
ignored: [
"**/.story_kit/**",
"**/target/**",
"**/.git/**",
"**/server/**",
"**/Cargo.*",
"**/vendor/**",
"**/node_modules/**",
],
},
},
build: {
outDir: "dist",

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "story-kit",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -71,20 +71,93 @@ ls -lh "${DIST}"/
echo "==> Generating changelog..."
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
CHANGELOG=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s" --no-merges)
LOG_RANGE="${PREV_TAG}..HEAD"
RANGE="${PREV_TAG}...${TAG}"
else
CHANGELOG=$(git log --pretty=format:"- %s" --no-merges)
LOG_RANGE=""
RANGE="initial...${TAG}"
fi
if [ -z "$CHANGELOG" ]; then
CHANGELOG="- No changes since last release"
# Extract completed stories/bugs/refactors from "story-kit: merge <id>" commits.
# Deduplicate (a story may have been merged more than once after reverts).
if [ -n "$LOG_RANGE" ]; then
MERGED_RAW=$(git log "$LOG_RANGE" --pretty=format:"%s" --no-merges \
| grep "^story-kit: merge " | sed 's/^story-kit: merge //' | sort -u)
else
MERGED_RAW=$(git log --pretty=format:"%s" --no-merges \
| grep "^story-kit: merge " | sed 's/^story-kit: merge //' | sort -u)
fi
RELEASE_BODY="## What's Changed
# Categorise merged work items and format names.
FEATURES=""
FIXES=""
REFACTORS=""
while IFS= read -r item; do
[ -z "$item" ] && continue
# Strip the numeric prefix and type to get the human name.
name=$(echo "$item" | sed -E 's/^[0-9]+_(story|bug|refactor|spike)_//' | tr '_' ' ')
# Capitalise first letter.
name="$(echo "${name:0:1}" | tr '[:lower:]' '[:upper:]')${name:1}"
case "$item" in
*_bug_*) FIXES="${FIXES}- ${name}\n" ;;
*_refactor_*) REFACTORS="${REFACTORS}- ${name}\n" ;;
*) FEATURES="${FEATURES}- ${name}\n" ;;
esac
done <<< "$MERGED_RAW"
${CHANGELOG}
# Collect non-automation manual commits (direct fixes, version bumps, etc).
if [ -n "$LOG_RANGE" ]; then
MANUAL=$(git log "$LOG_RANGE" --pretty=format:"%s" --no-merges \
| grep -v "^story-kit: " \
| grep -v "^Revert \"story-kit: " \
| grep -v "^Bump version" \
| sed 's/^/- /')
else
MANUAL=$(git log --pretty=format:"%s" --no-merges \
| grep -v "^story-kit: " \
| grep -v "^Revert \"story-kit: " \
| grep -v "^Bump version" \
| sed 's/^/- /')
fi
# Assemble the release body.
RELEASE_BODY="## What's Changed"
if [ -n "$FEATURES" ]; then
RELEASE_BODY="${RELEASE_BODY}
### Features
$(echo -e "$FEATURES")"
fi
if [ -n "$FIXES" ]; then
RELEASE_BODY="${RELEASE_BODY}
### Bug Fixes
$(echo -e "$FIXES")"
fi
if [ -n "$REFACTORS" ]; then
RELEASE_BODY="${RELEASE_BODY}
### Refactors
$(echo -e "$REFACTORS")"
fi
if [ -n "$MANUAL" ]; then
RELEASE_BODY="${RELEASE_BODY}
### Other Changes
${MANUAL}"
fi
if [ -z "$FEATURES" ] && [ -z "$FIXES" ] && [ -z "$REFACTORS" ] && [ -z "$MANUAL" ]; then
RELEASE_BODY="${RELEASE_BODY}
- No changes since last release"
fi
RELEASE_BODY="${RELEASE_BODY}
**Full diff:** ${GITEA_URL}/${REPO}/compare/${RANGE}"

View File

@@ -8,8 +8,16 @@ echo "=== Running Rust tests ==="
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml"
echo "=== Running frontend unit tests ==="
cd "$PROJECT_ROOT/frontend"
pnpm test
if [ -d "$PROJECT_ROOT/frontend" ]; then
cd "$PROJECT_ROOT/frontend"
npm test
else
echo "Skipping frontend tests (no frontend directory)"
fi
echo "=== Running e2e tests ==="
pnpm test:e2e
# Disabled: e2e tests may be causing merge pipeline hangs (no running server
# in merge workspace → Playwright blocks indefinitely). Re-enable once confirmed.
# Disabled: e2e tests cause merge pipeline hangs (no running server
# in merge workspace → Playwright blocks indefinitely).
# echo "=== Running e2e tests ==="
# npm run test:e2e

View File

@@ -64,7 +64,7 @@ echo "=== Running frontend tests with coverage ==="
FRONTEND_DIR="$PROJECT_ROOT/frontend"
FRONTEND_LINE_COV=0
if [ -d "$FRONTEND_DIR" ]; then
FRONTEND_REPORT=$(cd "$FRONTEND_DIR" && pnpm run test:coverage 2>&1) || true
FRONTEND_REPORT=$(cd "$FRONTEND_DIR" && npm run test:coverage 2>&1) || true
echo "$FRONTEND_REPORT"
# Parse "All files" line from vitest coverage text table.

1
serve Submodule

Submodule serve added at 1ec5c08ae7

View File

@@ -1,6 +1,6 @@
[package]
name = "story-kit"
version = "0.1.0"
version = "0.3.0"
edition = "2024"
build = "build.rs"
@@ -31,6 +31,10 @@ walkdir = { workspace = true }
matrix-sdk = { workspace = true }
pulldown-cmark = { workspace = true }
# Force bundled SQLite so static musl builds don't need a system libsqlite3
libsqlite3-sys = { version = "0.35.0", features = ["bundled"] }
wait-timeout = "0.2.1"
[dev-dependencies]
tempfile = { workspace = true }
tokio-tungstenite = { workspace = true }

View File

@@ -18,7 +18,7 @@ fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=PROFILE");
println!("cargo:rerun-if-changed=../frontend/package.json");
println!("cargo:rerun-if-changed=../frontend/pnpm-lock.yaml");
println!("cargo:rerun-if-changed=../frontend/package-lock.json");
println!("cargo:rerun-if-changed=../frontend/vite.config.ts");
println!("cargo:rerun-if-changed=../frontend/index.html");
println!("cargo:rerun-if-changed=../frontend/src");
@@ -30,7 +30,7 @@ fn main() {
}
// When cross-compiling (e.g. musl via `cross`), the Docker container
// has no Node/pnpm. The release script builds macOS first, so
// has no Node/npm. The release script builds macOS first, so
// frontend/dist/ already exists. Skip the frontend build in that case.
let target = env::var("TARGET").unwrap_or_default();
let host = env::var("HOST").unwrap_or_default();
@@ -45,6 +45,6 @@ fn main() {
let frontend_dir = Path::new("../frontend");
// Ensure dependencies are installed and build the frontend bundle.
run("pnpm", &["install"], frontend_dir);
run("pnpm", &["build"], frontend_dir);
run("npm", &["install"], frontend_dir);
run("npm", &["run", "build"], frontend_dir);
}

430
server/src/agents/gates.rs Normal file
View File

@@ -0,0 +1,430 @@
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use wait_timeout::ChildExt;
/// Maximum time any single test command is allowed to run before being killed.
const TEST_TIMEOUT: Duration = Duration::from_secs(600); // 10 minutes
/// Detect whether the base branch in a worktree is `master` or `main`.
/// Falls back to `"master"` if neither is found.
pub(crate) fn detect_worktree_base_branch(wt_path: &Path) -> String {
for branch in &["master", "main"] {
let ok = Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(wt_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
return branch.to_string();
}
}
"master".to_string()
}
/// Return `true` if the git worktree at `wt_path` has commits on its current
/// branch that are not present on the base branch (`master` or `main`).
///
/// Used during server startup reconciliation to detect stories whose agent work
/// was committed while the server was offline.
pub(crate) fn worktree_has_committed_work(wt_path: &Path) -> bool {
let base_branch = detect_worktree_base_branch(wt_path);
let output = Command::new("git")
.args(["log", &format!("{base_branch}..HEAD"), "--oneline"])
.current_dir(wt_path)
.output();
match output {
Ok(out) if out.status.success() => {
!String::from_utf8_lossy(&out.stdout).trim().is_empty()
}
_ => false,
}
}
/// Check whether the given directory has any uncommitted git changes.
/// Returns `Err` with a descriptive message if there are any.
pub(crate) fn check_uncommitted_changes(path: &Path) -> Result<(), String> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run git status: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
return Err(format!(
"Worktree has uncommitted changes. Please commit all work before \
the agent exits:\n{stdout}"
));
}
Ok(())
}
/// Run the project's test suite.
///
/// Uses `script/test` if present, treating it as the canonical single test entry point.
/// Falls back to `cargo nextest run` / `cargo test` when `script/test` is absent.
/// Returns `(tests_passed, output)`.
pub(crate) fn run_project_tests(path: &Path) -> Result<(bool, String), String> {
let script_test = path.join("script").join("test");
if script_test.exists() {
let mut output = String::from("=== script/test ===\n");
let (success, out) = run_command_with_timeout(&script_test, &[], path)?;
output.push_str(&out);
output.push('\n');
return Ok((success, output));
}
// Fallback: cargo nextest run / cargo test
let mut output = String::from("=== tests ===\n");
let (success, test_out) = match run_command_with_timeout("cargo", &["nextest", "run"], path) {
Ok(result) => result,
Err(_) => {
// nextest not available — fall back to cargo test
run_command_with_timeout("cargo", &["test"], path)
.map_err(|e| format!("Failed to run cargo test: {e}"))?
}
};
output.push_str(&test_out);
output.push('\n');
Ok((success, output))
}
/// Run a command with a timeout. Returns `(success, combined_output)`.
/// Kills the child process if it exceeds `TEST_TIMEOUT`.
///
/// Stdout and stderr are drained in background threads to avoid a pipe-buffer
/// deadlock: if the child fills the 64 KB OS pipe buffer while the parent
/// blocks on `waitpid`, neither side can make progress.
fn run_command_with_timeout(
program: impl AsRef<std::ffi::OsStr>,
args: &[&str],
dir: &Path,
) -> Result<(bool, String), String> {
let mut child = Command::new(program)
.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}"))?;
// Drain stdout/stderr in background threads so the pipe buffers never fill.
let stdout_handle = child.stdout.take().map(|r| {
std::thread::spawn(move || {
let mut s = String::new();
let mut r = r;
std::io::Read::read_to_string(&mut r, &mut s).ok();
s
})
});
let stderr_handle = child.stderr.take().map(|r| {
std::thread::spawn(move || {
let mut s = String::new();
let mut r = r;
std::io::Read::read_to_string(&mut r, &mut s).ok();
s
})
});
match child.wait_timeout(TEST_TIMEOUT) {
Ok(Some(status)) => {
let stdout = stdout_handle
.and_then(|h| h.join().ok())
.unwrap_or_default();
let stderr = stderr_handle
.and_then(|h| h.join().ok())
.unwrap_or_default();
Ok((status.success(), format!("{stdout}{stderr}")))
}
Ok(None) => {
// Timed out — kill the child.
let _ = child.kill();
let _ = child.wait();
Err(format!(
"Command timed out after {} seconds",
TEST_TIMEOUT.as_secs()
))
}
Err(e) => Err(format!("Failed to wait for command: {e}")),
}
}
/// Run `cargo clippy` and the project test suite (via `script/test` if present,
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
/// Returns `(gates_passed, combined_output)`.
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
let mut all_output = String::new();
let mut all_passed = true;
// ── cargo clippy ──────────────────────────────────────────────
let clippy = Command::new("cargo")
.args(["clippy", "--all-targets", "--all-features"])
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run cargo clippy: {e}"))?;
all_output.push_str("=== cargo clippy ===\n");
let clippy_stdout = String::from_utf8_lossy(&clippy.stdout);
let clippy_stderr = String::from_utf8_lossy(&clippy.stderr);
if !clippy_stdout.is_empty() {
all_output.push_str(&clippy_stdout);
}
if !clippy_stderr.is_empty() {
all_output.push_str(&clippy_stderr);
}
all_output.push('\n');
if !clippy.status.success() {
all_passed = false;
}
// ── tests (script/test if available, else cargo nextest/test) ─
let (test_success, test_out) = run_project_tests(path)?;
all_output.push_str(&test_out);
if !test_success {
all_passed = false;
}
Ok((all_passed, all_output))
}
/// Run `script/test_coverage` in the given directory if the script exists.
///
/// Used as a QA gate before advancing a story from `3_qa/` to `4_merge/`.
/// Returns `(passed, output)`. If the script does not exist, returns `(true, …)`.
pub(crate) fn run_coverage_gate(path: &Path) -> Result<(bool, String), String> {
let script = path.join("script").join("test_coverage");
if !script.exists() {
return Ok((
true,
"script/test_coverage not found; coverage gate skipped.\n".to_string(),
));
}
let mut output = String::from("=== script/test_coverage ===\n");
let result = Command::new(&script)
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run script/test_coverage: {e}"))?;
let combined = format!(
"{}{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
output.push_str(&combined);
output.push('\n');
Ok((result.status.success(), output))
}
#[cfg(test)]
mod tests {
use super::*;
fn init_git_repo(repo: &std::path::Path) {
Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.unwrap();
}
// ── run_project_tests tests ───────────────────────────────────
#[cfg(unix)]
#[test]
fn run_project_tests_uses_script_test_when_present_and_passes() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
let script_test = script_dir.join("test");
fs::write(&script_test, "#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n").unwrap();
let mut perms = fs::metadata(&script_test).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_test, perms).unwrap();
let (passed, output) = run_project_tests(path).unwrap();
assert!(passed, "script/test exiting 0 should pass");
assert!(output.contains("script/test"), "output should mention script/test");
}
#[cfg(unix)]
#[test]
fn run_project_tests_reports_failure_when_script_test_exits_nonzero() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
let script_test = script_dir.join("test");
fs::write(&script_test, "#!/usr/bin/env bash\nexit 1\n").unwrap();
let mut perms = fs::metadata(&script_test).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_test, perms).unwrap();
let (passed, output) = run_project_tests(path).unwrap();
assert!(!passed, "script/test exiting 1 should fail");
assert!(output.contains("script/test"), "output should mention script/test");
}
// ── run_coverage_gate tests ───────────────────────────────────────────────
#[cfg(unix)]
#[test]
fn coverage_gate_passes_when_script_absent() {
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let (passed, output) = run_coverage_gate(tmp.path()).unwrap();
assert!(passed, "coverage gate should pass when script is absent");
assert!(
output.contains("not found"),
"output should mention script not found"
);
}
#[cfg(unix)]
#[test]
fn coverage_gate_passes_when_script_exits_zero() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
let script = script_dir.join("test_coverage");
fs::write(
&script,
"#!/usr/bin/env bash\necho 'Rust line coverage: 85%'\necho 'PASS: Coverage 85% meets threshold 0%'\nexit 0\n",
)
.unwrap();
let mut perms = fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script, perms).unwrap();
let (passed, output) = run_coverage_gate(path).unwrap();
assert!(passed, "coverage gate should pass when script exits 0");
assert!(
output.contains("script/test_coverage"),
"output should mention script/test_coverage"
);
}
#[cfg(unix)]
#[test]
fn coverage_gate_fails_when_script_exits_nonzero() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
let script = script_dir.join("test_coverage");
fs::write(
&script,
"#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n",
)
.unwrap();
let mut perms = fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script, perms).unwrap();
let (passed, output) = run_coverage_gate(path).unwrap();
assert!(!passed, "coverage gate should fail when script exits 1");
assert!(
output.contains("script/test_coverage"),
"output should mention script/test_coverage"
);
}
// ── worktree_has_committed_work tests ─────────────────────────────────────
#[test]
fn worktree_has_committed_work_false_on_fresh_repo() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
// init_git_repo creates the initial commit on the default branch.
// HEAD IS the base branch — no commits ahead.
init_git_repo(repo);
assert!(!worktree_has_committed_work(repo));
}
#[test]
fn worktree_has_committed_work_true_after_commit_on_feature_branch() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path().join("project");
fs::create_dir_all(&project_root).unwrap();
init_git_repo(&project_root);
// Create a git worktree on a feature branch.
let wt_path = tmp.path().join("wt");
Command::new("git")
.args([
"worktree",
"add",
&wt_path.to_string_lossy(),
"-b",
"feature/story-99_test",
])
.current_dir(&project_root)
.output()
.unwrap();
// No commits on the feature branch yet — same as base branch.
assert!(!worktree_has_committed_work(&wt_path));
// Add a commit to the feature branch in the worktree.
fs::write(wt_path.join("work.txt"), "done").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&wt_path)
.output()
.unwrap();
Command::new("git")
.args([
"-c",
"user.email=test@test.com",
"-c",
"user.name=Test",
"commit",
"-m",
"coder: implement story",
])
.current_dir(&wt_path)
.output()
.unwrap();
// Now the feature branch is ahead of the base branch.
assert!(worktree_has_committed_work(&wt_path));
}
}

View File

@@ -0,0 +1,555 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::io::story_metadata::clear_front_matter_field;
use crate::slog;
pub(super) fn item_type_from_id(item_id: &str) -> &'static str {
// New format: {digits}_{type}_{slug}
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
if after_num.starts_with("_bug_") {
"bug"
} else if after_num.starts_with("_spike_") {
"spike"
} else {
"story"
}
}
/// Return the source directory path for a work item (always work/1_upcoming/).
fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf {
project_root.join(".story_kit").join("work").join("1_upcoming")
}
/// Return the done directory path for a work item (always work/5_done/).
fn item_archive_dir(project_root: &Path, _item_id: &str) -> PathBuf {
project_root.join(".story_kit").join("work").join("5_done")
}
/// Move a work item (story, bug, or spike) from `work/1_upcoming/` to `work/2_current/`.
///
/// Idempotent: if the item is already in `2_current/`, returns Ok without committing.
/// If the item is not found in `1_upcoming/`, logs a warning and returns Ok.
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
let sk = project_root.join(".story_kit").join("work");
let current_dir = sk.join("2_current");
let current_path = current_dir.join(format!("{story_id}.md"));
if current_path.exists() {
// Already in 2_current/ — idempotent, nothing to do.
return Ok(());
}
let source_dir = item_source_dir(project_root, story_id);
let source_path = source_dir.join(format!("{story_id}.md"));
if !source_path.exists() {
slog!(
"[lifecycle] Work item '{story_id}' not found in {}; skipping move to 2_current/",
source_dir.display()
);
return Ok(());
}
std::fs::create_dir_all(&current_dir)
.map_err(|e| format!("Failed to create work/2_current/ directory: {e}"))?;
std::fs::rename(&source_path, &current_path)
.map_err(|e| format!("Failed to move '{story_id}' to 2_current/: {e}"))?;
slog!(
"[lifecycle] Moved '{story_id}' from {} to work/2_current/",
source_dir.display()
);
Ok(())
}
/// Check whether a feature branch `feature/story-{story_id}` exists and has
/// commits that are not yet on master. Returns `true` when there is unmerged
/// work, `false` when there is no branch or all its commits are already
/// reachable from master.
pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str) -> bool {
let branch = format!("feature/story-{story_id}");
// Check if the branch exists.
let branch_check = Command::new("git")
.args(["rev-parse", "--verify", &branch])
.current_dir(project_root)
.output();
match branch_check {
Ok(out) if out.status.success() => {}
_ => return false, // No feature branch → nothing to merge.
}
// Check if the branch has commits not reachable from master.
let log = Command::new("git")
.args(["log", &format!("master..{branch}"), "--oneline"])
.current_dir(project_root)
.output();
match log {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
!stdout.trim().is_empty()
}
Err(_) => false,
}
}
/// Move a story from `work/2_current/` to `work/5_done/` and auto-commit.
///
/// * If the story is in `2_current/`, it is moved to `5_done/` and committed.
/// * If the story is in `4_merge/`, it is moved to `5_done/` and committed.
/// * If the story is already in `5_done/` or `6_archived/`, this is a no-op (idempotent).
/// * If the story is not found in `2_current/`, `4_merge/`, `5_done/`, or `6_archived/`, an error is returned.
pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> {
let sk = project_root.join(".story_kit").join("work");
let current_path = sk.join("2_current").join(format!("{story_id}.md"));
let merge_path = sk.join("4_merge").join(format!("{story_id}.md"));
let done_dir = sk.join("5_done");
let done_path = done_dir.join(format!("{story_id}.md"));
let archived_path = sk.join("6_archived").join(format!("{story_id}.md"));
if done_path.exists() || archived_path.exists() {
// Already in done or archived — idempotent, nothing to do.
return Ok(());
}
// Check 2_current/ first, then 4_merge/
let source_path = if current_path.exists() {
current_path.clone()
} else if merge_path.exists() {
merge_path.clone()
} else {
return Err(format!(
"Story '{story_id}' not found in work/2_current/ or work/4_merge/. Cannot accept story."
));
};
std::fs::create_dir_all(&done_dir)
.map_err(|e| format!("Failed to create work/5_done/ directory: {e}"))?;
std::fs::rename(&source_path, &done_path)
.map_err(|e| format!("Failed to move story '{story_id}' to 5_done/: {e}"))?;
// Strip stale merge_failure from front matter now that the story is done.
if let Err(e) = clear_front_matter_field(&done_path, "merge_failure") {
slog!("[lifecycle] Warning: could not clear merge_failure from '{story_id}': {e}");
}
let from_dir = if source_path == current_path {
"work/2_current/"
} else {
"work/4_merge/"
};
slog!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_done/");
Ok(())
}
/// Move a story/bug from `work/2_current/` or `work/3_qa/` to `work/4_merge/`.
///
/// This stages a work item as ready for the mergemaster to pick up and merge into master.
/// Idempotent: if already in `4_merge/`, returns Ok without committing.
pub fn move_story_to_merge(project_root: &Path, story_id: &str) -> Result<(), String> {
let sk = project_root.join(".story_kit").join("work");
let current_path = sk.join("2_current").join(format!("{story_id}.md"));
let qa_path = sk.join("3_qa").join(format!("{story_id}.md"));
let merge_dir = sk.join("4_merge");
let merge_path = merge_dir.join(format!("{story_id}.md"));
if merge_path.exists() {
// Already in 4_merge/ — idempotent, nothing to do.
return Ok(());
}
// Accept from 2_current/ (manual trigger) or 3_qa/ (pipeline advancement from QA stage).
let source_path = if current_path.exists() {
current_path.clone()
} else if qa_path.exists() {
qa_path.clone()
} else {
return Err(format!(
"Work item '{story_id}' not found in work/2_current/ or work/3_qa/. Cannot move to 4_merge/."
));
};
std::fs::create_dir_all(&merge_dir)
.map_err(|e| format!("Failed to create work/4_merge/ directory: {e}"))?;
std::fs::rename(&source_path, &merge_path)
.map_err(|e| format!("Failed to move '{story_id}' to 4_merge/: {e}"))?;
let from_dir = if source_path == current_path {
"work/2_current/"
} else {
"work/3_qa/"
};
slog!("[lifecycle] Moved '{story_id}' from {from_dir} to work/4_merge/");
Ok(())
}
/// Move a story/bug from `work/2_current/` to `work/3_qa/` and auto-commit.
///
/// This stages a work item for QA review before merging to master.
/// Idempotent: if already in `3_qa/`, returns Ok without committing.
pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), String> {
let sk = project_root.join(".story_kit").join("work");
let current_path = sk.join("2_current").join(format!("{story_id}.md"));
let qa_dir = sk.join("3_qa");
let qa_path = qa_dir.join(format!("{story_id}.md"));
if qa_path.exists() {
// Already in 3_qa/ — idempotent, nothing to do.
return Ok(());
}
if !current_path.exists() {
return Err(format!(
"Work item '{story_id}' not found in work/2_current/. Cannot move to 3_qa/."
));
}
std::fs::create_dir_all(&qa_dir)
.map_err(|e| format!("Failed to create work/3_qa/ directory: {e}"))?;
std::fs::rename(&current_path, &qa_path)
.map_err(|e| format!("Failed to move '{story_id}' to 3_qa/: {e}"))?;
slog!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/");
Ok(())
}
/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_done/` and auto-commit.
///
/// * If the bug is in `2_current/`, it is moved to `5_done/` and committed.
/// * If the bug is still in `1_upcoming/` (never started), it is moved directly to `5_done/`.
/// * If the bug is already in `5_done/`, this is a no-op (idempotent).
/// * If the bug is not found anywhere, an error is returned.
pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), String> {
let sk = project_root.join(".story_kit").join("work");
let current_path = sk.join("2_current").join(format!("{bug_id}.md"));
let upcoming_path = sk.join("1_upcoming").join(format!("{bug_id}.md"));
let archive_dir = item_archive_dir(project_root, bug_id);
let archive_path = archive_dir.join(format!("{bug_id}.md"));
if archive_path.exists() {
return Ok(());
}
let source_path = if current_path.exists() {
current_path.clone()
} else if upcoming_path.exists() {
upcoming_path.clone()
} else {
return Err(format!(
"Bug '{bug_id}' not found in work/2_current/ or work/1_upcoming/. Cannot close bug."
));
};
std::fs::create_dir_all(&archive_dir)
.map_err(|e| format!("Failed to create work/5_done/ directory: {e}"))?;
std::fs::rename(&source_path, &archive_path)
.map_err(|e| format!("Failed to move bug '{bug_id}' to 5_done/: {e}"))?;
slog!(
"[lifecycle] Closed bug '{bug_id}' → work/5_done/"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// ── move_story_to_current tests ────────────────────────────────────────────
#[test]
fn move_story_to_current_moves_file() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let upcoming = root.join(".story_kit/work/1_upcoming");
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&current).unwrap();
fs::write(upcoming.join("10_story_foo.md"), "test").unwrap();
move_story_to_current(root, "10_story_foo").unwrap();
assert!(!upcoming.join("10_story_foo.md").exists());
assert!(current.join("10_story_foo.md").exists());
}
#[test]
fn move_story_to_current_is_idempotent_when_already_current() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("11_story_foo.md"), "test").unwrap();
move_story_to_current(root, "11_story_foo").unwrap();
assert!(current.join("11_story_foo.md").exists());
}
#[test]
fn move_story_to_current_noop_when_not_in_upcoming() {
let tmp = tempfile::tempdir().unwrap();
assert!(move_story_to_current(tmp.path(), "99_missing").is_ok());
}
#[test]
fn move_bug_to_current_moves_from_upcoming() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let upcoming = root.join(".story_kit/work/1_upcoming");
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&current).unwrap();
fs::write(upcoming.join("1_bug_test.md"), "# Bug 1\n").unwrap();
move_story_to_current(root, "1_bug_test").unwrap();
assert!(!upcoming.join("1_bug_test.md").exists());
assert!(current.join("1_bug_test.md").exists());
}
// ── close_bug_to_archive tests ─────────────────────────────────────────────
#[test]
fn close_bug_moves_from_current_to_archive() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("2_bug_test.md"), "# Bug 2\n").unwrap();
close_bug_to_archive(root, "2_bug_test").unwrap();
assert!(!current.join("2_bug_test.md").exists());
assert!(root.join(".story_kit/work/5_done/2_bug_test.md").exists());
}
#[test]
fn close_bug_moves_from_upcoming_when_not_started() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let upcoming = root.join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join("3_bug_test.md"), "# Bug 3\n").unwrap();
close_bug_to_archive(root, "3_bug_test").unwrap();
assert!(!upcoming.join("3_bug_test.md").exists());
assert!(root.join(".story_kit/work/5_done/3_bug_test.md").exists());
}
// ── item_type_from_id tests ────────────────────────────────────────────────
#[test]
fn item_type_from_id_detects_types() {
assert_eq!(item_type_from_id("1_bug_test"), "bug");
assert_eq!(item_type_from_id("1_spike_research"), "spike");
assert_eq!(item_type_from_id("50_story_my_story"), "story");
assert_eq!(item_type_from_id("1_story_simple"), "story");
}
// ── move_story_to_merge tests ──────────────────────────────────────────────
#[test]
fn move_story_to_merge_moves_file() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("20_story_foo.md"), "test").unwrap();
move_story_to_merge(root, "20_story_foo").unwrap();
assert!(!current.join("20_story_foo.md").exists());
assert!(root.join(".story_kit/work/4_merge/20_story_foo.md").exists());
}
#[test]
fn move_story_to_merge_from_qa_dir() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let qa_dir = root.join(".story_kit/work/3_qa");
fs::create_dir_all(&qa_dir).unwrap();
fs::write(qa_dir.join("40_story_test.md"), "test").unwrap();
move_story_to_merge(root, "40_story_test").unwrap();
assert!(!qa_dir.join("40_story_test.md").exists());
assert!(root.join(".story_kit/work/4_merge/40_story_test.md").exists());
}
#[test]
fn move_story_to_merge_idempotent_when_already_in_merge() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let merge_dir = root.join(".story_kit/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(merge_dir.join("21_story_test.md"), "test").unwrap();
move_story_to_merge(root, "21_story_test").unwrap();
assert!(merge_dir.join("21_story_test.md").exists());
}
#[test]
fn move_story_to_merge_errors_when_not_in_current_or_qa() {
let tmp = tempfile::tempdir().unwrap();
let result = move_story_to_merge(tmp.path(), "99_nonexistent");
assert!(result.unwrap_err().contains("not found in work/2_current/ or work/3_qa/"));
}
// ── move_story_to_qa tests ────────────────────────────────────────────────
#[test]
fn move_story_to_qa_moves_file() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("30_story_qa.md"), "test").unwrap();
move_story_to_qa(root, "30_story_qa").unwrap();
assert!(!current.join("30_story_qa.md").exists());
assert!(root.join(".story_kit/work/3_qa/30_story_qa.md").exists());
}
#[test]
fn move_story_to_qa_idempotent_when_already_in_qa() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let qa_dir = root.join(".story_kit/work/3_qa");
fs::create_dir_all(&qa_dir).unwrap();
fs::write(qa_dir.join("31_story_test.md"), "test").unwrap();
move_story_to_qa(root, "31_story_test").unwrap();
assert!(qa_dir.join("31_story_test.md").exists());
}
#[test]
fn move_story_to_qa_errors_when_not_in_current() {
let tmp = tempfile::tempdir().unwrap();
let result = move_story_to_qa(tmp.path(), "99_nonexistent");
assert!(result.unwrap_err().contains("not found in work/2_current/"));
}
// ── move_story_to_archived tests ──────────────────────────────────────────
#[test]
fn move_story_to_archived_finds_in_merge_dir() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let merge_dir = root.join(".story_kit/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(merge_dir.join("22_story_test.md"), "test").unwrap();
move_story_to_archived(root, "22_story_test").unwrap();
assert!(!merge_dir.join("22_story_test.md").exists());
assert!(root.join(".story_kit/work/5_done/22_story_test.md").exists());
}
#[test]
fn move_story_to_archived_error_when_not_in_current_or_merge() {
let tmp = tempfile::tempdir().unwrap();
let result = move_story_to_archived(tmp.path(), "99_nonexistent");
assert!(result.unwrap_err().contains("4_merge"));
}
// ── feature_branch_has_unmerged_changes tests ────────────────────────────
fn init_git_repo(repo: &std::path::Path) {
Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.unwrap();
}
/// Bug 226: feature_branch_has_unmerged_changes returns true when the
/// feature branch has commits not on master.
#[test]
fn feature_branch_has_unmerged_changes_detects_unmerged_code() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create a feature branch with a code commit.
Command::new("git")
.args(["checkout", "-b", "feature/story-50_story_test"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("feature.rs"), "fn main() {}").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add feature"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
assert!(
feature_branch_has_unmerged_changes(repo, "50_story_test"),
"should detect unmerged changes on feature branch"
);
}
/// Bug 226: feature_branch_has_unmerged_changes returns false when no
/// feature branch exists.
#[test]
fn feature_branch_has_unmerged_changes_false_when_no_branch() {
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
assert!(
!feature_branch_has_unmerged_changes(repo, "99_nonexistent"),
"should return false when no feature branch"
);
}
}

1667
server/src/agents/merge.rs Normal file

File diff suppressed because it is too large Load Diff

181
server/src/agents/mod.rs Normal file
View File

@@ -0,0 +1,181 @@
pub mod gates;
pub mod lifecycle;
pub mod merge;
mod pool;
mod pty;
use crate::config::AgentConfig;
use serde::Serialize;
pub use lifecycle::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived,
move_story_to_merge, move_story_to_qa,
};
pub use pool::AgentPool;
/// Events emitted during server startup reconciliation to broadcast real-time
/// progress to connected WebSocket clients.
#[derive(Debug, Clone, Serialize)]
pub struct ReconciliationEvent {
/// The story being reconciled, or empty string for the overall "done" event.
pub story_id: String,
/// Coarse status: "checking", "gates_running", "advanced", "skipped", "failed", "done"
pub status: String,
/// Human-readable details.
pub message: String,
}
/// Events streamed from a running agent to SSE clients.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
/// Agent status changed.
Status {
story_id: String,
agent_name: String,
status: String,
},
/// Raw text output from the agent process.
Output {
story_id: String,
agent_name: String,
text: String,
},
/// Agent produced a JSON event from `--output-format stream-json`.
AgentJson {
story_id: String,
agent_name: String,
data: serde_json::Value,
},
/// Agent finished.
Done {
story_id: String,
agent_name: String,
session_id: Option<String>,
},
/// Agent errored.
Error {
story_id: String,
agent_name: String,
message: String,
},
/// Thinking tokens from an extended-thinking block.
Thinking {
story_id: String,
agent_name: String,
text: String,
},
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AgentStatus {
Pending,
Running,
Completed,
Failed,
}
impl std::fmt::Display for AgentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
}
}
}
/// Pipeline stages for automatic story advancement.
#[derive(Debug, Clone, PartialEq)]
pub enum PipelineStage {
/// Coding agents (coder-1, coder-2, etc.)
Coder,
/// QA review agent
Qa,
/// Mergemaster agent
Mergemaster,
/// Supervisors and unknown agents — no automatic advancement.
Other,
}
/// Determine the pipeline stage from an agent name.
pub fn pipeline_stage(agent_name: &str) -> PipelineStage {
match agent_name {
"qa" => PipelineStage::Qa,
"mergemaster" => PipelineStage::Mergemaster,
name if name.starts_with("coder") => PipelineStage::Coder,
_ => PipelineStage::Other,
}
}
/// Determine the pipeline stage for a configured agent.
///
/// Prefers the explicit `stage` config field (added in Bug 150) over the
/// legacy name-based heuristic so that agents with non-standard names
/// (e.g. `qa-2`, `coder-opus`) are assigned to the correct stage.
pub(crate) fn agent_config_stage(cfg: &AgentConfig) -> PipelineStage {
match cfg.stage.as_deref() {
Some("coder") => PipelineStage::Coder,
Some("qa") => PipelineStage::Qa,
Some("mergemaster") => PipelineStage::Mergemaster,
Some(_) => PipelineStage::Other,
None => pipeline_stage(&cfg.name),
}
}
/// Completion report produced when acceptance gates are run.
///
/// Created automatically by the server when an agent process exits normally,
/// or via the internal `report_completion` method.
#[derive(Debug, Serialize, Clone)]
pub struct CompletionReport {
pub summary: String,
pub gates_passed: bool,
pub gate_output: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct AgentInfo {
pub story_id: String,
pub agent_name: String,
pub status: AgentStatus,
pub session_id: Option<String>,
pub worktree_path: Option<String>,
pub base_branch: Option<String>,
pub completion: Option<CompletionReport>,
/// UUID identifying the persistent log file for this session.
pub log_session_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
// ── pipeline_stage tests ──────────────────────────────────────────────────
#[test]
fn pipeline_stage_detects_coders() {
assert_eq!(pipeline_stage("coder-1"), PipelineStage::Coder);
assert_eq!(pipeline_stage("coder-2"), PipelineStage::Coder);
assert_eq!(pipeline_stage("coder-3"), PipelineStage::Coder);
}
#[test]
fn pipeline_stage_detects_qa() {
assert_eq!(pipeline_stage("qa"), PipelineStage::Qa);
}
#[test]
fn pipeline_stage_detects_mergemaster() {
assert_eq!(pipeline_stage("mergemaster"), PipelineStage::Mergemaster);
}
#[test]
fn pipeline_stage_supervisor_is_other() {
assert_eq!(pipeline_stage("supervisor"), PipelineStage::Other);
assert_eq!(pipeline_stage("default"), PipelineStage::Other);
assert_eq!(pipeline_stage("unknown"), PipelineStage::Other);
}
}

File diff suppressed because it is too large Load Diff

490
server/src/agents/pty.rs Normal file
View File

@@ -0,0 +1,490 @@
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::sync::{Arc, Mutex};
use portable_pty::{ChildKiller, CommandBuilder, PtySize, native_pty_system};
use tokio::sync::broadcast;
use super::AgentEvent;
use crate::agent_log::AgentLogWriter;
use crate::slog;
use crate::slog_warn;
fn composite_key(story_id: &str, agent_name: &str) -> String {
format!("{story_id}:{agent_name}")
}
struct ChildKillerGuard {
killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
key: String,
}
impl Drop for ChildKillerGuard {
fn drop(&mut self) {
if let Ok(mut killers) = self.killers.lock() {
killers.remove(&self.key);
}
}
}
/// Spawn claude agent in a PTY and stream events through the broadcast channel.
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_agent_pty_streaming(
story_id: &str,
agent_name: &str,
command: &str,
args: &[String],
prompt: &str,
cwd: &str,
tx: &broadcast::Sender<AgentEvent>,
event_log: &Arc<Mutex<Vec<AgentEvent>>>,
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
inactivity_timeout_secs: u64,
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
) -> Result<Option<String>, String> {
let sid = story_id.to_string();
let aname = agent_name.to_string();
let cmd = command.to_string();
let args = args.to_vec();
let prompt = prompt.to_string();
let cwd = cwd.to_string();
let tx = tx.clone();
let event_log = event_log.clone();
tokio::task::spawn_blocking(move || {
run_agent_pty_blocking(
&sid,
&aname,
&cmd,
&args,
&prompt,
&cwd,
&tx,
&event_log,
log_writer.as_deref(),
inactivity_timeout_secs,
&child_killers,
)
})
.await
.map_err(|e| format!("Agent task panicked: {e}"))?
}
/// Dispatch a `stream_event` from Claude Code's `--include-partial-messages` output.
///
/// Extracts `thinking_delta` and `text_delta` from `content_block_delta` events
/// and routes them as `AgentEvent::Thinking` and `AgentEvent::Output` respectively.
/// This ensures thinking traces flow through the dedicated `ThinkingBlock` UI
/// component rather than appearing as unbounded regular output.
fn handle_agent_stream_event(
event: &serde_json::Value,
story_id: &str,
agent_name: &str,
tx: &broadcast::Sender<AgentEvent>,
event_log: &Mutex<Vec<AgentEvent>>,
log_writer: Option<&Mutex<AgentLogWriter>>,
) {
let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
if event_type == "content_block_delta"
&& let Some(delta) = event.get("delta")
{
let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
match delta_type {
"thinking_delta" => {
if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) {
emit_event(
AgentEvent::Thinking {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
text: thinking.to_string(),
},
tx,
event_log,
log_writer,
);
}
}
"text_delta" => {
if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
emit_event(
AgentEvent::Output {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
text: text.to_string(),
},
tx,
event_log,
log_writer,
);
}
}
_ => {}
}
}
}
/// Helper to send an event to broadcast, event log, and optional persistent log file.
pub(super) fn emit_event(
event: AgentEvent,
tx: &broadcast::Sender<AgentEvent>,
event_log: &Mutex<Vec<AgentEvent>>,
log_writer: Option<&Mutex<AgentLogWriter>>,
) {
if let Ok(mut log) = event_log.lock() {
log.push(event.clone());
}
if let Some(writer) = log_writer
&& let Ok(mut w) = writer.lock()
&& let Err(e) = w.write_event(&event)
{
eprintln!("[agent_log] Failed to write event to log file: {e}");
}
let _ = tx.send(event);
}
#[allow(clippy::too_many_arguments)]
fn run_agent_pty_blocking(
story_id: &str,
agent_name: &str,
command: &str,
args: &[String],
prompt: &str,
cwd: &str,
tx: &broadcast::Sender<AgentEvent>,
event_log: &Mutex<Vec<AgentEvent>>,
log_writer: Option<&Mutex<AgentLogWriter>>,
inactivity_timeout_secs: u64,
child_killers: &Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
) -> Result<Option<String>, String> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 50,
cols: 200,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open PTY: {e}"))?;
let mut cmd = CommandBuilder::new(command);
// -p <prompt> must come first
cmd.arg("-p");
cmd.arg(prompt);
// Add configured args (e.g., --directory /path/to/worktree, --model, etc.)
for arg in args {
cmd.arg(arg);
}
cmd.arg("--output-format");
cmd.arg("stream-json");
cmd.arg("--verbose");
// Enable partial streaming so we receive thinking_delta and text_delta
// events in real-time, rather than only complete assistant events.
// Without this, thinking traces may not appear in the structured output
// and instead leak as unstructured PTY text.
cmd.arg("--include-partial-messages");
// Supervised agents don't need interactive permission prompts
cmd.arg("--permission-mode");
cmd.arg("bypassPermissions");
cmd.cwd(cwd);
cmd.env("NO_COLOR", "1");
// Allow spawning Claude Code from within a Claude Code session
cmd.env_remove("CLAUDECODE");
cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
slog!("[agent:{story_id}:{agent_name}] Spawning {command} in {cwd} with args: {args:?}");
let mut child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn agent for {story_id}:{agent_name}: {e}"))?;
// Register the child killer so that kill_all_children() / stop_agent() can
// terminate this process on server shutdown, even if the blocking thread
// cannot be interrupted. The ChildKillerGuard deregisters on function exit.
let killer_key = composite_key(story_id, agent_name);
{
let killer = child.clone_killer();
if let Ok(mut killers) = child_killers.lock() {
killers.insert(killer_key.clone(), killer);
}
}
let _killer_guard = ChildKillerGuard {
killers: Arc::clone(child_killers),
key: killer_key,
};
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
drop(pair.master);
// Spawn a reader thread to collect PTY output lines.
// We use a channel so the main thread can apply an inactivity deadline
// via recv_timeout: if no output arrives within the configured window
// the process is killed and the agent is marked Failed.
let (line_tx, line_rx) = std::sync::mpsc::channel::<std::io::Result<String>>();
std::thread::spawn(move || {
let buf_reader = BufReader::new(reader);
for line in buf_reader.lines() {
if line_tx.send(line).is_err() {
break;
}
}
});
let timeout_dur = if inactivity_timeout_secs > 0 {
Some(std::time::Duration::from_secs(inactivity_timeout_secs))
} else {
None
};
let mut session_id: Option<String> = None;
loop {
let recv_result = match timeout_dur {
Some(dur) => line_rx.recv_timeout(dur),
None => line_rx
.recv()
.map_err(|_| std::sync::mpsc::RecvTimeoutError::Disconnected),
};
let line = match recv_result {
Ok(Ok(l)) => l,
Ok(Err(_)) => {
// IO error reading from PTY — treat as EOF.
break;
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// Reader thread exited (EOF from PTY).
break;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
slog_warn!(
"[agent:{story_id}:{agent_name}] Inactivity timeout after \
{inactivity_timeout_secs}s with no output. Killing process."
);
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"Agent inactivity timeout: no output received for {inactivity_timeout_secs}s"
));
}
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// Try to parse as JSON
let json: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(j) => j,
Err(_) => {
// Non-JSON output (terminal escapes etc.) — send as raw output
emit_event(
AgentEvent::Output {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
text: trimmed.to_string(),
},
tx,
event_log,
log_writer,
);
continue;
}
};
let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or("");
match event_type {
"system" => {
session_id = json
.get("session_id")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
// With --include-partial-messages, thinking and text arrive
// incrementally via stream_event → content_block_delta. Handle
// them here for real-time streaming to the frontend.
"stream_event" => {
if let Some(event) = json.get("event") {
handle_agent_stream_event(
event,
story_id,
agent_name,
tx,
event_log,
log_writer,
);
}
}
// Complete assistant events are skipped for content extraction
// because thinking and text already arrived via stream_event.
// The raw JSON is still forwarded as AgentJson below.
"assistant" | "user" | "result" => {}
_ => {}
}
// Forward all JSON events
emit_event(
AgentEvent::AgentJson {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
data: json,
},
tx,
event_log,
log_writer,
);
}
let _ = child.kill();
let _ = child.wait();
slog!(
"[agent:{story_id}:{agent_name}] Done. Session: {:?}",
session_id
);
Ok(session_id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentEvent;
#[test]
fn test_emit_event_writes_to_log_writer() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let log_writer =
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-emit").unwrap();
let log_mutex = Mutex::new(log_writer);
let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
let event = AgentEvent::Status {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
status: "running".to_string(),
};
emit_event(event, &tx, &event_log, Some(&log_mutex));
// Verify event was added to in-memory log
let mem_events = event_log.lock().unwrap();
assert_eq!(mem_events.len(), 1);
drop(mem_events);
// Verify event was written to the log file
let log_path =
crate::agent_log::log_file_path(root, "42_story_foo", "coder-1", "sess-emit");
let entries = crate::agent_log::read_log(&log_path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].event["type"], "status");
assert_eq!(entries[0].event["status"], "running");
}
// ── bug 167: handle_agent_stream_event routes thinking/text correctly ───
#[test]
fn stream_event_thinking_delta_emits_thinking_event() {
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
let event = serde_json::json!({
"type": "content_block_delta",
"delta": {"type": "thinking_delta", "thinking": "Let me analyze this..."}
});
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
let received = rx.try_recv().unwrap();
match received {
AgentEvent::Thinking {
story_id,
agent_name,
text,
} => {
assert_eq!(story_id, "s1");
assert_eq!(agent_name, "coder-1");
assert_eq!(text, "Let me analyze this...");
}
other => panic!("Expected Thinking event, got: {other:?}"),
}
}
#[test]
fn stream_event_text_delta_emits_output_event() {
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
let event = serde_json::json!({
"type": "content_block_delta",
"delta": {"type": "text_delta", "text": "Here is the result."}
});
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
let received = rx.try_recv().unwrap();
match received {
AgentEvent::Output {
story_id,
agent_name,
text,
} => {
assert_eq!(story_id, "s1");
assert_eq!(agent_name, "coder-1");
assert_eq!(text, "Here is the result.");
}
other => panic!("Expected Output event, got: {other:?}"),
}
}
#[test]
fn stream_event_input_json_delta_ignored() {
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
let event = serde_json::json!({
"type": "content_block_delta",
"delta": {"type": "input_json_delta", "partial_json": "{\"file\":"}
});
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
// No event should be emitted for tool argument deltas
assert!(rx.try_recv().is_err());
}
#[test]
fn stream_event_non_delta_type_ignored() {
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
let event = serde_json::json!({
"type": "message_start",
"message": {"role": "assistant"}
});
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
assert!(rx.try_recv().is_err());
}
}

View File

@@ -632,6 +632,48 @@ name = "coder"
assert_eq!(config.watcher, WatcherConfig::default());
}
#[test]
fn coder_agents_have_root_cause_guidance() {
// Load the actual project.toml and verify all coder-stage agents
// include root cause investigation guidance for bugs.
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let project_root = manifest_dir.parent().unwrap();
let config = ProjectConfig::load(project_root).unwrap();
let coder_agents: Vec<_> = config
.agent
.iter()
.filter(|a| a.stage.as_deref() == Some("coder"))
.collect();
assert!(
!coder_agents.is_empty(),
"Expected at least one coder-stage agent in project.toml"
);
for agent in coder_agents {
let prompt = &agent.prompt;
let system_prompt = agent.system_prompt.as_deref().unwrap_or("");
let combined = format!("{prompt} {system_prompt}");
assert!(
combined.contains("root cause"),
"Coder agent '{}' must mention 'root cause' in prompt or system_prompt",
agent.name
);
assert!(
combined.contains("git bisect") || combined.contains("git log"),
"Coder agent '{}' must mention 'git bisect' or 'git log' for bug investigation",
agent.name
);
assert!(
combined.to_lowercase().contains("do not") || combined.contains("surgical"),
"Coder agent '{}' must discourage adding abstractions/workarounds",
agent.name
);
}
}
#[test]
fn watcher_config_preserved_in_legacy_format() {
let toml_str = r#"

View File

@@ -68,6 +68,7 @@ struct WorkItemContentResponse {
content: String,
stage: String,
name: Option<String>,
agent: Option<String>,
}
/// A single test case result for the OpenAPI response.
@@ -105,6 +106,12 @@ impl TestResultsResponse {
}
}
/// Response for the agent output endpoint.
#[derive(Object, Serialize)]
struct AgentOutputResponse {
output: String,
}
/// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`.
///
/// Used to exclude agents for already-archived stories from the `list_agents`
@@ -348,13 +355,14 @@ impl AgentsApi {
if file_path.exists() {
let content = std::fs::read_to_string(&file_path)
.map_err(|e| bad_request(format!("Failed to read work item: {e}")))?;
let name = crate::io::story_metadata::parse_front_matter(&content)
.ok()
.and_then(|m| m.name);
let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
let name = metadata.as_ref().and_then(|m| m.name.clone());
let agent = metadata.and_then(|m| m.agent);
return Ok(Json(WorkItemContentResponse {
content,
stage: stage_name.to_string(),
name,
agent,
}));
}
}
@@ -400,6 +408,45 @@ impl AgentsApi {
))
}
/// Get the historical output text for an agent session.
///
/// Reads the most recent persistent log file for the given story+agent and
/// returns all `output` events concatenated as a single string. Returns an
/// empty string if no log file exists yet.
#[oai(path = "/agents/:story_id/:agent_name/output", method = "get")]
async fn get_agent_output(
&self,
story_id: Path<String>,
agent_name: Path<String>,
) -> OpenApiResult<Json<AgentOutputResponse>> {
let project_root = self
.ctx
.agents
.get_project_root(&self.ctx.state)
.map_err(bad_request)?;
let log_path =
crate::agent_log::find_latest_log(&project_root, &story_id.0, &agent_name.0);
let Some(path) = log_path else {
return Ok(Json(AgentOutputResponse {
output: String::new(),
}));
};
let entries = crate::agent_log::read_log(&path).map_err(bad_request)?;
let output: String = entries
.iter()
.filter(|e| {
e.event.get("type").and_then(|t| t.as_str()) == Some("output")
})
.filter_map(|e| e.event.get("text").and_then(|t| t.as_str()).map(str::to_owned))
.collect();
Ok(Json(AgentOutputResponse { output }))
}
/// Remove a git worktree and its feature branch for a story.
#[oai(path = "/agents/worktrees/:story_id", method = "delete")]
async fn remove_worktree(&self, story_id: Path<String>) -> OpenApiResult<Json<bool>> {
@@ -835,6 +882,100 @@ allowed_tools = ["Read", "Bash"]
assert!(result.is_err());
}
// --- get_agent_output tests ---
#[tokio::test]
async fn get_agent_output_returns_empty_when_no_log_exists() {
let tmp = TempDir::new().unwrap();
let ctx = AppContext::new_test(tmp.path().to_path_buf());
let api = AgentsApi {
ctx: Arc::new(ctx),
};
let result = api
.get_agent_output(
Path("42_story_foo".to_string()),
Path("coder-1".to_string()),
)
.await
.unwrap()
.0;
assert_eq!(result.output, "");
}
#[tokio::test]
async fn get_agent_output_returns_concatenated_output_events() {
use crate::agent_log::AgentLogWriter;
use crate::agents::AgentEvent;
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let mut writer =
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-test").unwrap();
writer
.write_event(&AgentEvent::Status {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
status: "running".to_string(),
})
.unwrap();
writer
.write_event(&AgentEvent::Output {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
text: "Hello ".to_string(),
})
.unwrap();
writer
.write_event(&AgentEvent::Output {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
text: "world\n".to_string(),
})
.unwrap();
writer
.write_event(&AgentEvent::Done {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
session_id: None,
})
.unwrap();
let ctx = AppContext::new_test(root.to_path_buf());
let api = AgentsApi {
ctx: Arc::new(ctx),
};
let result = api
.get_agent_output(
Path("42_story_foo".to_string()),
Path("coder-1".to_string()),
)
.await
.unwrap()
.0;
// Only output event texts should be concatenated; status and done are excluded.
assert_eq!(result.output, "Hello world\n");
}
#[tokio::test]
async fn get_agent_output_returns_error_when_no_project_root() {
let tmp = TempDir::new().unwrap();
let ctx = AppContext::new_test(tmp.path().to_path_buf());
*ctx.state.project_root.lock().unwrap() = None;
let api = AgentsApi {
ctx: Arc::new(ctx),
};
let result = api
.get_agent_output(
Path("42_story_foo".to_string()),
Path("coder-1".to_string()),
)
.await;
assert!(result.is_err());
}
// --- create_worktree error path ---
#[tokio::test]

View File

@@ -9,6 +9,9 @@ use std::sync::Arc;
///
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
/// with `data:` prefix and double newline terminator per the SSE spec.
///
/// `AgentEvent::Thinking` events are intentionally excluded — thinking traces
/// are internal model state and must never be displayed in the UI.
#[handler]
pub async fn agent_stream(
Path((story_id, agent_name)): Path<(String, String)>,
@@ -27,6 +30,11 @@ pub async fn agent_stream(
loop {
match rx.recv().await {
Ok(event) => {
// Never forward thinking traces to the UI — they are
// internal model state and must not be displayed.
if matches!(event, crate::agents::AgentEvent::Thinking { .. }) {
continue;
}
if let Ok(json) = serde_json::to_string(&event) {
yield Ok::<_, std::io::Error>(format!("data: {json}\n\n"));
}
@@ -56,3 +64,145 @@ pub async fn agent_stream(
futures::StreamExt::map(stream, |r| r.map(bytes::Bytes::from)),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::{AgentEvent, AgentStatus};
use crate::http::context::AppContext;
use poem::{EndpointExt, Route, get};
use std::sync::Arc;
use tempfile::tempdir;
fn test_app(ctx: Arc<AppContext>) -> impl poem::Endpoint {
Route::new()
.at(
"/agents/:story_id/:agent_name/stream",
get(agent_stream),
)
.data(ctx)
}
#[tokio::test]
async fn thinking_events_are_not_forwarded_via_sse() {
let tmp = tempdir().unwrap();
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
// Inject a running agent and get its broadcast sender.
let tx = ctx
.agents
.inject_test_agent("1_story", "coder-1", AgentStatus::Running);
// Spawn a task that sends events after the SSE connection is established.
let tx_clone = tx.clone();
tokio::spawn(async move {
// Brief pause so the SSE handler has subscribed before we emit.
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
// Thinking event — must be filtered out.
let _ = tx_clone.send(AgentEvent::Thinking {
story_id: "1_story".to_string(),
agent_name: "coder-1".to_string(),
text: "secret thinking text".to_string(),
});
// Output event — must be forwarded.
let _ = tx_clone.send(AgentEvent::Output {
story_id: "1_story".to_string(),
agent_name: "coder-1".to_string(),
text: "visible output".to_string(),
});
// Done event — closes the stream.
let _ = tx_clone.send(AgentEvent::Done {
story_id: "1_story".to_string(),
agent_name: "coder-1".to_string(),
session_id: None,
});
});
let cli = poem::test::TestClient::new(test_app(ctx));
let resp = cli
.get("/agents/1_story/coder-1/stream")
.send()
.await;
let body = resp.0.into_body().into_string().await.unwrap();
// Thinking content must not appear anywhere in the SSE output.
assert!(
!body.contains("secret thinking text"),
"Thinking text must not be forwarded via SSE: {body}"
);
assert!(
!body.contains("\"type\":\"thinking\""),
"Thinking event type must not appear in SSE output: {body}"
);
// Output event must be present.
assert!(
body.contains("visible output"),
"Output event must be forwarded via SSE: {body}"
);
assert!(
body.contains("\"type\":\"output\""),
"Output event type must appear in SSE output: {body}"
);
}
#[tokio::test]
async fn output_and_done_events_are_forwarded_via_sse() {
let tmp = tempdir().unwrap();
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
let tx = ctx
.agents
.inject_test_agent("2_story", "coder-1", AgentStatus::Running);
let tx_clone = tx.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let _ = tx_clone.send(AgentEvent::Output {
story_id: "2_story".to_string(),
agent_name: "coder-1".to_string(),
text: "step 1 output".to_string(),
});
let _ = tx_clone.send(AgentEvent::Done {
story_id: "2_story".to_string(),
agent_name: "coder-1".to_string(),
session_id: Some("sess-abc".to_string()),
});
});
let cli = poem::test::TestClient::new(test_app(ctx));
let resp = cli
.get("/agents/2_story/coder-1/stream")
.send()
.await;
let body = resp.0.into_body().into_string().await.unwrap();
assert!(body.contains("step 1 output"), "Output must be forwarded: {body}");
assert!(body.contains("\"type\":\"done\""), "Done event must be forwarded: {body}");
}
#[tokio::test]
async fn unknown_agent_returns_404() {
let tmp = tempdir().unwrap();
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
let cli = poem::test::TestClient::new(test_app(ctx));
let resp = cli
.get("/agents/nonexistent/coder-1/stream")
.send()
.await;
assert_eq!(
resp.0.status(),
poem::http::StatusCode::NOT_FOUND,
"Unknown agent must return 404"
);
}
}

View File

@@ -103,6 +103,15 @@ impl IoApi {
Ok(Json(home))
}
/// List all files in the project recursively, respecting .gitignore.
#[oai(path = "/io/fs/files", method = "get")]
async fn list_project_files(&self) -> OpenApiResult<Json<Vec<String>>> {
let files = io_fs::list_project_files(&self.ctx.state)
.await
.map_err(bad_request)?;
Ok(Json(files))
}
/// Search the currently open project for files containing the provided query string.
#[oai(path = "/io/search", method = "post")]
async fn search_files(
@@ -316,6 +325,53 @@ mod tests {
);
}
// --- list_project_files ---
#[tokio::test]
async fn list_project_files_returns_file_paths() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
std::fs::write(dir.path().join("README.md"), "# readme").unwrap();
let api = make_api(&dir);
let result = api.list_project_files().await.unwrap();
let files = &result.0;
assert!(files.contains(&"README.md".to_string()));
assert!(files.contains(&"src/main.rs".to_string()));
}
#[tokio::test]
async fn list_project_files_excludes_directories() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("subdir")).unwrap();
std::fs::write(dir.path().join("file.txt"), "").unwrap();
let api = make_api(&dir);
let result = api.list_project_files().await.unwrap();
let files = &result.0;
assert!(files.contains(&"file.txt".to_string()));
// Directories should not appear
assert!(!files.iter().any(|f| f == "subdir"));
}
#[tokio::test]
async fn list_project_files_returns_sorted_paths() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("z_last.txt"), "").unwrap();
std::fs::write(dir.path().join("a_first.txt"), "").unwrap();
let api = make_api(&dir);
let result = api.list_project_files().await.unwrap();
let files = &result.0;
let a_idx = files.iter().position(|f| f == "a_first.txt").unwrap();
let z_idx = files.iter().position(|f| f == "z_last.txt").unwrap();
assert!(a_idx < z_idx);
}
// --- list_directory (project-scoped) ---
#[tokio::test]
@@ -345,4 +401,5 @@ mod tests {
let result = api.list_directory(payload).await;
assert!(result.is_err());
}
}

View File

@@ -6,9 +6,9 @@ use crate::slog_warn;
use crate::http::context::AppContext;
use crate::http::settings::get_editor_command_from_store;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_spike_file,
create_story_file, list_bug_files, load_upcoming_stories, update_story_in_file,
validate_story_dirs,
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
create_spike_file, create_story_file, list_bug_files, list_refactor_files,
load_pipeline_state, load_upcoming_stories, update_story_in_file, validate_story_dirs,
};
use crate::worktree;
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos, write_merge_failure};
@@ -19,6 +19,7 @@ use poem::web::Data;
use poem::{Body, Request, Response};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
@@ -638,7 +639,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "update_story",
"description": "Update the user story text and/or description of an existing story file. Replaces the content of the '## User Story' and/or '## Description' section in place. Auto-commits via the filesystem watcher.",
"description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, manual_qa). Auto-commits via the filesystem watcher.",
"inputSchema": {
"type": "object",
"properties": {
@@ -653,6 +654,17 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"description": {
"type": "string",
"description": "New description text to replace the '## Description' section content"
},
"agent": {
"type": "string",
"description": "Set or change the 'agent' YAML front matter field"
},
"front_matter": {
"type": "object",
"description": "Arbitrary YAML front matter key-value pairs to set or update",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["story_id"]
@@ -719,6 +731,37 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"properties": {}
}
},
{
"name": "create_refactor",
"description": "Create a refactor work item in work/1_upcoming/ with a deterministic filename and YAML front matter. Returns the refactor_id.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Short human-readable refactor name"
},
"description": {
"type": "string",
"description": "Optional description of the desired state after refactoring"
},
"acceptance_criteria": {
"type": "array",
"items": { "type": "string" },
"description": "Optional list of acceptance criteria"
}
},
"required": ["name"]
}
},
{
"name": "list_refactors",
"description": "List all open refactors in work/1_upcoming/ matching the _refactor_ naming convention.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "close_bug",
"description": "Archive a bug from work/2_current/ or work/1_upcoming/ to work/5_done/ and auto-commit to master.",
@@ -735,7 +778,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "merge_agent_work",
"description": "Trigger the mergemaster pipeline for a completed story: squash-merge the feature branch into master, run quality gates (cargo clippy, cargo test, pnpm build, pnpm test), move the story from work/4_merge/ or work/2_current/ to work/5_done/, and clean up the worktree and branch. Reports success/failure with details including any conflicts found and gate output.",
"description": "Start the mergemaster pipeline for a completed story as a background job. Returns immediately — poll get_merge_status(story_id) until the merge completes or fails. The pipeline squash-merges the feature branch into master, runs quality gates, moves the story to done, and cleans up.",
"inputSchema": {
"type": "object",
"properties": {
@@ -751,6 +794,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": ["story_id"]
}
},
{
"name": "get_merge_status",
"description": "Check the status of a merge_agent_work background job. Returns running/completed/failed. When completed, includes the full merge report with conflict details, gate output, and whether the story was archived.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (same as passed to merge_agent_work)"
}
},
"required": ["story_id"]
}
},
{
"name": "move_story_to_merge",
"description": "Move a story or bug from work/2_current/ to work/4_merge/ to queue it for the mergemaster pipeline and automatically spawn the mergemaster agent to squash-merge, run quality gates, and archive.",
@@ -805,6 +862,14 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": ["story_id"]
}
},
{
"name": "get_pipeline_status",
"description": "Return a structured snapshot of the full work item pipeline. Includes all active stages (current, qa, merge, done) with each item's stage, name, and assigned agent. Also includes upcoming backlog items.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "get_server_logs",
"description": "Return recent server log lines captured in the in-process ring buffer. Useful for diagnosing runtime behaviour such as WebSocket events, MCP call flow, and filesystem watcher activity.",
@@ -896,12 +961,18 @@ async fn handle_tools_call(
"create_bug" => tool_create_bug(&args, ctx),
"list_bugs" => tool_list_bugs(ctx),
"close_bug" => tool_close_bug(&args, ctx),
// Refactor lifecycle tools
"create_refactor" => tool_create_refactor(&args, ctx),
"list_refactors" => tool_list_refactors(ctx),
// Mergemaster tools
"merge_agent_work" => tool_merge_agent_work(&args, ctx).await,
"merge_agent_work" => tool_merge_agent_work(&args, ctx),
"get_merge_status" => tool_get_merge_status(&args, ctx),
"move_story_to_merge" => tool_move_story_to_merge(&args, ctx).await,
"report_merge_failure" => tool_report_merge_failure(&args, ctx),
// QA tools
"request_qa" => tool_request_qa(&args, ctx).await,
// Pipeline status
"get_pipeline_status" => tool_get_pipeline_status(ctx),
// Diagnostics
"get_server_logs" => tool_get_server_logs(&args),
// Permission bridge (Claude Code → frontend dialog)
@@ -983,6 +1054,47 @@ fn tool_list_upcoming(ctx: &AppContext) -> Result<String, String> {
.map_err(|e| format!("Serialization error: {e}"))
}
fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, String> {
let state = load_pipeline_state(ctx)?;
fn map_items(items: &[crate::http::workflow::UpcomingStory], stage: &str) -> Vec<Value> {
items
.iter()
.map(|s| {
json!({
"story_id": s.story_id,
"name": s.name,
"stage": stage,
"agent": s.agent.as_ref().map(|a| json!({
"agent_name": a.agent_name,
"model": a.model,
"status": a.status,
})),
})
})
.collect()
}
let mut active: Vec<Value> = Vec::new();
active.extend(map_items(&state.current, "current"));
active.extend(map_items(&state.qa, "qa"));
active.extend(map_items(&state.merge, "merge"));
active.extend(map_items(&state.done, "done"));
let upcoming: Vec<Value> = state
.upcoming
.iter()
.map(|s| json!({ "story_id": s.story_id, "name": s.name }))
.collect();
serde_json::to_string_pretty(&json!({
"active": active,
"upcoming": upcoming,
"upcoming_count": upcoming.len(),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
@@ -1495,8 +1607,24 @@ fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let user_story = args.get("user_story").and_then(|v| v.as_str());
let description = args.get("description").and_then(|v| v.as_str());
// Collect front matter fields: explicit `agent` param + arbitrary `front_matter` object.
let mut front_matter: HashMap<String, String> = HashMap::new();
if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
front_matter.insert("agent".to_string(), agent.to_string());
}
if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) {
for (k, v) in obj {
let val = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
front_matter.insert(k.clone(), val);
}
}
let front_matter_opt = if front_matter.is_empty() { None } else { Some(&front_matter) };
let root = ctx.state.get_project_root()?;
update_story_in_file(&root, story_id, user_story, description)?;
update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?;
Ok(format!("Updated story '{story_id}'."))
}
@@ -1582,56 +1710,116 @@ fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result<String, String> {
))
}
// ── Refactor lifecycle tool implementations ───────────────────────
fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result<String, String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: name")?;
let description = args.get("description").and_then(|v| v.as_str());
let acceptance_criteria: Option<Vec<String>> = args
.get("acceptance_criteria")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let root = ctx.state.get_project_root()?;
let refactor_id = create_refactor_file(
&root,
name,
description,
acceptance_criteria.as_deref(),
)?;
Ok(format!("Created refactor: {refactor_id}"))
}
fn tool_list_refactors(ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
let refactors = list_refactor_files(&root)?;
serde_json::to_string_pretty(&json!(refactors
.iter()
.map(|(id, name)| json!({ "refactor_id": id, "name": name }))
.collect::<Vec<_>>()))
.map_err(|e| format!("Serialization error: {e}"))
}
// ── Mergemaster tool implementations ─────────────────────────────
async fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String, String> {
fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let agent_name = args.get("agent_name").and_then(|v| v.as_str());
// TRACE:MERGE-DEBUG — remove once root cause is found
crate::slog!(
"[MERGE-DEBUG] tool_merge_agent_work called for story_id={:?}, agent_name={:?}",
story_id,
agent_name
);
let project_root = ctx.agents.get_project_root(&ctx.state)?;
crate::slog!(
"[MERGE-DEBUG] tool_merge_agent_work: project_root resolved to {:?}",
project_root
);
let report = ctx.agents.merge_agent_work(&project_root, story_id).await?;
let status_msg = if report.success && report.gates_passed && report.conflicts_resolved {
"Merge complete: conflicts were auto-resolved and all quality gates passed. Story moved to done and worktree cleaned up."
} else if report.success && report.gates_passed {
"Merge complete: all quality gates passed. Story moved to done and worktree cleaned up."
} else if report.had_conflicts && !report.conflicts_resolved {
"Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Call report_merge_failure with the conflict details so the human can resolve them. Do NOT manually move the story file or call accept_story."
} else if report.success && !report.gates_passed {
"Merge committed but quality gates failed. Review gate_output and fix issues before re-running."
} else {
"Merge failed. Review gate_output for details. Call report_merge_failure to record the failure. Do NOT manually move the story file or call accept_story."
};
ctx.agents.start_merge_agent_work(&project_root, story_id)?;
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"agent_name": agent_name,
"success": report.success,
"had_conflicts": report.had_conflicts,
"conflicts_resolved": report.conflicts_resolved,
"conflict_details": report.conflict_details,
"gates_passed": report.gates_passed,
"gate_output": report.gate_output,
"worktree_cleaned_up": report.worktree_cleaned_up,
"story_archived": report.story_archived,
"message": status_msg,
"status": "started",
"message": "Merge pipeline started. Poll get_merge_status(story_id) every 10-15 seconds until status is 'completed' or 'failed'."
}))
.map_err(|e| format!("Serialization error: {e}"))
}
fn tool_get_merge_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 job = ctx.agents.get_merge_status(story_id)
.ok_or_else(|| format!("No merge job found for story '{story_id}'. Call merge_agent_work first."))?;
match &job.status {
crate::agents::merge::MergeJobStatus::Running => {
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"status": "running",
"message": "Merge pipeline is still running. Poll again in 10-15 seconds."
}))
.map_err(|e| format!("Serialization error: {e}"))
}
crate::agents::merge::MergeJobStatus::Completed(report) => {
let status_msg = if report.success && report.gates_passed && report.conflicts_resolved {
"Merge complete: conflicts were auto-resolved and all quality gates passed. Story moved to done and worktree cleaned up."
} else if report.success && report.gates_passed {
"Merge complete: all quality gates passed. Story moved to done and worktree cleaned up."
} else if report.had_conflicts && !report.conflicts_resolved {
"Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Call report_merge_failure with the conflict details so the human can resolve them. Do NOT manually move the story file or call accept_story."
} else if report.success && !report.gates_passed {
"Merge committed but quality gates failed. Review gate_output and fix issues before re-running."
} else {
"Merge failed. Review gate_output for details. Call report_merge_failure to record the failure. Do NOT manually move the story file or call accept_story."
};
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"status": "completed",
"success": report.success,
"had_conflicts": report.had_conflicts,
"conflicts_resolved": report.conflicts_resolved,
"conflict_details": report.conflict_details,
"gates_passed": report.gates_passed,
"gate_output": report.gate_output,
"worktree_cleaned_up": report.worktree_cleaned_up,
"story_archived": report.story_archived,
"message": status_msg,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
crate::agents::merge::MergeJobStatus::Failed(err) => {
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"status": "failed",
"error": err,
"message": format!("Merge pipeline failed: {err}. Call report_merge_failure to record the failure.")
}))
.map_err(|e| format!("Serialization error: {e}"))
}
}
}
async fn tool_move_story_to_merge(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
@@ -1679,6 +1867,13 @@ fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Result<String, S
slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}");
ctx.agents.set_merge_failure_reported(story_id);
// Broadcast the failure so the Matrix notification listener can post an
// error message to configured rooms without coupling this tool to the bot.
let _ = ctx.watcher_tx.send(crate::io::watcher::WatcherEvent::MergeFailure {
story_id: story_id.to_string(),
reason: reason.to_string(),
});
// Persist the failure reason to the story file's front matter so it
// survives server restarts and is visible in the web UI.
if let Ok(project_root) = ctx.state.get_project_root() {
@@ -2077,13 +2272,17 @@ mod tests {
assert!(names.contains(&"create_bug"));
assert!(names.contains(&"list_bugs"));
assert!(names.contains(&"close_bug"));
assert!(names.contains(&"create_refactor"));
assert!(names.contains(&"list_refactors"));
assert!(names.contains(&"merge_agent_work"));
assert!(names.contains(&"get_merge_status"));
assert!(names.contains(&"move_story_to_merge"));
assert!(names.contains(&"report_merge_failure"));
assert!(names.contains(&"request_qa"));
assert!(names.contains(&"get_server_logs"));
assert!(names.contains(&"prompt_permission"));
assert_eq!(tools.len(), 31);
assert!(names.contains(&"get_pipeline_status"));
assert_eq!(tools.len(), 35);
}
#[test]
@@ -2150,6 +2349,81 @@ mod tests {
assert!(result.unwrap_err().contains("Missing required argument"));
}
#[test]
fn tool_get_pipeline_status_returns_structured_response() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (stage, id, name) in &[
("1_upcoming", "10_story_upcoming", "Upcoming Story"),
("2_current", "20_story_current", "Current Story"),
("3_qa", "30_story_qa", "QA Story"),
("4_merge", "40_story_merge", "Merge Story"),
("5_done", "50_story_done", "Done Story"),
] {
let dir = root.join(".story_kit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{id}.md")),
format!("---\nname: \"{name}\"\n---\n"),
)
.unwrap();
}
let ctx = test_ctx(root);
let result = tool_get_pipeline_status(&ctx).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
// Active stages include current, qa, merge, done
let active = parsed["active"].as_array().unwrap();
assert_eq!(active.len(), 4);
let stages: Vec<&str> = active.iter().map(|i| i["stage"].as_str().unwrap()).collect();
assert!(stages.contains(&"current"));
assert!(stages.contains(&"qa"));
assert!(stages.contains(&"merge"));
assert!(stages.contains(&"done"));
// Upcoming backlog
let upcoming = parsed["upcoming"].as_array().unwrap();
assert_eq!(upcoming.len(), 1);
assert_eq!(upcoming[0]["story_id"], "10_story_upcoming");
assert_eq!(parsed["upcoming_count"], 1);
}
#[test]
fn tool_get_pipeline_status_includes_agent_assignment() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
std::fs::create_dir_all(&current).unwrap();
std::fs::write(
current.join("20_story_active.md"),
"---\nname: \"Active Story\"\n---\n",
)
.unwrap();
let ctx = test_ctx(root);
ctx.agents.inject_test_agent(
"20_story_active",
"coder-1",
crate::agents::AgentStatus::Running,
);
let result = tool_get_pipeline_status(&ctx).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let active = parsed["active"].as_array().unwrap();
assert_eq!(active.len(), 1);
let item = &active[0];
assert_eq!(item["story_id"], "20_story_active");
assert_eq!(item["stage"], "current");
assert!(!item["agent"].is_null(), "agent should be present");
assert_eq!(item["agent"]["agent_name"], "coder-1");
assert_eq!(item["agent"]["status"], "running");
}
#[test]
fn tool_get_story_todos_missing_file() {
let tmp = tempfile::tempdir().unwrap();
@@ -2718,11 +2992,11 @@ mod tests {
assert!(!req_names.contains(&"agent_name"));
}
#[tokio::test]
async fn tool_merge_agent_work_missing_story_id() {
#[test]
fn tool_merge_agent_work_missing_story_id() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_merge_agent_work(&json!({}), &ctx).await;
let result = tool_merge_agent_work(&json!({}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("story_id"));
}
@@ -2769,28 +3043,54 @@ mod tests {
}
#[tokio::test]
async fn tool_merge_agent_work_returns_coherent_report() {
async fn tool_merge_agent_work_returns_started() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo_in(tmp.path());
let ctx = test_ctx(tmp.path());
// Try to merge a non-existent branch — should return a report (not panic)
let result = tool_merge_agent_work(
&json!({"story_id": "99_nonexistent", "agent_name": "coder-1"}),
&ctx,
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["story_id"], "99_nonexistent");
assert_eq!(parsed["agent_name"], "coder-1");
assert!(parsed.get("success").is_some());
assert!(parsed.get("had_conflicts").is_some());
assert!(parsed.get("gates_passed").is_some());
assert!(parsed.get("gate_output").is_some());
assert_eq!(parsed["status"], "started");
assert!(parsed.get("message").is_some());
}
#[test]
fn tool_get_merge_status_no_job() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_get_merge_status(&json!({"story_id": "99_nonexistent"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No merge job"));
}
#[tokio::test]
async fn tool_get_merge_status_returns_running() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo_in(tmp.path());
let ctx = test_ctx(tmp.path());
// Start a merge (it will run in background)
tool_merge_agent_work(
&json!({"story_id": "99_nonexistent"}),
&ctx,
)
.unwrap();
// Immediately check — should be running (or already finished if very fast)
let result = tool_get_merge_status(&json!({"story_id": "99_nonexistent"}), &ctx).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let status = parsed["status"].as_str().unwrap();
assert!(
status == "running" || status == "completed" || status == "failed",
"unexpected status: {status}"
);
}
// ── report_merge_failure tool tests ─────────────────────────────
#[test]

View File

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

View File

@@ -1,6 +1,6 @@
use crate::agents::AgentStatus;
use crate::http::context::AppContext;
use crate::io::story_metadata::{parse_front_matter, write_coverage_baseline};
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field, write_coverage_baseline};
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
use serde::Serialize;
use std::collections::HashMap;
@@ -338,6 +338,73 @@ pub fn create_spike_file(
Ok(spike_id)
}
/// Create a refactor work item file in `work/1_upcoming/`.
///
/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`).
pub fn create_refactor_file(
root: &Path,
name: &str,
description: Option<&str>,
acceptance_criteria: Option<&[String]>,
) -> Result<String, String> {
let refactor_number = next_item_number(root)?;
let slug = slugify_name(name);
if slug.is_empty() {
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let filename = format!("{refactor_number}_refactor_{slug}.md");
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
fs::create_dir_all(&upcoming_dir)
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
let filepath = upcoming_dir.join(&filename);
if filepath.exists() {
return Err(format!("Refactor file already exists: {filename}"));
}
let refactor_id = filepath
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let mut content = String::new();
content.push_str("---\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
content.push_str("---\n\n");
content.push_str(&format!("# Refactor {refactor_number}: {name}\n\n"));
content.push_str("## Current State\n\n");
content.push_str("- TBD\n\n");
content.push_str("## Desired State\n\n");
if let Some(desc) = description {
content.push_str(desc);
content.push('\n');
} else {
content.push_str("- TBD\n");
}
content.push('\n');
content.push_str("## Acceptance Criteria\n\n");
if let Some(criteria) = acceptance_criteria {
for criterion in criteria {
content.push_str(&format!("- [ ] {criterion}\n"));
}
} else {
content.push_str("- [ ] Refactoring complete and all tests pass\n");
}
content.push('\n');
content.push_str("## Out of Scope\n\n");
content.push_str("- TBD\n");
fs::write(&filepath, &content)
.map_err(|e| format!("Failed to write refactor file: {e}"))?;
// Watcher handles the git commit asynchronously.
Ok(refactor_id)
}
/// Returns true if the item stem (filename without extension) is a bug item.
/// Bug items follow the pattern: {N}_bug_{slug}
fn is_bug_item(stem: &str) -> bool {
@@ -403,6 +470,59 @@ pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
Ok(bugs)
}
/// Returns true if the item stem (filename without extension) is a refactor item.
/// Refactor items follow the pattern: {N}_refactor_{slug}
fn is_refactor_item(stem: &str) -> bool {
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
after_num.starts_with("_refactor_")
}
/// List all open refactors — files in `work/1_upcoming/` matching the `_refactor_` naming pattern.
///
/// Returns a sorted list of `(refactor_id, name)` pairs.
pub fn list_refactor_files(root: &Path) -> Result<Vec<(String, String)>, String> {
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
if !upcoming_dir.exists() {
return Ok(Vec::new());
}
let mut refactors = Vec::new();
for entry in fs::read_dir(&upcoming_dir)
.map_err(|e| format!("Failed to read upcoming directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
let path = entry.path();
if path.is_dir() {
continue;
}
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| "Invalid file name.".to_string())?;
if !is_refactor_item(stem) {
continue;
}
let refactor_id = stem.to_string();
let name = fs::read_to_string(&path)
.ok()
.and_then(|contents| parse_front_matter(&contents).ok())
.and_then(|m| m.name)
.unwrap_or_else(|| refactor_id.clone());
refactors.push((refactor_id, name));
}
refactors.sort_by(|a, b| a.0.cmp(&b.0));
Ok(refactors)
}
/// Locate a work item file by searching all active pipeline stages.
///
/// Searches in priority order: 2_current, 1_upcoming, 3_qa, 4_merge, 5_done, 6_archived.
@@ -586,10 +706,13 @@ pub fn update_story_in_file(
story_id: &str,
user_story: Option<&str>,
description: Option<&str>,
front_matter: Option<&HashMap<String, String>>,
) -> Result<(), String> {
if user_story.is_none() && description.is_none() {
let has_front_matter_updates = front_matter.map(|m| !m.is_empty()).unwrap_or(false);
if user_story.is_none() && description.is_none() && !has_front_matter_updates {
return Err(
"At least one of 'user_story' or 'description' must be provided.".to_string(),
"At least one of 'user_story', 'description', or 'front_matter' must be provided."
.to_string(),
);
}
@@ -597,6 +720,13 @@ pub fn update_story_in_file(
let mut contents = fs::read_to_string(&filepath)
.map_err(|e| format!("Failed to read story file: {e}"))?;
if let Some(fields) = front_matter {
for (key, value) in fields {
let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', ""));
contents = set_front_matter_field(&contents, key, &yaml_value);
}
}
if let Some(us) = user_story {
contents = replace_section_content(&contents, "User Story", us)?;
}
@@ -1477,7 +1607,7 @@ mod tests {
let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n";
fs::write(&filepath, content).unwrap();
update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None).unwrap();
update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None, None).unwrap();
let result = fs::read_to_string(&filepath).unwrap();
assert!(result.contains("New user story text"), "new text should be present");
@@ -1494,7 +1624,7 @@ mod tests {
let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n";
fs::write(&filepath, content).unwrap();
update_story_in_file(tmp.path(), "21_test", None, Some("New description")).unwrap();
update_story_in_file(tmp.path(), "21_test", None, Some("New description"), None).unwrap();
let result = fs::read_to_string(&filepath).unwrap();
assert!(result.contains("New description"), "new description present");
@@ -1508,7 +1638,7 @@ mod tests {
fs::create_dir_all(&current).unwrap();
fs::write(current.join("22_test.md"), "---\nname: T\n---\n").unwrap();
let result = update_story_in_file(tmp.path(), "22_test", None, None);
let result = update_story_in_file(tmp.path(), "22_test", None, None, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("At least one"));
}
@@ -1524,11 +1654,65 @@ mod tests {
)
.unwrap();
let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None);
let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("User Story"));
}
#[test]
fn update_story_sets_agent_front_matter_field() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("24_test.md");
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
let mut fields = HashMap::new();
fields.insert("agent".to_string(), "dev".to_string());
update_story_in_file(tmp.path(), "24_test", None, None, Some(&fields)).unwrap();
let result = fs::read_to_string(&filepath).unwrap();
assert!(result.contains("agent: \"dev\""), "agent field should be set");
assert!(result.contains("name: T"), "name field preserved");
}
#[test]
fn update_story_sets_arbitrary_front_matter_fields() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("25_test.md");
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
let mut fields = HashMap::new();
fields.insert("manual_qa".to_string(), "true".to_string());
fields.insert("priority".to_string(), "high".to_string());
update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap();
let result = fs::read_to_string(&filepath).unwrap();
assert!(result.contains("manual_qa: \"true\""), "manual_qa field should be set");
assert!(result.contains("priority: \"high\""), "priority field should be set");
assert!(result.contains("name: T"), "name field preserved");
}
#[test]
fn update_story_front_matter_only_no_section_required() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
// File without a User Story section — front matter update should succeed
let filepath = current.join("26_test.md");
fs::write(&filepath, "---\nname: T\n---\n\nNo sections here.\n").unwrap();
let mut fields = HashMap::new();
fields.insert("agent".to_string(), "dev".to_string());
let result = update_story_in_file(tmp.path(), "26_test", None, None, Some(&fields));
assert!(result.is_ok(), "front-matter-only update should not require body sections");
let contents = fs::read_to_string(&filepath).unwrap();
assert!(contents.contains("agent: \"dev\""));
}
// ── Bug file helper tests ──────────────────────────────────────────────────
#[test]

View File

@@ -35,6 +35,14 @@ enum WsRequest {
/// Heartbeat ping from the client. The server responds with `Pong` so the
/// client can detect stale (half-closed) connections.
Ping,
/// A quick side question answered from current conversation context.
/// The question and response are NOT added to the conversation history
/// and no tool calls are made.
SideQuestion {
question: String,
context_messages: Vec<Message>,
config: chat::ProviderConfig,
},
}
#[derive(Serialize)]
@@ -116,6 +124,14 @@ enum WsResponse {
OnboardingStatus {
needs_onboarding: bool,
},
/// Streaming token from a `/btw` side question response.
SideQuestionToken {
content: String,
},
/// Final signal that the `/btw` side question has been fully answered.
SideQuestionDone {
response: String,
},
}
impl From<WatcherEvent> for Option<WsResponse> {
@@ -134,6 +150,9 @@ 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.
WatcherEvent::MergeFailure { .. } => None,
}
}
}
@@ -344,6 +363,33 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
Ok(WsRequest::Ping) => {
let _ = tx.send(WsResponse::Pong);
}
Ok(WsRequest::SideQuestion { question, context_messages, config }) => {
let tx_side = tx.clone();
let store = ctx.store.clone();
tokio::spawn(async move {
let result = chat::side_question(
context_messages,
question,
config,
store.as_ref(),
|token| {
let _ = tx_side.send(WsResponse::SideQuestionToken {
content: token.to_string(),
});
},
).await;
match result {
Ok(response) => {
let _ = tx_side.send(WsResponse::SideQuestionDone { response });
}
Err(err) => {
let _ = tx_side.send(WsResponse::SideQuestionDone {
response: format!("Error: {err}"),
});
}
}
});
}
_ => {}
}
}
@@ -370,6 +416,39 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
Ok(WsRequest::PermissionResponse { .. }) => {
// Permission responses outside an active chat are ignored.
}
Ok(WsRequest::SideQuestion {
question,
context_messages,
config,
}) => {
let tx_side = tx.clone();
let store = ctx.store.clone();
tokio::spawn(async move {
let result = chat::side_question(
context_messages,
question,
config,
store.as_ref(),
|token| {
let _ = tx_side.send(WsResponse::SideQuestionToken {
content: token.to_string(),
});
},
)
.await;
match result {
Ok(response) => {
let _ = tx_side
.send(WsResponse::SideQuestionDone { response });
}
Err(err) => {
let _ = tx_side.send(WsResponse::SideQuestionDone {
response: format!("Error: {err}"),
});
}
}
});
}
Err(err) => {
let _ = tx.send(WsResponse::Error {
message: format!("Invalid request: {err}"),

View File

@@ -1,6 +1,5 @@
use crate::state::SessionState;
use crate::store::StoreOps;
use crate::worktree::write_mcp_json as worktree_write_mcp_json;
use serde::Serialize;
use serde_json::json;
use std::fs;
@@ -127,8 +126,8 @@ role = "Merges completed work into master, runs quality gates, and archives stor
model = "sonnet"
max_turns = 30
max_budget_usd = 5.00
prompt = "You are the mergemaster agent for story {{story_id}}. Call merge_agent_work(story_id='{{story_id}}') via the MCP tool to trigger the full merge pipeline. Report the result to the human. If the merge fails, call report_merge_failure."
system_prompt = "You are the mergemaster agent. Trigger merge_agent_work via MCP and report results. Never manually move story files. Call report_merge_failure when merges fail."
prompt = "You are the mergemaster agent for story {{story_id}}. Call merge_agent_work(story_id='{{story_id}}') to start the merge pipeline. Then poll get_merge_status(story_id='{{story_id}}') every 15 seconds until the status is 'completed' or 'failed'. Report the final result. If the merge fails, call report_merge_failure."
system_prompt = "You are the mergemaster agent. Call merge_agent_work to start the merge, then poll get_merge_status every 15 seconds until done. Never manually move story files. Call report_merge_failure when merges fail."
"#;
/// Detect the tech stack from the project root and return TOML `[[component]]` entries.
@@ -313,17 +312,61 @@ fn write_script_if_missing(path: &Path, content: &str) -> Result<(), String> {
Ok(())
}
/// Append Story Kit entries to `.gitignore` (or create one if missing).
/// Does not duplicate entries already present.
fn append_gitignore_entries(root: &Path) -> Result<(), String> {
/// Write (or idempotently update) `.story_kit/.gitignore` with Story Kitspecific
/// ignore patterns for files that live inside the `.story_kit/` directory.
/// Patterns are relative to `.story_kit/` as git resolves `.gitignore` files
/// relative to the directory that contains them.
fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
// Entries that belong inside .story_kit/.gitignore (relative to .story_kit/).
let entries = [
".story_kit/worktrees/",
".story_kit/merge_workspace/",
".story_kit/coverage/",
".story_kit_port",
"store.json",
"bot.toml",
"matrix_store/",
"matrix_device_id",
"worktrees/",
"merge_workspace/",
"coverage/",
];
let gitignore_path = root.join(".story_kit").join(".gitignore");
let existing = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path)
.map_err(|e| format!("Failed to read .story_kit/.gitignore: {}", e))?
} else {
String::new()
};
let missing: Vec<&str> = entries
.iter()
.copied()
.filter(|e| !existing.lines().any(|l| l.trim() == *e))
.collect();
if missing.is_empty() {
return Ok(());
}
let mut new_content = existing;
if !new_content.is_empty() && !new_content.ends_with('\n') {
new_content.push('\n');
}
for entry in missing {
new_content.push_str(entry);
new_content.push('\n');
}
fs::write(&gitignore_path, new_content)
.map_err(|e| format!("Failed to write .story_kit/.gitignore: {}", e))?;
Ok(())
}
/// Append root-level Story Kit entries to the project `.gitignore`.
/// Only `store.json` and `.story_kit_port` remain here because they live at
/// the project root and git does not support `../` patterns in `.gitignore`
/// files, so they cannot be expressed in `.story_kit/.gitignore`.
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
let entries = [".story_kit_port", "store.json"];
let gitignore_path = root.join(".gitignore");
let existing = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path)
@@ -402,7 +445,8 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
.map_err(|e| format!("Failed to create .claude/ directory: {}", e))?;
write_file_if_missing(&claude_dir.join("settings.json"), STORY_KIT_CLAUDE_SETTINGS)?;
append_gitignore_entries(root)?;
write_story_kit_gitignore(root)?;
append_root_gitignore_entries(root)?;
// Run `git init` if the directory is not already a git repo, then make an initial commit
if !root.join(".git").exists() {
@@ -470,17 +514,12 @@ 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?;
validate_project_path(p.clone()).await?;
// Write .mcp.json so that claude-code can connect to the MCP server.
// Best-effort: failure should not prevent the project from opening.
let _ = worktree_write_mcp_json(&p, port);
{
// TRACE:MERGE-DEBUG — remove once root cause is found
crate::slog!("[MERGE-DEBUG] open_project: setting project_root to {:?}", p);
@@ -682,6 +721,42 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
.map_err(|e| format!("Task failed: {}", e))?
}
/// List all files in the project recursively, respecting .gitignore.
/// Returns relative paths from the project root (files only, not directories).
pub async fn list_project_files(state: &SessionState) -> Result<Vec<String>, String> {
let root = state.get_project_root()?;
list_project_files_impl(root).await
}
pub async fn list_project_files_impl(root: PathBuf) -> Result<Vec<String>, String> {
use ignore::WalkBuilder;
let root_clone = root.clone();
let files = tokio::task::spawn_blocking(move || {
let mut result = Vec::new();
let walker = WalkBuilder::new(&root_clone).git_ignore(true).build();
for entry in walker.flatten() {
if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
let relative = entry
.path()
.strip_prefix(&root_clone)
.unwrap_or(entry.path())
.to_string_lossy()
.to_string();
result.push(relative);
}
}
result.sort();
result
})
.await
.map_err(|e| format!("Task failed: {e}"))?;
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -732,7 +807,6 @@ mod tests {
project_dir.to_string_lossy().to_string(),
&state,
&store,
3001,
)
.await;
@@ -742,7 +816,11 @@ mod tests {
}
#[tokio::test]
async fn open_project_writes_mcp_json_to_project_root() {
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.
let dir = tempdir().unwrap();
let project_dir = dir.path().join("myproject");
fs::create_dir_all(&project_dir).unwrap();
@@ -753,17 +831,14 @@ mod tests {
project_dir.to_string_lossy().to_string(),
&state,
&store,
4242,
)
.await
.unwrap();
let mcp_path = project_dir.join(".mcp.json");
assert!(mcp_path.exists(), ".mcp.json should be written to project root");
let content = fs::read_to_string(&mcp_path).unwrap();
assert!(
content.contains("http://localhost:4242/mcp"),
".mcp.json should contain the correct port"
!mcp_path.exists(),
"open_project must not write .mcp.json — that would overwrite the root with the wrong port"
);
}
@@ -823,7 +898,6 @@ mod tests {
project_dir.to_string_lossy().to_string(),
&state,
&store,
3001,
)
.await
.unwrap();
@@ -1122,12 +1196,17 @@ mod tests {
toml_content
);
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let count = gitignore
let story_kit_gitignore =
fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap();
let count = story_kit_gitignore
.lines()
.filter(|l| l.trim() == ".story_kit/worktrees/")
.filter(|l| l.trim() == "worktrees/")
.count();
assert_eq!(count, 1, ".gitignore should not have duplicate entries");
assert_eq!(
count,
1,
".story_kit/.gitignore should not have duplicate entries"
);
}
#[test]
@@ -1173,53 +1252,56 @@ mod tests {
}
#[test]
fn scaffold_creates_gitignore_with_story_kit_entries() {
fn scaffold_creates_story_kit_gitignore_with_relative_entries() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(content.contains(".story_kit/worktrees/"));
assert!(content.contains(".story_kit/merge_workspace/"));
assert!(content.contains(".story_kit/coverage/"));
assert!(content.contains(".story_kit_port"));
assert!(content.contains("store.json"));
// .story_kit/.gitignore must contain relative patterns for files under .story_kit/
let sk_content =
fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap();
assert!(sk_content.contains("worktrees/"));
assert!(sk_content.contains("merge_workspace/"));
assert!(sk_content.contains("coverage/"));
// Must NOT contain absolute .story_kit/ prefixed paths
assert!(!sk_content.contains(".story_kit/"));
// Root .gitignore must contain root-level story-kit entries
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(root_content.contains(".story_kit_port"));
assert!(root_content.contains("store.json"));
// Root .gitignore must NOT contain .story_kit/ sub-directory patterns
assert!(!root_content.contains(".story_kit/worktrees/"));
assert!(!root_content.contains(".story_kit/merge_workspace/"));
assert!(!root_content.contains(".story_kit/coverage/"));
}
#[test]
fn scaffold_gitignore_does_not_duplicate_existing_entries() {
fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries() {
let dir = tempdir().unwrap();
// Pre-create .gitignore with some Story Kit entries already present
// Pre-create .story_kit dir and .gitignore with some entries already present
fs::create_dir_all(dir.path().join(".story_kit")).unwrap();
fs::write(
dir.path().join(".gitignore"),
".story_kit/worktrees/\n.story_kit/coverage/\n",
dir.path().join(".story_kit/.gitignore"),
"worktrees/\ncoverage/\n",
)
.unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let content =
fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap();
let worktrees_count = content
.lines()
.filter(|l| l.trim() == ".story_kit/worktrees/")
.filter(|l| l.trim() == "worktrees/")
.count();
assert_eq!(
worktrees_count,
1,
".story_kit/worktrees/ should not be duplicated"
);
assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated");
let coverage_count = content
.lines()
.filter(|l| l.trim() == ".story_kit/coverage/")
.filter(|l| l.trim() == "coverage/")
.count();
assert_eq!(
coverage_count,
1,
".story_kit/coverage/ should not be duplicated"
);
// The missing entries must have been added
assert!(content.contains(".story_kit/merge_workspace/"));
assert!(content.contains(".story_kit_port"));
assert!(content.contains("store.json"));
assert_eq!(coverage_count, 1, "coverage/ should not be duplicated");
// The missing entry must have been added
assert!(content.contains("merge_workspace/"));
}
// --- CLAUDE.md scaffold ---
@@ -1276,7 +1358,6 @@ mod tests {
project_dir.to_string_lossy().to_string(),
&state,
&store,
0,
)
.await
.unwrap();
@@ -1300,7 +1381,6 @@ mod tests {
project_dir.to_string_lossy().to_string(),
&state,
&store,
0,
)
.await
.unwrap();
@@ -1535,4 +1615,68 @@ mod tests {
"scaffold should not overwrite existing project.toml"
);
}
// --- list_project_files_impl ---
#[tokio::test]
async fn list_project_files_returns_all_files() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
fs::write(dir.path().join("README.md"), "# readme").unwrap();
let files = list_project_files_impl(dir.path().to_path_buf())
.await
.unwrap();
assert!(files.contains(&"README.md".to_string()));
assert!(files.contains(&"src/main.rs".to_string()));
}
#[tokio::test]
async fn list_project_files_excludes_dirs_from_output() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join("subdir")).unwrap();
fs::write(dir.path().join("file.txt"), "").unwrap();
let files = list_project_files_impl(dir.path().to_path_buf())
.await
.unwrap();
assert!(files.contains(&"file.txt".to_string()));
assert!(!files.iter().any(|f| f == "subdir"));
}
#[tokio::test]
async fn list_project_files_returns_sorted() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("z.txt"), "").unwrap();
fs::write(dir.path().join("a.txt"), "").unwrap();
let files = list_project_files_impl(dir.path().to_path_buf())
.await
.unwrap();
let a_idx = files.iter().position(|f| f == "a.txt").unwrap();
let z_idx = files.iter().position(|f| f == "z.txt").unwrap();
assert!(a_idx < z_idx);
}
#[tokio::test]
async fn list_project_files_with_state() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("hello.rs"), "").unwrap();
let state = make_state_with_root(dir.path().to_path_buf());
let files = list_project_files(&state).await.unwrap();
assert!(files.contains(&"hello.rs".to_string()));
}
#[tokio::test]
async fn list_project_files_errors_without_project() {
let state = SessionState::default();
let result = list_project_files(&state).await;
assert!(result.is_err());
}
}

View File

@@ -7,6 +7,8 @@ pub struct StoryMetadata {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -29,6 +31,8 @@ struct FrontMatter {
name: Option<String>,
coverage_baseline: Option<String>,
merge_failure: Option<String>,
agent: Option<String>,
review_hold: Option<bool>,
}
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
@@ -61,6 +65,8 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
name: front.name,
coverage_baseline: front.coverage_baseline,
merge_failure: front.merge_failure,
agent: front.agent,
review_hold: front.review_hold,
}
}
@@ -95,10 +101,67 @@ pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> {
Ok(())
}
/// Write `review_hold: true` to the YAML front matter of a story file.
///
/// Used to mark spikes that have passed QA and are waiting for human review.
pub fn write_review_hold(path: &Path) -> Result<(), String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
let updated = set_front_matter_field(&contents, "review_hold", "true");
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
Ok(())
}
/// Remove a key from the YAML front matter of a story file on disk.
///
/// If front matter is present and contains the key, the line is removed.
/// If no front matter or key is not found, the file is left unchanged.
pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
let updated = remove_front_matter_field(&contents, key);
if updated != contents {
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
}
Ok(())
}
/// Remove a key: value line from the YAML front matter of a markdown string.
///
/// If no front matter (opening `---`) is found or the key is absent, returns content unchanged.
fn remove_front_matter_field(contents: &str, key: &str) -> String {
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
if lines.is_empty() || lines[0].trim() != "---" {
return contents.to_string();
}
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
Some(i) => i + 1,
None => return contents.to_string(),
};
let key_prefix = format!("{key}:");
if let Some(idx) = lines[1..close_idx]
.iter()
.position(|l| l.trim_start().starts_with(&key_prefix))
.map(|i| i + 1)
{
lines.remove(idx);
} else {
return contents.to_string();
}
let mut result = lines.join("\n");
if contents.ends_with('\n') {
result.push('\n');
}
result
}
/// Insert or update a key: value pair in the YAML front matter of a markdown string.
///
/// If no front matter (opening `---`) is found, returns the content unchanged.
fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
if lines.is_empty() || lines[0].trim() != "---" {
return contents.to_string();
@@ -219,6 +282,40 @@ workflow: tdd
));
}
#[test]
fn remove_front_matter_field_removes_key() {
let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n";
let output = remove_front_matter_field(input, "merge_failure");
assert!(!output.contains("merge_failure"));
assert!(output.contains("name: My Story"));
assert!(output.ends_with('\n'));
}
#[test]
fn remove_front_matter_field_no_op_when_absent() {
let input = "---\nname: My Story\n---\n# Body\n";
let output = remove_front_matter_field(input, "merge_failure");
assert_eq!(output, input);
}
#[test]
fn remove_front_matter_field_no_op_without_front_matter() {
let input = "# No front matter\n";
let output = remove_front_matter_field(input, "merge_failure");
assert_eq!(output, input);
}
#[test]
fn clear_front_matter_field_updates_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n").unwrap();
clear_front_matter_field(&path, "merge_failure").unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(!contents.contains("merge_failure"));
assert!(contents.contains("name: Test"));
}
#[test]
fn parse_unchecked_todos_mixed() {
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
@@ -245,4 +342,29 @@ workflow: tdd
let input = " - [ ] Indented item\n";
assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]);
}
#[test]
fn parses_review_hold_from_front_matter() {
let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.review_hold, Some(true));
}
#[test]
fn review_hold_defaults_to_none() {
let input = "---\nname: Story\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.review_hold, None);
}
#[test]
fn write_review_hold_sets_field() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("spike.md");
std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap();
write_review_hold(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("review_hold: true"));
assert!(contents.contains("name: My Spike"));
}
}

View File

@@ -20,6 +20,7 @@
//! the event so connected clients stay in sync.
use crate::config::{ProjectConfig, WatcherConfig};
use crate::io::story_metadata::clear_front_matter_field;
use crate::slog;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher};
use serde::Serialize;
@@ -50,6 +51,14 @@ pub enum WatcherEvent {
/// Triggers a pipeline state refresh so the frontend can update agent
/// assignments without waiting for a filesystem event.
AgentStateChanged,
/// A story encountered a failure (e.g. merge failure).
/// Triggers an error notification to configured Matrix rooms.
MergeFailure {
/// Work item ID (e.g. `"42_story_my_feature"`).
story_id: String,
/// Human-readable description of the failure.
reason: String,
},
}
/// Return `true` if `path` is the root-level `.story_kit/project.toml`, i.e.
@@ -146,11 +155,25 @@ fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, Strin
Err(format!("git commit failed: {stderr}"))
}
/// Stages that represent meaningful git checkpoints (creation and archival).
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
/// that don't need to be committed — they're only relevant while the server is
/// running and are broadcast to WebSocket clients for real-time UI updates.
const COMMIT_WORTHY_STAGES: &[&str] = &["1_upcoming", "5_done", "6_archived"];
/// Return `true` if changes in `stage` should be committed to git.
fn should_commit_stage(stage: &str) -> bool {
COMMIT_WORTHY_STAGES.contains(&stage)
}
/// Process a batch of pending (path → stage) entries: commit and broadcast.
///
/// Only files that still exist on disk are used to derive the commit message
/// (they represent the destination of a move or a new file). Deletions are
/// captured by `git add -A .story_kit/work/` automatically.
///
/// Only terminal stages (`1_upcoming` and `6_archived`) trigger git commits.
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
fn flush_pending(
pending: &HashMap<PathBuf, String>,
git_root: &Path,
@@ -182,27 +205,46 @@ fn flush_pending(
("remove", item.to_string(), format!("story-kit: remove {item}"))
};
slog!("[watcher] flush: {commit_msg}");
match git_add_work_and_commit(git_root, &commit_msg) {
Ok(committed) => {
if committed {
slog!("[watcher] committed: {commit_msg}");
} else {
slog!("[watcher] skipped (already committed): {commit_msg}");
}
let stage = additions.first().map_or("unknown", |(_, s)| s);
let evt = WatcherEvent::WorkItem {
stage: stage.to_string(),
item_id,
action: action.to_string(),
commit_msg,
};
let _ = event_tx.send(evt);
}
Err(e) => {
slog!("[watcher] git error: {e}");
// Strip stale merge_failure front matter from any story that has left 4_merge/.
for (path, stage) in &additions {
if *stage != "4_merge"
&& let Err(e) = clear_front_matter_field(path, "merge_failure")
{
slog!("[watcher] Warning: could not clear merge_failure from {}: {e}", path.display());
}
}
// Only commit for terminal stages; intermediate moves are broadcast-only.
let dest_stage = additions.first().map_or("unknown", |(_, s)| *s);
let should_commit = should_commit_stage(dest_stage);
if should_commit {
slog!("[watcher] flush: {commit_msg}");
match git_add_work_and_commit(git_root, &commit_msg) {
Ok(committed) => {
if committed {
slog!("[watcher] committed: {commit_msg}");
} else {
slog!("[watcher] skipped (already committed): {commit_msg}");
}
}
Err(e) => {
slog!("[watcher] git error: {e}");
return;
}
}
} else {
slog!("[watcher] flush (broadcast-only): {commit_msg}");
}
// Always broadcast the event so connected WebSocket clients stay in sync.
let evt = WatcherEvent::WorkItem {
stage: dest_stage.to_string(),
item_id,
action: action.to_string(),
commit_msg,
};
let _ = event_tx.send(evt);
}
/// Scan `work/5_done/` and move any `.md` files whose mtime is older than
@@ -529,7 +571,50 @@ mod tests {
// ── flush_pending ─────────────────────────────────────────────────────────
#[test]
fn flush_pending_commits_and_broadcasts_work_item_for_addition() {
fn flush_pending_commits_and_broadcasts_for_terminal_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "1_upcoming");
let story_path = stage_dir.join("42_story_foo.md");
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path, "1_upcoming".to_string());
flush_pending(&pending, tmp.path(), &tx);
let evt = rx.try_recv().expect("expected a broadcast event");
match evt {
WatcherEvent::WorkItem {
stage,
item_id,
action,
commit_msg,
} => {
assert_eq!(stage, "1_upcoming");
assert_eq!(item_id, "42_story_foo");
assert_eq!(action, "create");
assert_eq!(commit_msg, "story-kit: create 42_story_foo");
}
other => panic!("unexpected event: {other:?}"),
}
// Verify the file was actually committed.
let log = std::process::Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(tmp.path())
.output()
.expect("git log");
let log_msg = String::from_utf8_lossy(&log.stdout);
assert!(
log_msg.contains("story-kit: create 42_story_foo"),
"terminal stage should produce a git commit"
);
}
#[test]
fn flush_pending_broadcasts_without_commit_for_intermediate_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
@@ -542,6 +627,7 @@ mod tests {
flush_pending(&pending, tmp.path(), &tx);
// Event should still be broadcast for frontend sync.
let evt = rx.try_recv().expect("expected a broadcast event");
match evt {
WatcherEvent::WorkItem {
@@ -557,6 +643,18 @@ mod tests {
}
other => panic!("unexpected event: {other:?}"),
}
// Verify NO git commit was made (only the initial empty commit should exist).
let log = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(tmp.path())
.output()
.expect("git log");
let log_msg = String::from_utf8_lossy(&log.stdout);
assert!(
!log_msg.contains("story-kit:"),
"intermediate stage should NOT produce a git commit"
);
}
#[test]
@@ -582,6 +680,7 @@ mod tests {
flush_pending(&pending, tmp.path(), &tx);
// All stages should broadcast events regardless of commit behavior.
let evt = rx.try_recv().expect("expected broadcast for stage {stage}");
match evt {
WatcherEvent::WorkItem {
@@ -664,6 +763,128 @@ mod tests {
assert!(rx.try_recv().is_err(), "no event for empty pending map");
}
// ── flush_pending clears merge_failure ─────────────────────────────────────
#[test]
fn flush_pending_clears_merge_failure_when_leaving_merge_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
let story_path = stage_dir.join("50_story_retry.md");
fs::write(
&story_path,
"---\nname: Retry Story\nmerge_failure: \"conflicts detected\"\n---\n# Story\n",
)
.unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "2_current".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
!contents.contains("merge_failure"),
"merge_failure should be stripped when story lands in 2_current"
);
assert!(contents.contains("name: Retry Story"));
}
#[test]
fn flush_pending_clears_merge_failure_when_moving_to_upcoming() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "1_upcoming");
let story_path = stage_dir.join("51_story_reset.md");
fs::write(
&story_path,
"---\nname: Reset Story\nmerge_failure: \"gate failed\"\n---\n# Story\n",
)
.unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "1_upcoming".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
!contents.contains("merge_failure"),
"merge_failure should be stripped when story lands in 1_upcoming"
);
}
#[test]
fn flush_pending_clears_merge_failure_when_moving_to_done() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "5_done");
let story_path = stage_dir.join("52_story_done.md");
fs::write(
&story_path,
"---\nname: Done Story\nmerge_failure: \"stale error\"\n---\n# Story\n",
)
.unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "5_done".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
!contents.contains("merge_failure"),
"merge_failure should be stripped when story lands in 5_done"
);
}
#[test]
fn flush_pending_preserves_merge_failure_when_in_merge_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "4_merge");
let story_path = stage_dir.join("53_story_merging.md");
fs::write(
&story_path,
"---\nname: Merging Story\nmerge_failure: \"conflicts\"\n---\n# Story\n",
)
.unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "4_merge".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
contents.contains("merge_failure"),
"merge_failure should be preserved when story is in 4_merge"
);
}
#[test]
fn flush_pending_no_op_when_no_merge_failure() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
let story_path = stage_dir.join("54_story_clean.md");
let original = "---\nname: Clean Story\n---\n# Story\n";
fs::write(&story_path, original).unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "2_current".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert_eq!(contents, original, "file without merge_failure should be unchanged");
}
// ── stage_for_path (additional edge cases) ────────────────────────────────
#[test]
@@ -713,6 +934,20 @@ mod tests {
);
}
#[test]
fn should_commit_stage_only_for_terminal_stages() {
// Terminal stages — should commit.
assert!(should_commit_stage("1_upcoming"));
assert!(should_commit_stage("5_done"));
assert!(should_commit_stage("6_archived"));
// Intermediate stages — broadcast-only, no commit.
assert!(!should_commit_stage("2_current"));
assert!(!should_commit_stage("3_qa"));
assert!(!should_commit_stage("4_merge"));
// Unknown — no commit.
assert!(!should_commit_stage("unknown"));
}
#[test]
fn stage_metadata_returns_correct_actions() {
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();

View File

@@ -179,6 +179,44 @@ pub fn set_anthropic_api_key(store: &dyn StoreOps, api_key: String) -> Result<()
set_anthropic_api_key_impl(store, &api_key)
}
/// Build a prompt for Claude Code that includes prior conversation history.
///
/// When a Claude Code session cannot be resumed (no session_id), we embed
/// the prior messages as a structured preamble so the LLM retains context.
/// If there is only one user message (the current one), the content is
/// returned as-is with no preamble.
fn build_claude_code_context_prompt(messages: &[Message], latest_user_content: &str) -> String {
// Collect prior messages (everything except the trailing user message).
let prior: Vec<&Message> = messages
.iter()
.rev()
.skip(1) // skip the latest user message
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if prior.is_empty() {
return latest_user_content.to_string();
}
let mut parts = Vec::new();
parts.push("<conversation_history>".to_string());
for msg in &prior {
let label = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
Role::Tool => "Tool",
Role::System => continue,
};
parts.push(format!("[{}]: {}", label, msg.content));
}
parts.push("</conversation_history>".to_string());
parts.push(String::new());
parts.push(latest_user_content.to_string());
parts.join("\n")
}
#[allow(clippy::too_many_arguments)]
pub async fn chat<F, U, T, A>(
messages: Vec<Message>,
@@ -224,13 +262,25 @@ where
if is_claude_code {
use crate::llm::providers::claude_code::ClaudeCodeProvider;
let user_message = messages
let latest_user_content = messages
.iter()
.rev()
.find(|m| m.role == Role::User)
.map(|m| m.content.clone())
.ok_or_else(|| "No user message found".to_string())?;
// When resuming with a session_id, Claude Code loads its own transcript
// from disk — the latest user message is sufficient. Without a
// session_id (e.g. after a page refresh) the prior conversation context
// would be lost because Claude Code only receives a single prompt
// string. In that case, prepend the conversation history so the LLM
// retains full context even though the session cannot be resumed.
let user_message = if config.session_id.is_some() {
latest_user_content
} else {
build_claude_code_context_prompt(&messages, &latest_user_content)
};
let project_root = state
.get_project_root()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
@@ -244,6 +294,7 @@ where
&user_message,
&project_root.to_string_lossy(),
config.session_id.as_deref(),
None,
&mut cancel_rx,
|token| on_token(token),
|thinking| on_thinking(thinking),
@@ -404,11 +455,88 @@ where
}
Ok(ChatResult {
messages: new_messages,
messages: current_history[2..].to_vec(),
session_id: None,
})
}
/// Answer a one-off side question using the existing conversation as context.
///
/// Unlike `chat`, this function:
/// - Does NOT perform tool calls.
/// - Does NOT modify the main conversation history.
/// - Does NOT touch the shared cancel signal.
/// - Performs a single LLM call and returns the response text.
pub async fn side_question<U>(
context_messages: Vec<Message>,
question: String,
config: ProviderConfig,
store: &dyn StoreOps,
mut on_token: U,
) -> Result<String, String>
where
U: FnMut(&str) + Send,
{
use crate::llm::providers::anthropic::AnthropicProvider;
use crate::llm::providers::ollama::OllamaProvider;
// Use a local cancel channel that is never cancelled, so the side question
// runs to completion independently of any main chat cancel signal.
// Keep `_cancel_tx` alive for the duration of the function so the channel
// stays open and `changed()` inside the providers does not spuriously fire.
let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let mut cancel_rx = cancel_rx;
cancel_rx.borrow_and_update();
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| "http://localhost:11434".to_string());
let is_claude_code = config.provider == "claude-code";
let is_claude = !is_claude_code && config.model.starts_with("claude-");
// Build a minimal history: existing context + the side question.
let mut history = context_messages;
history.push(Message {
role: Role::User,
content: question,
tool_calls: None,
tool_call_id: None,
});
// No tools for side questions.
let tools: &[ToolDefinition] = &[];
let response = if is_claude {
let api_key = get_anthropic_api_key_impl(store)?;
let provider = AnthropicProvider::new(api_key);
provider
.chat_stream(
&config.model,
&history,
tools,
&mut cancel_rx,
|token| on_token(token),
|_tool_name| {},
)
.await
.map_err(|e| format!("Anthropic Error: {e}"))?
} else if is_claude_code {
return Err("Claude Code provider does not support side questions".to_string());
} else {
let provider = OllamaProvider::new(base_url);
provider
.chat_stream(&config.model, &history, tools, &mut cancel_rx, |token| {
on_token(token)
})
.await
.map_err(|e| format!("Ollama Error: {e}"))?
};
Ok(response.content.unwrap_or_default())
}
async fn execute_tool(call: &ToolCall, state: &SessionState) -> String {
use crate::io::{fs, search, shell};
@@ -1018,4 +1146,102 @@ mod tests {
let result = execute_tool(&call, &state).await;
assert!(result.starts_with("Error:"), "unexpected result: {result}");
}
// ---------------------------------------------------------------------------
// build_claude_code_context_prompt (Bug 245)
// ---------------------------------------------------------------------------
#[test]
fn context_prompt_single_message_returns_content_as_is() {
let messages = vec![Message {
role: Role::User,
content: "hello".to_string(),
tool_calls: None,
tool_call_id: None,
}];
let result = build_claude_code_context_prompt(&messages, "hello");
assert_eq!(result, "hello");
}
#[test]
fn context_prompt_includes_prior_conversation() {
let messages = vec![
Message {
role: Role::User,
content: "What is Rust?".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::Assistant,
content: "Rust is a systems language.".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::User,
content: "Tell me more".to_string(),
tool_calls: None,
tool_call_id: None,
},
];
let result = build_claude_code_context_prompt(&messages, "Tell me more");
assert!(
result.contains("<conversation_history>"),
"should have history preamble"
);
assert!(
result.contains("[User]: What is Rust?"),
"should include prior user message"
);
assert!(
result.contains("[Assistant]: Rust is a systems language."),
"should include prior assistant message"
);
assert!(
result.contains("</conversation_history>"),
"should close history block"
);
assert!(
result.ends_with("Tell me more"),
"should end with latest user message"
);
}
#[test]
fn context_prompt_skips_system_messages() {
let messages = vec![
Message {
role: Role::System,
content: "You are a helpful assistant.".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::User,
content: "hi".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::Assistant,
content: "hello".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::User,
content: "bye".to_string(),
tool_calls: None,
tool_call_id: None,
},
];
let result = build_claude_code_context_prompt(&messages, "bye");
assert!(
!result.contains("helpful assistant"),
"should not include system messages"
);
assert!(result.contains("[User]: hi"));
assert!(result.contains("[Assistant]: hello"));
}
}

View File

@@ -42,6 +42,7 @@ impl ClaudeCodeProvider {
user_message: &str,
project_root: &str,
session_id: Option<&str>,
system_prompt: Option<&str>,
cancel_rx: &mut watch::Receiver<bool>,
mut on_token: F,
mut on_thinking: T,
@@ -55,6 +56,7 @@ impl ClaudeCodeProvider {
let message = user_message.to_string();
let cwd = project_root.to_string();
let resume_id = session_id.map(|s| s.to_string());
let sys_prompt = system_prompt.map(|s| s.to_string());
let cancelled = Arc::new(AtomicBool::new(false));
let cancelled_clone = cancelled.clone();
@@ -79,6 +81,7 @@ impl ClaudeCodeProvider {
&message,
&cwd,
resume_id.as_deref(),
sys_prompt.as_deref(),
cancelled,
token_tx,
thinking_tx,
@@ -120,6 +123,7 @@ impl ClaudeCodeProvider {
.map_err(|e| format!("PTY task panicked: {e}"))??;
let captured_session_id = sid_rx.await.ok();
slog!("[pty-debug] RECEIVED session_id: {:?}", captured_session_id);
let structured_messages: Vec<Message> = msg_rx.try_iter().collect();
Ok(ClaudeCodeResult {
@@ -146,6 +150,7 @@ fn run_pty_session(
user_message: &str,
cwd: &str,
resume_session_id: Option<&str>,
system_prompt: Option<&str>,
cancelled: Arc<AtomicBool>,
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
thinking_tx: tokio::sync::mpsc::UnboundedSender<String>,
@@ -184,6 +189,10 @@ fn run_pty_session(
// a tool requires user approval, instead of using PTY stdin/stdout.
cmd.arg("--permission-prompt-tool");
cmd.arg("mcp__story-kit__prompt_permission");
if let Some(sys) = system_prompt {
cmd.arg("--system");
cmd.arg(sys);
}
cmd.cwd(cwd);
// Keep TERM reasonable but disable color
cmd.env("NO_COLOR", "1");
@@ -346,6 +355,7 @@ fn process_json_event(
// Capture session_id from the first event that carries it
if let Some(tx) = sid_tx.take() {
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
slog!("[pty-debug] CAPTURED session_id: {}", sid);
let _ = tx.send(sid.to_string());
} else {
*sid_tx = Some(tx);

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