Compare commits
396 Commits
2f0c54150a
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7451cb7170 | ||
|
|
83ccfece81 | ||
|
|
68bf179407 | ||
|
|
c35c05d02c | ||
|
|
3adae6c475 | ||
|
|
c4753b51de | ||
|
|
e7a73e7322 | ||
|
|
e8ec84668f | ||
|
|
8d9cf4b283 | ||
|
|
a8cb38fe27 | ||
|
|
dd83e0f4ee | ||
|
|
3923aafb71 | ||
|
|
8fcfadcb04 | ||
|
|
7c023c6beb | ||
|
|
e7bb8db7c1 | ||
|
|
727da0c6d0 | ||
|
|
257ee05ac6 | ||
|
|
b9f3505738 | ||
|
|
be56792c6e | ||
|
|
9daaae2d43 | ||
|
|
c85d02a3ef | ||
|
|
df6f792214 | ||
|
|
967ebd7a84 | ||
|
|
3bc44289b9 | ||
|
|
17f6bae573 | ||
|
|
baa8bdcfda | ||
|
|
33492c49fa | ||
|
|
63a90195e7 | ||
|
|
7bd390c762 | ||
|
|
0d581ab459 | ||
|
|
42f88cc172 | ||
|
|
945648bf6e | ||
|
|
bc5a3da2c0 | ||
|
|
04e841643e | ||
|
|
3d97b0b95a | ||
|
|
8f4cb9475c | ||
|
|
8f63cfda07 | ||
|
|
1b3843d913 | ||
|
|
4c898996a2 | ||
|
|
281531624d | ||
|
|
b09a2cbdf9 | ||
|
|
a0c1457757 | ||
|
|
e818ac986d | ||
|
|
b29f6628f8 | ||
|
|
4dc4fef83b | ||
|
|
7ef85c459c | ||
|
|
f6058a50b9 | ||
|
|
d347ba084d | ||
|
|
b50d007b40 | ||
|
|
ed3d7311d1 | ||
|
|
e7aef3edc7 | ||
|
|
d5a93fe726 | ||
|
|
7e45a1fba0 | ||
|
|
ad348e813f | ||
|
|
de5dcceeaf | ||
|
|
53fdcfec75 | ||
|
|
bad680cf24 | ||
|
|
a5e64ded83 | ||
|
|
77e368d354 | ||
|
|
db92a78d2b | ||
|
|
420deebdb4 | ||
|
|
0a6de3717a | ||
|
|
15645a2a3e | ||
|
|
eab65de723 | ||
|
|
81a5660f11 | ||
|
|
4bf01c6cca | ||
|
|
a799009720 | ||
|
|
549c23bd77 | ||
|
|
34be4d1e75 | ||
|
|
a390861520 | ||
|
|
ce9bdbbb9d | ||
|
|
5f4591f496 | ||
|
|
dc7968ffbc | ||
|
|
5fedd9130a | ||
|
|
c7e371c124 | ||
|
|
8748d7d49a | ||
|
|
825d36c204 | ||
|
|
65a8feff17 | ||
|
|
60dabae795 | ||
|
|
1bae7bd223 | ||
|
|
a0091e81f9 | ||
|
|
beb5ea9f53 | ||
|
|
89e96dc0a6 | ||
|
|
0c686ba170 | ||
|
|
74dc42c1fc | ||
|
|
a3d22fd874 | ||
|
|
8561910cd8 | ||
|
|
e569c1bcad | ||
|
|
4dcb24d5dd | ||
|
|
59f37e13b9 | ||
|
|
3a1d7012b4 | ||
|
|
41b24e4b7a | ||
|
|
06948dae74 | ||
|
|
bbd4aee828 | ||
|
|
d40f007818 | ||
|
|
3819a02159 | ||
|
|
9b65845c90 | ||
|
|
28176727d7 | ||
|
|
1d59cdcc25 | ||
|
|
edc6b9ea05 | ||
|
|
8e4a8ce57a | ||
|
|
c863ee4135 | ||
|
|
dd4a1140fe | ||
|
|
895317330b | ||
|
|
11e32f9802 | ||
|
|
8b7ff6383f | ||
|
|
964a8bfcff | ||
|
|
978b84893c | ||
|
|
7dd6821dc5 | ||
|
|
6abf5c87b2 | ||
|
|
b682c67f97 | ||
|
|
81309a5559 | ||
|
|
2006ad6d8c | ||
|
|
41bafb80e4 | ||
|
|
569380e133 | ||
|
|
10a5bea2b1 | ||
|
|
110815c1c5 | ||
|
|
29fc761980 | ||
|
|
d537aceb63 | ||
|
|
72b89c8ccc | ||
|
|
e19de02967 | ||
|
|
1c5f13e7eb | ||
|
|
816c771a2a | ||
|
|
642a8486cd | ||
|
|
605bcadea7 | ||
|
|
ccc1ead8c9 | ||
|
|
8bbbe8fbdd | ||
|
|
d9775834ed | ||
|
|
c32f0dce45 | ||
|
|
d864941665 | ||
|
|
9c2d831c65 | ||
|
|
2ab91f933f | ||
|
|
1fcb8cb332 | ||
|
|
3439c16e66 | ||
|
|
ce93987da8 | ||
|
|
bd7b7cc34a | ||
|
|
855452b4a2 | ||
|
|
1fcfa9123f | ||
|
|
e66b811436 | ||
|
|
8d5fa85a3a | ||
|
|
a4e7a23ca6 | ||
|
|
b67eea7b9a | ||
|
|
4a89b46857 | ||
|
|
047bf83b76 | ||
|
|
62aa142409 | ||
|
|
c93a2e80f9 | ||
|
|
9176fe3303 | ||
|
|
296a59def3 | ||
|
|
90bb2fb137 | ||
|
|
bc0bb91a83 | ||
|
|
0b39b2acfc | ||
|
|
75c27f5853 | ||
|
|
349866606c | ||
|
|
901f7a65d3 | ||
|
|
c52b41b99c | ||
|
|
ec76005c63 | ||
|
|
1736f8d924 | ||
|
|
f8b5e11c27 | ||
|
|
12c500ee90 | ||
|
|
81c9cf797f | ||
|
|
d18c1105c7 | ||
|
|
ca8e6dc51c | ||
|
|
30ad59c6eb | ||
|
|
123f140244 | ||
|
|
8db23f77cd | ||
|
|
6bfa10b0e5 | ||
|
|
65036b2ce7 | ||
|
|
76d73b2d0b | ||
|
|
78618a1b76 | ||
|
|
47e07b23d1 | ||
|
|
45ae7b8f01 | ||
|
|
e1c30b5953 | ||
|
|
b0d9fb4f39 | ||
|
|
dcc11c2b0f | ||
|
|
7f21454880 | ||
|
|
a893a1cef7 | ||
|
|
3fb48cdf51 | ||
|
|
f1bb1216bf | ||
|
|
b3faf7b810 | ||
|
|
89e4ee1c9c | ||
|
|
4df39eb1f2 | ||
|
|
a7d23143ef | ||
|
|
f72666b39e | ||
|
|
1f8ffee38e | ||
|
|
798f841b9a | ||
|
|
25c3dbb3d1 | ||
|
|
71cbc21b01 | ||
|
|
6deeba81a8 | ||
|
|
b862a7a6d0 | ||
|
|
fe1f76957d | ||
|
|
266e676dd4 | ||
|
|
402159c19a | ||
|
|
6d1b36e515 | ||
|
|
81d4889cee | ||
|
|
0eb2cd8ec3 | ||
|
|
b251ed7421 | ||
|
|
4a600e9954 | ||
|
|
cfb810b061 | ||
|
|
71bd999586 | ||
|
|
10d0cdeeae | ||
|
|
6e375aaab5 | ||
|
|
e7edf9a8d5 | ||
|
|
20431f625b | ||
|
|
d35f0f19fb | ||
|
|
4303b33b90 | ||
|
|
f9c0d24d7a | ||
|
|
ec3277234c | ||
|
|
0a28aae041 | ||
|
|
a7a8358cbb | ||
|
|
6b6cb525a7 | ||
|
|
27465b1130 | ||
|
|
e74c370c7e | ||
|
|
8defd5c671 | ||
|
|
a5c4fb553a | ||
|
|
a7772d1421 | ||
|
|
ed967403fb | ||
|
|
998b188ac7 | ||
|
|
115c9fd6df | ||
|
|
86694a4383 | ||
|
|
7b324ea96e | ||
|
|
744a12eeea | ||
|
|
cffe63680d | ||
|
|
f5fffd64b8 | ||
|
|
ad68bc912f | ||
|
|
d02d53d112 | ||
|
|
3ce7276e89 | ||
|
|
6d87e64859 | ||
|
|
83db282892 | ||
|
|
f5d5196bf5 | ||
|
|
7ec869baa8 | ||
|
|
1a257b3057 | ||
|
|
b9fd87ed7c | ||
|
|
fda763d3f0 | ||
|
|
77d89b17e8 | ||
|
|
df0fa46591 | ||
|
|
1f5d70ce0d | ||
|
|
0d46c86469 | ||
|
|
a439f8fdcb | ||
|
|
1adddf4e4c | ||
|
|
23484716e2 | ||
|
|
92085f9071 | ||
|
|
ce899b569e | ||
|
|
da7216630b | ||
|
|
b57c270144 | ||
|
|
230b8fdc35 | ||
|
|
75b2446801 | ||
|
|
96779c9caf | ||
|
|
bf5d9ff6b1 | ||
|
|
c551faeea3 | ||
|
|
3f38f90a50 | ||
|
|
26a1328c89 | ||
|
|
21b45b8dd7 | ||
|
|
3a860bd2d5 | ||
|
|
c2c95c18b4 | ||
|
|
e3a301009b | ||
|
|
c90bdc8907 | ||
|
|
dba12a38c2 | ||
|
|
4b60452b27 | ||
|
|
d2f677ae0c | ||
|
|
427bb6929a | ||
|
|
78c04ee576 | ||
|
|
3309d26142 | ||
|
|
5a4a2aaa17 | ||
|
|
d3786253ef | ||
|
|
76db12a53e | ||
|
|
4eb5a01774 | ||
|
|
198f9ff5bf | ||
|
|
e30773d088 | ||
|
|
a4affca9be | ||
|
|
a067091354 | ||
|
|
da423d9c97 | ||
|
|
d6d080e30a | ||
|
|
9098c1ba9d | ||
|
|
511c5809f2 | ||
|
|
ace8e59536 | ||
|
|
fa128c52d9 | ||
|
|
621cdea6df | ||
|
|
68233e3355 | ||
|
|
99d298035b | ||
|
|
73b41d1c6c | ||
|
|
1a56844661 | ||
|
|
48ff0ba205 | ||
|
|
50b29e0bed | ||
|
|
ea062400e5 | ||
|
|
b0e4e04c9d | ||
|
|
02fe364349 | ||
|
|
3602f882d2 | ||
|
|
730e7324ea | ||
|
|
ae73d95d50 | ||
|
|
ae6dd3217b | ||
|
|
9a6f63b591 | ||
|
|
421eaec7ba | ||
|
|
2c4e376054 | ||
|
|
1896a0ac49 | ||
|
|
b8d3978a54 | ||
|
|
72c50b6ffc | ||
|
|
bab77fe105 | ||
|
|
1d935192e1 | ||
|
|
f89f78d77d | ||
|
|
09a71b4515 | ||
|
|
988562fc82 | ||
|
|
ed0d5d9253 | ||
|
|
bb265d7bd5 | ||
|
|
126a6f8dc3 | ||
|
|
3b66b89c90 | ||
|
|
e9879ce1c7 | ||
|
|
d30192b6a3 | ||
|
|
93c4f06818 | ||
|
|
7dab810572 | ||
|
|
cb7dde9fc1 | ||
|
|
7f70d1118f | ||
|
|
5638402745 | ||
|
|
e90bf38fa2 | ||
|
|
46ab4cdd8a | ||
|
|
7341fca72e | ||
|
|
fdb4a4fb62 | ||
|
|
87791c755e | ||
|
|
a4ce5f8f7c | ||
|
|
a9a84bee6d | ||
|
|
34755d3f63 | ||
|
|
ec553a5b8a | ||
|
|
076324c470 | ||
|
|
5ed2737edc | ||
|
|
0eafddd186 | ||
|
|
7d4f722942 | ||
|
|
5d80d289c4 | ||
|
|
7c6e1b445d | ||
|
|
a85d1a1170 | ||
|
|
afc1ab5e0e | ||
|
|
32b6439f2f | ||
|
|
85e56e0ea8 | ||
|
|
b63fa6be4f | ||
|
|
f012311303 | ||
|
|
af0aa007ca | ||
|
|
b2aec94d4c | ||
|
|
2ac550008a | ||
|
|
ebbbfed1d9 | ||
|
|
fd6ef83f76 | ||
|
|
473461b65d | ||
|
|
dc8d639d02 | ||
|
|
594fc500cf | ||
|
|
5448a99759 | ||
|
|
f5524b3ae1 | ||
|
|
4585537dd8 | ||
|
|
57911fd9e7 | ||
|
|
b6f5169b56 | ||
|
|
a4b99c68da | ||
|
|
85062c338f | ||
|
|
a7f3d283ec | ||
|
|
6cc9d1bde9 | ||
|
|
a82fa37730 | ||
|
|
06ceab3e22 | ||
|
|
58438f3ab6 | ||
|
|
59bb7dbc3a | ||
|
|
9c2471fbcc | ||
|
|
f383d0cb4f | ||
|
|
be61803af0 | ||
|
|
c132d4f5c0 | ||
|
|
263ba440dc | ||
|
|
2fae9066e2 | ||
|
|
3553f59078 | ||
|
|
78ea96d0a9 | ||
|
|
79d3eccc46 | ||
|
|
c21a087399 | ||
|
|
67942d466c | ||
|
|
1d6a4fa8c6 | ||
|
|
250f3ff819 | ||
|
|
a02ea3c292 | ||
|
|
bbc5d9c90c | ||
|
|
24f6a5c7cc | ||
|
|
ab3420fa90 | ||
|
|
4c6228abee | ||
|
|
6df28d5393 | ||
|
|
2ad59ba155 | ||
|
|
319fc3823a | ||
|
|
b9f3449021 | ||
|
|
cd7444ac5c | ||
|
|
f5d9c98e74 | ||
|
|
7cd19e248c | ||
|
|
ec5024a089 | ||
|
|
9041cd1d16 | ||
|
|
0a0624795c | ||
|
|
d8d0d7936c | ||
|
|
55ea8e6aaf | ||
|
|
1598d2a453 | ||
|
|
0120de5f00 | ||
|
|
21835bc37d | ||
|
|
f01fa6c527 | ||
|
|
a51488a0ce | ||
|
|
9054ac013e | ||
|
|
95eea3a624 | ||
|
|
6b9390b243 | ||
|
|
3ed9b7a185 | ||
|
|
bd7426131f | ||
|
|
e0132a7807 | ||
|
|
b829783a84 |
@@ -59,7 +59,8 @@
|
||||
"mcp__story-kit__*",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash(find *)"
|
||||
"Bash(find *)",
|
||||
"Bash(sqlite3 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"story-kit": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:3001/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
.story_kit/.gitignore
vendored
Normal file
19
.story_kit/.gitignore
vendored
Normal 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/
|
||||
@@ -18,7 +18,7 @@ When you start a new session with this project:
|
||||
This returns the full tool catalog (create stories, spawn agents, record tests, manage worktrees, etc.). Familiarize yourself with the available tools before proceeding. These tools allow you to directly manipulate the workflow and spawn subsidiary agents without manual file manipulation.
|
||||
2. **Read Context:** Check `.story_kit/specs/00_CONTEXT.md` for high-level project goals.
|
||||
3. **Read Stack:** Check `.story_kit/specs/tech/STACK.md` for technical constraints and patterns.
|
||||
4. **Check Work Items:** Look at `.story_kit/work/1_upcoming/` and `.story_kit/work/2_current/` to see what work is pending.
|
||||
4. **Check Work Items:** Look at `.story_kit/work/1_backlog/` and `.story_kit/work/2_current/` to see what work is pending.
|
||||
|
||||
|
||||
---
|
||||
@@ -52,7 +52,7 @@ project_root/
|
||||
├── README.md # This document
|
||||
├── project.toml # Agent configuration (roles, models, prompts)
|
||||
├── work/ # Unified work item pipeline (stories, bugs, spikes)
|
||||
│ ├── 1_upcoming/ # New work items awaiting implementation
|
||||
│ ├── 1_backlog/ # New work items awaiting implementation
|
||||
│ ├── 2_current/ # Work in progress
|
||||
│ ├── 3_qa/ # QA review
|
||||
│ ├── 4_merge/ # Ready to merge to master
|
||||
@@ -78,7 +78,7 @@ All work items (stories, bugs, spikes) live in the same `work/` pipeline. Items
|
||||
|
||||
Items move through stages by moving the file between directories:
|
||||
|
||||
`1_upcoming` → `2_current` → `3_qa` → `4_merge` → `5_done` → `6_archived`
|
||||
`1_backlog` → `2_current` → `3_qa` → `4_merge` → `5_done` → `6_archived`
|
||||
|
||||
Items in `5_done` are auto-swept to `6_archived` after 4 hours by the server.
|
||||
|
||||
@@ -87,7 +87,7 @@ Items in `5_done` are auto-swept to `6_archived` after 4 hours by the server.
|
||||
The server watches `.story_kit/work/` for changes. When a file is created, moved, or modified, the watcher auto-commits with a deterministic message and broadcasts a WebSocket notification to the frontend. This means:
|
||||
|
||||
* MCP tools only need to write/move files — the watcher handles git commits
|
||||
* IDE drag-and-drop works (drag a story from `1_upcoming/` to `2_current/`)
|
||||
* IDE drag-and-drop works (drag a story from `1_backlog/` to `2_current/`)
|
||||
* The frontend updates automatically without manual refresh
|
||||
|
||||
---
|
||||
@@ -156,7 +156,7 @@ Not everything needs to be a full story. Simple bugs can skip the story process:
|
||||
* Performance issues with known fixes
|
||||
|
||||
### Bug Process
|
||||
1. **Document Bug:** Create a bug file in `work/1_upcoming/` named `{id}_bug_{slug}.md` with:
|
||||
1. **Document Bug:** Create a bug file in `work/1_backlog/` named `{id}_bug_{slug}.md` with:
|
||||
* **Symptom:** What the user observes
|
||||
* **Root Cause:** Technical explanation (if known)
|
||||
* **Reproduction Steps:** How to trigger the bug
|
||||
@@ -186,7 +186,7 @@ Not everything needs a story or bug fix. Spikes are time-boxed investigations to
|
||||
* Need to validate performance constraints
|
||||
|
||||
### Spike Process
|
||||
1. **Document Spike:** Create a spike file in `work/1_upcoming/` named `{id}_spike_{slug}.md` with:
|
||||
1. **Document Spike:** Create a spike file in `work/1_backlog/` named `{id}_spike_{slug}.md` with:
|
||||
* **Question:** What you need to answer
|
||||
* **Hypothesis:** What you expect to be true
|
||||
* **Timebox:** Strict limit for the research
|
||||
@@ -209,7 +209,7 @@ When the LLM context window fills up (or the chat gets slow/confused):
|
||||
1. **Stop Coding.**
|
||||
2. **Instruction:** Tell the user to open a new chat.
|
||||
3. **Handoff:** The only context the new LLM needs is in the `specs/` folder and `.mcp.json`.
|
||||
* *Prompt for New Session:* "I am working on Project X. Read `.mcp.json` to discover available tools, then read `specs/00_CONTEXT.md` and `specs/tech/STACK.md`. Then look at `work/1_upcoming/` and `work/2_current/` to see what is pending."
|
||||
* *Prompt for New Session:* "I am working on Project X. Read `.mcp.json` to discover available tools, then read `specs/00_CONTEXT.md` and `specs/tech/STACK.md`. Then look at `work/1_backlog/` and `work/2_current/` to see what is pending."
|
||||
|
||||
|
||||
---
|
||||
@@ -221,7 +221,7 @@ If a user hands you this document and says "Apply this process to my project":
|
||||
1. **Check for MCP Tools:** Look for `.mcp.json` in the project root. If it exists, you have programmatic access to workflow tools and agent spawning capabilities.
|
||||
2. **Analyze the Request:** Ask for the high-level goal ("What are we building?") and the tech preferences ("Rust or Python?").
|
||||
3. **Git Check:** Check if the directory is a git repository (`git status`). If not, run `git init`.
|
||||
4. **Scaffold:** Run commands to create the `work/` and `specs/` folders with the 6-stage pipeline (`work/1_upcoming/` through `work/6_archived/`).
|
||||
4. **Scaffold:** Run commands to create the `work/` and `specs/` folders with the 6-stage pipeline (`work/1_backlog/` through `work/6_archived/`).
|
||||
5. **Draft Context:** Write `specs/00_CONTEXT.md` based on the user's answer.
|
||||
6. **Draft Stack:** Write `specs/tech/STACK.md` based on best practices for that language.
|
||||
7. **Wait:** Ask the user for "Story #1".
|
||||
|
||||
@@ -13,3 +13,7 @@ enabled = false
|
||||
|
||||
# Maximum conversation turns to remember per room (default: 20).
|
||||
# history_size = 20
|
||||
|
||||
# Rooms where the bot responds to all messages (not just addressed ones).
|
||||
# This list is updated automatically when users toggle ambient mode at runtime.
|
||||
# ambient_rooms = ["!roomid:example.com"]
|
||||
|
||||
7
.story_kit/problems.md
Normal file
7
.story_kit/problems.md
Normal 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.
|
||||
@@ -34,7 +34,7 @@ You have these tools via the story-kit MCP server:
|
||||
## Your Workflow
|
||||
1. Read CLAUDE.md and .story_kit/README.md to understand the project and dev process
|
||||
2. Read the story file from .story_kit/work/ to understand requirements
|
||||
3. Move it to work/2_current/ if it is in work/1_upcoming/
|
||||
3. Move it to work/2_current/ if it is in work/1_backlog/
|
||||
4. Start coder-1 on the story: call start_agent with story_id="{{story_id}}" and agent_name="coder-1"
|
||||
5. Wait for completion: call wait_for_agent with story_id="{{story_id}}" and agent_name="coder-1". The server automatically runs acceptance gates (cargo clippy + tests) when the coder process exits. wait_for_agent returns when the coder reaches a terminal state.
|
||||
6. Check the result: inspect the "completion" field in the wait_for_agent response — if gates_passed is true, the work is done; if false, review the gate_output and decide whether to start a fresh coder.
|
||||
@@ -102,7 +102,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Kill the test server when done: `pkill -f story-kit || true`
|
||||
- Kill the test server when done: `pkill -f 'target.*story-kit' || true` (NEVER use `pkill -f story-kit` — it kills the vite dev server)
|
||||
|
||||
### 4. Produce Structured Report
|
||||
Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format:
|
||||
@@ -179,7 +179,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Kill the test server when done: `pkill -f story-kit || true`
|
||||
- Kill the test server when done: `pkill -f 'target.*story-kit' || true` (NEVER use `pkill -f story-kit` — it kills the vite dev server)
|
||||
|
||||
### 4. Produce Structured Report
|
||||
Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format:
|
||||
@@ -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, npm run build, npm 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, npm run build, npm test), attem
|
||||
- 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."
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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
|
||||
|
||||
## Notes
|
||||
|
||||
- **2026-03-18**: Moved back to current from merge. Previous attempt went through the full pipeline but the squash-merge produced an empty diff — no code was actually implemented. Needs a real implementation.
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: "Show server logs in web UI"
|
||||
---
|
||||
|
||||
# Story 292: Show server logs in web UI
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner using the web UI, I want to see live server logs in the interface, so that I can debug agent behavior and pipeline issues without needing terminal access.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Web UI has a server logs panel accessible from the main interface
|
||||
- [ ] Logs stream in real-time via WebSocket or SSE
|
||||
- [ ] Logs can be filtered by keyword (same as get_server_logs MCP tool's filter param)
|
||||
- [ ] Log entries show timestamp and severity level
|
||||
- [ ] Panel doesn't interfere with the existing pipeline board and work item views
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -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)
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: "Chat history persistence lost on page refresh (story 145 regression)"
|
||||
---
|
||||
|
||||
## Rejection Notes
|
||||
|
||||
**2026-03-16:** Previous coder produced zero code changes — feature branch had no diff against master. The coder must actually use `git bisect` to find the breaking commit and produce a surgical fix. Do not submit with no code changes.
|
||||
|
||||
# Bug 245: Chat history persistence lost on page refresh (story 145 regression)
|
||||
|
||||
## Description
|
||||
|
||||
Story 145 implemented localStorage persistence for chat history across page reloads. This is no longer working — refreshing the page loses all conversation context. This is a regression of the feature delivered in story 145.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Open the web UI and have a conversation with the agent
|
||||
2. Refresh the page (F5 or Cmd+R)
|
||||
|
||||
## Actual Result
|
||||
|
||||
Chat history is gone after refresh — the UI shows a blank conversation.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Chat history is restored from localStorage on page load, as implemented in story 145.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Chat messages survive a full page refresh
|
||||
- [ ] Chat messages are restored from localStorage on component mount
|
||||
- [ ] Behaviour matches the original acceptance criteria from story 145
|
||||
|
||||
## Investigation Notes
|
||||
|
||||
**Use `git bisect` to find the commit that broke this.** Story 145 delivered working localStorage persistence — something after that regressed it. Find the breaking commit, understand the root cause, and fix it there. Do NOT layer on a new implementation. Revert or surgically fix the regression.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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
|
||||
|
||||
## Notes
|
||||
|
||||
- **2026-03-18**: Moved back to current from done. Previous attempt went through the full pipeline but merged with an empty diff — no typing indicator code was actually implemented. Needs a real implementation this time.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "Matrix bot ambient mode toggle via chat command"
|
||||
---
|
||||
|
||||
# Story 282: Matrix bot ambient mode toggle via chat command
|
||||
|
||||
## User Story
|
||||
|
||||
As a user chatting with Timmy in a Matrix room, I want to toggle between "addressed mode" (bot only responds when mentioned by name) and "ambient mode" (bot responds to all messages) via a chat command, so that I don't have to @-mention the bot on every message when I'm the only one around.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Matrix bot defaults to addressed mode — only forwards messages containing the bot's name to Claude
|
||||
- [ ] Chat command "{bot_name} ambient on" switches to ambient mode — bot forwards all room messages to Claude (bot name comes from display_name in bot.toml)
|
||||
- [ ] Chat command "{bot_name} ambient off" switches back to addressed mode
|
||||
- [ ] Mode is persisted per-room in bot.toml so it survives bot restarts
|
||||
- [ ] bot.toml.example includes the ambient_mode setting with a comment explaining it
|
||||
- [ ] Bot confirms the mode switch with a short response in chat
|
||||
- [ ] When other users join or are active, user can flip back to addressed mode to avoid noise
|
||||
- [ ] Ambient mode applies per-room (not globally across all rooms)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: "Pipeline does not check manual_qa flag before advancing from QA to merge"
|
||||
---
|
||||
|
||||
# Bug 283: Pipeline does not check manual_qa flag before advancing from QA to merge
|
||||
|
||||
## Description
|
||||
|
||||
Story 247 added the manual_qa front matter field and the MCP tooling to set it, but the pipeline in pool.rs never actually checks the flag. After QA passes gates and coverage, stories move straight to merge regardless of manual_qa setting.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Create a story with manual_qa: true in front matter
|
||||
2. Let it go through the coder and QA stages
|
||||
3. Observe that it moves directly to merge without waiting for human approval
|
||||
|
||||
## Actual Result
|
||||
|
||||
Stories always advance from QA to merge automatically, ignoring the manual_qa flag.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Stories with manual_qa: true should pause after QA passes and wait for human approval before moving to merge. Stories with manual_qa: false (the default) should advance automatically as they do now.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Pipeline checks manual_qa front matter field after QA gates pass
|
||||
- [ ] manual_qa defaults to false — stories advance automatically unless explicitly opted in
|
||||
- [ ] Stories with manual_qa: true wait in 3_qa for human approval via accept_story or the UI
|
||||
- [ ] Stories with manual_qa: false proceed directly from QA to merge as before
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "Matrix bot status command shows pipeline and agent availability"
|
||||
---
|
||||
|
||||
# Story 284: Matrix bot status command shows pipeline and agent availability
|
||||
|
||||
## User Story
|
||||
|
||||
As a user in a Matrix room, I want to type "{bot_name} status" and get a formatted summary of the full pipeline (upcoming through done) with agent assignments, plus which agents are currently free, so that I can check project status without leaving chat.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Chat command "{bot_name} status" triggers a pipeline status display (bot name comes from display_name in bot.toml)
|
||||
- [ ] Output shows all stages: upcoming, current, qa, merge, done — with story names and IDs
|
||||
- [ ] Each active story shows its assigned agent name and model
|
||||
- [ ] Output includes a section showing which agents are free (not currently assigned to any story)
|
||||
- [ ] Response is formatted for readability in Matrix (monospace or markdown as appropriate)
|
||||
- [ ] Command is handled at the bot level — does not require a full Claude invocation
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Matrix bot help command lists available bot commands"
|
||||
---
|
||||
|
||||
# Story 285: Matrix bot help command lists available bot commands
|
||||
|
||||
## User Story
|
||||
|
||||
As a user in a Matrix room, I want to type "{bot_name} help" and get a list of all available bot commands with brief descriptions, so that I can discover what the bot can do without having to ask or remember.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Chat command "{bot_name} help" displays a list of all available bot-level commands (bot name comes from display_name in bot.toml)
|
||||
- [ ] Each command is shown with a short description of what it does
|
||||
- [ ] Help output is formatted for readability in Matrix
|
||||
- [ ] Help command is handled at the bot level — does not require a full Claude invocation
|
||||
- [ ] Help list automatically includes new commands as they are added (driven by a registry or similar, not a hardcoded string)
|
||||
|
||||
## Notes
|
||||
|
||||
- **2026-03-18**: Moved back to current from done. Previous attempt committed code directly to master (commit a32cfbd) instead of the worktree, and the help command is not functional in the running server. Needs a clean implementation this time.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "Server self-rebuild and restart via MCP tool"
|
||||
---
|
||||
|
||||
# Story 286: Server self-rebuild and restart via MCP tool
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner away from my terminal, I want to tell the bot to restart the server so that it picks up new code changes, without needing physical access to the machine.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] MCP tool `rebuild_and_restart` triggers a cargo build of the server
|
||||
- [ ] If the build fails, server stays up and returns the build error
|
||||
- [ ] If the build succeeds, server re-execs itself with the new binary using std::os::unix::process::CommandExt::exec()
|
||||
- [ ] Server logs the restart so it's traceable
|
||||
- [ ] Matrix bot reconnects automatically after the server comes back up
|
||||
- [ ] Running agents are gracefully stopped before re-exec
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "Rename upcoming pipeline stage to backlog"
|
||||
---
|
||||
|
||||
# Story 287: Rename upcoming pipeline stage to backlog
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner, I want the "upcoming" pipeline stage renamed to "backlog" throughout the codebase, UI, and directory structure, so that the terminology better reflects that these items are not necessarily coming up next.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Directory renamed from 1_upcoming to 1_backlog
|
||||
- [ ] All server code references updated (watcher, lifecycle, MCP tools, workflow, etc.)
|
||||
- [ ] Frontend UI labels updated
|
||||
- [ ] MCP tool descriptions and outputs use "backlog" instead of "upcoming"
|
||||
- [ ] Existing story/bug files moved to the new directory
|
||||
- [ ] Git commit messages use "backlog" for new items going forward
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: "Ambient mode state lost on server restart"
|
||||
---
|
||||
|
||||
# Bug 288: Ambient mode state lost on server restart
|
||||
|
||||
## Description
|
||||
|
||||
Story 282 implemented ambient mode toggle but only in-memory. The acceptance criterion requiring persistence in bot.toml was not implemented. Every server restart (including rebuild_and_restart) clears ambient mode for all rooms.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Type "timmy ambient on" — get confirmation
|
||||
2. Restart server (or rebuild_and_restart)
|
||||
3. Send unaddressed message — bot ignores it, ambient mode is gone
|
||||
|
||||
## Actual Result
|
||||
|
||||
Ambient mode state is lost on server restart.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Ambient mode per-room state is persisted in bot.toml and restored on startup.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Ambient mode per-room state is saved to bot.toml when toggled
|
||||
- [ ] Ambient mode state is restored from bot.toml on server startup
|
||||
- [ ] bot.toml.example includes the ambient_rooms setting with a comment
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "rebuild_and_restart MCP tool does not rebuild"
|
||||
agent: coder-opus
|
||||
---
|
||||
|
||||
# Bug 289: rebuild_and_restart MCP tool does not rebuild
|
||||
|
||||
## Description
|
||||
|
||||
The rebuild_and_restart MCP tool re-execs the server binary but does not run cargo build first. It restarts with the old binary, so code changes are not picked up.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Make a code change to the server
|
||||
2. Call rebuild_and_restart via MCP
|
||||
3. Observe the server restarts but the code change is not reflected
|
||||
|
||||
## Actual Result
|
||||
|
||||
Server re-execs with the old binary. Code changes are not compiled.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Server runs cargo build --release (or cargo build) before re-execing, so the new binary includes the latest code changes.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "Remove agent thinking traces from agents sidebar"
|
||||
---
|
||||
|
||||
# Story 290: Remove agent thinking traces from agents sidebar
|
||||
|
||||
## User Story
|
||||
|
||||
As a user viewing an expanded work item in the web UI, I want to see the live agent output stream (thinking traces, tool calls, progress) for the agent working on that story, so that I can monitor progress in context rather than in the agents sidebar.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Agent thinking traces are removed from the agents sidebar panel — they should only appear in the work item detail panel (which already has SSE streaming wired up in `WorkItemDetailPanel.tsx`)
|
||||
|
||||
## Notes
|
||||
|
||||
The detail panel (`WorkItemDetailPanel.tsx`) already has agent log streaming implemented — SSE subscription, real-time output, status badges, etc. The only remaining work is removing the thinking traces from the agents sidebar (`AgentPanel.tsx` or similar) so they don't appear in both places.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Replacing the agents sidebar entirely — it still shows agent names, status, and assignments
|
||||
- Historical agent output (only live stream while agent is running)
|
||||
@@ -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.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
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
|
||||
@@ -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
|
||||
@@ -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.*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: "Detect and log when root .mcp.json port is modified"
|
||||
---
|
||||
|
||||
# Story 276: Detect and log when root .mcp.json port is modified
|
||||
|
||||
## User Story
|
||||
|
||||
As a ..., I want ..., so that ...
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TODO
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -3997,7 +3997,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "story-kit"
|
||||
version = "0.1.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -4025,9 +4025,10 @@ dependencies = [
|
||||
"strip-ansi-escapes",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml 1.0.6+spec-1.1.0",
|
||||
"uuid",
|
||||
"wait-timeout",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4332,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]]
|
||||
@@ -4561,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",
|
||||
@@ -4573,7 +4574,6 @@ dependencies = [
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4771,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"
|
||||
|
||||
@@ -26,7 +26,7 @@ tempfile = "3"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
toml = "1.0.6"
|
||||
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
||||
tokio-tungstenite = "0.28.0"
|
||||
tokio-tungstenite = "0.29.0"
|
||||
walkdir = "2.5.0"
|
||||
filetime = "0.2"
|
||||
matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
||||
|
||||
@@ -13,7 +13,7 @@ cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Run the server (serves embedded frontend/dist/)
|
||||
# In another terminal - run the server (serves embedded frontend/dist/)
|
||||
cargo run
|
||||
```
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("ChatWebSocket", () => {
|
||||
|
||||
// Server pushes pipeline_state on fresh connection
|
||||
const freshState = {
|
||||
upcoming: [{ story_id: "1_story_test", name: "Test", error: null }],
|
||||
backlog: [{ story_id: "1_story_test", name: "Test", error: null }],
|
||||
current: [],
|
||||
qa: [],
|
||||
merge: [],
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface PipelineStageItem {
|
||||
}
|
||||
|
||||
export interface PipelineState {
|
||||
upcoming: PipelineStageItem[];
|
||||
backlog: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
@@ -50,7 +50,7 @@ export type WsResponse =
|
||||
| { type: "error"; message: string }
|
||||
| {
|
||||
type: "pipeline_state";
|
||||
upcoming: PipelineStageItem[];
|
||||
backlog: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
@@ -115,6 +115,7 @@ export interface WorkItemContent {
|
||||
content: string;
|
||||
stage: string;
|
||||
name: string | null;
|
||||
agent: string | null;
|
||||
}
|
||||
|
||||
export interface TestCaseResult {
|
||||
@@ -277,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",
|
||||
@@ -394,7 +398,7 @@ export class ChatWebSocket {
|
||||
if (data.type === "error") this.onError?.(data.message);
|
||||
if (data.type === "pipeline_state")
|
||||
this.onPipelineState?.({
|
||||
upcoming: data.upcoming,
|
||||
backlog: data.backlog,
|
||||
current: data.current,
|
||||
qa: data.qa,
|
||||
merge: data.merge,
|
||||
|
||||
@@ -213,7 +213,7 @@ describe("RosterBadge availability state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Thinking traces hidden from agent stream UI", () => {
|
||||
describe("Agent output not shown in sidebar (story 290)", () => {
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
@@ -224,7 +224,51 @@ describe("Thinking traces hidden from agent stream UI", () => {
|
||||
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
// AC1: thinking block is never rendered even when thinking events arrive
|
||||
// AC1: output events do not appear in the agents sidebar
|
||||
it("does not render agent output when output event arrives", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "290_output",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||
|
||||
const { container } = render(<AgentPanel />);
|
||||
await screen.findByTestId("roster-badge-coder-1");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "290_output",
|
||||
agent_name: "coder-1",
|
||||
text: "doing some work...",
|
||||
});
|
||||
});
|
||||
|
||||
// No output elements in the sidebar
|
||||
expect(
|
||||
container.querySelector('[data-testid^="agent-output-"]'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('[data-testid^="agent-stream-"]'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AC1: thinking events do not appear in the agents sidebar
|
||||
it("does not render thinking block when thinking event arrives", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
@@ -236,7 +280,7 @@ describe("Thinking traces hidden from agent stream UI", () => {
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "218_thinking",
|
||||
story_id: "290_thinking",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
@@ -253,109 +297,16 @@ describe("Thinking traces hidden from agent stream UI", () => {
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "thinking",
|
||||
story_id: "218_thinking",
|
||||
story_id: "290_thinking",
|
||||
agent_name: "coder-1",
|
||||
text: "Let me consider the problem carefully...",
|
||||
});
|
||||
});
|
||||
|
||||
// AC1: thinking block must not be present
|
||||
// No thinking block or output in sidebar
|
||||
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AC2: after thinking events, only regular output is rendered
|
||||
it("renders regular output but not thinking block when both arrive", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "218_output",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(<AgentPanel />);
|
||||
await screen.findByTestId("roster-badge-coder-1");
|
||||
|
||||
// Thinking event — must be ignored visually
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "thinking",
|
||||
story_id: "218_output",
|
||||
agent_name: "coder-1",
|
||||
text: "thinking deeply",
|
||||
});
|
||||
});
|
||||
|
||||
// AC3: output event still renders correctly (no regression)
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "218_output",
|
||||
agent_name: "coder-1",
|
||||
text: "Here is the result.",
|
||||
});
|
||||
});
|
||||
|
||||
// AC1: no thinking block
|
||||
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
|
||||
|
||||
// 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
|
||||
it("renders output event text without a thinking block", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "218_noThink",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(<AgentPanel />);
|
||||
await screen.findByTestId("roster-badge-coder-1");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "218_noThink",
|
||||
agent_name: "coder-1",
|
||||
text: "plain output line",
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
|
||||
const outputArea = screen.getByTestId("agent-output-coder-1");
|
||||
expect(outputArea.textContent).toContain("plain output line");
|
||||
expect(
|
||||
screen.queryByText("Let me consider the problem carefully..."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ const { useCallback, useEffect, useRef, useState } = React;
|
||||
interface AgentState {
|
||||
agentName: string;
|
||||
status: AgentStatusValue;
|
||||
log: string[];
|
||||
sessionId: string | null;
|
||||
worktreePath: string | null;
|
||||
baseBranch: string | null;
|
||||
@@ -120,7 +119,6 @@ export function AgentPanel({
|
||||
const current = prev[key] ?? {
|
||||
agentName,
|
||||
status: "pending" as AgentStatusValue,
|
||||
log: [],
|
||||
sessionId: null,
|
||||
worktreePath: null,
|
||||
baseBranch: null,
|
||||
@@ -144,14 +142,6 @@ export function AgentPanel({
|
||||
},
|
||||
};
|
||||
}
|
||||
case "output":
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...current,
|
||||
log: [...current.log, event.text ?? ""],
|
||||
},
|
||||
};
|
||||
case "done":
|
||||
return {
|
||||
...prev,
|
||||
@@ -168,17 +158,12 @@ export function AgentPanel({
|
||||
[key]: {
|
||||
...current,
|
||||
status: "failed",
|
||||
log: [
|
||||
...current.log,
|
||||
`[ERROR] ${event.message ?? "Unknown error"}`,
|
||||
],
|
||||
terminalAt: current.terminalAt ?? Date.now(),
|
||||
},
|
||||
};
|
||||
case "thinking":
|
||||
// Thinking traces are internal model state — never display them.
|
||||
return prev;
|
||||
default:
|
||||
// output, thinking, and other events are not displayed in the sidebar.
|
||||
// Agent output streams appear in the work item detail panel instead.
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
@@ -204,7 +189,6 @@ export function AgentPanel({
|
||||
agentMap[key] = {
|
||||
agentName: a.agent_name,
|
||||
status: a.status,
|
||||
log: [],
|
||||
sessionId: a.session_id,
|
||||
worktreePath: a.worktree_path,
|
||||
baseBranch: a.base_branch,
|
||||
@@ -261,9 +245,6 @@ export function AgentPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Agents that have streaming content to show
|
||||
const activeAgents = Object.values(agents).filter((a) => a.log.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -420,35 +401,6 @@ export function AgentPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-agent streaming output */}
|
||||
{activeAgents.map((agent) => (
|
||||
<div
|
||||
key={`stream-${agent.agentName}`}
|
||||
data-testid={`agent-stream-${agent.agentName}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{agent.log.length > 0 && (
|
||||
<div
|
||||
data-testid={`agent-output-${agent.agentName}`}
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
fontFamily: "monospace",
|
||||
color: "#ccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
>
|
||||
{agent.log.join("")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{actionError && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -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,117 @@ 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 +1285,175 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,13 +165,22 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
|
||||
const [pipeline, setPipeline] = useState<PipelineState>({
|
||||
upcoming: [],
|
||||
backlog: [],
|
||||
current: [],
|
||||
qa: [],
|
||||
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<
|
||||
{
|
||||
@@ -247,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()
|
||||
@@ -530,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);
|
||||
@@ -664,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.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -969,8 +1017,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Upcoming"
|
||||
items={pipeline.upcoming}
|
||||
title="Backlog"
|
||||
items={pipeline.backlog}
|
||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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'", () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export function ChatHeader({
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
StorkIt
|
||||
Story Kit
|
||||
</span>
|
||||
<div
|
||||
title={projectPath}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
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 +22,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 +121,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 +137,118 @@ 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 +348,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={{
|
||||
|
||||
194
frontend/src/components/ChatInputFilePicker.test.tsx
Normal file
194
frontend/src/components/ChatInputFilePicker.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { StagePanel } from "./StagePanel";
|
||||
|
||||
function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
|
||||
return {
|
||||
upcoming: [],
|
||||
backlog: [],
|
||||
current: [],
|
||||
qa: [],
|
||||
merge: [],
|
||||
|
||||
@@ -115,7 +115,7 @@ export function LozengeFlyProvider({
|
||||
const assignedAgentNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const item of [
|
||||
...pipeline.upcoming,
|
||||
...pipeline.backlog,
|
||||
...pipeline.current,
|
||||
...pipeline.qa,
|
||||
...pipeline.merge,
|
||||
@@ -165,13 +165,13 @@ export function LozengeFlyProvider({
|
||||
|
||||
const prev = prevPipelineRef.current;
|
||||
const allPrev = [
|
||||
...prev.upcoming,
|
||||
...prev.backlog,
|
||||
...prev.current,
|
||||
...prev.qa,
|
||||
...prev.merge,
|
||||
];
|
||||
const allCurr = [
|
||||
...pipeline.upcoming,
|
||||
...pipeline.backlog,
|
||||
...pipeline.current,
|
||||
...pipeline.qa,
|
||||
...pipeline.merge,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -37,6 +37,7 @@ const DEFAULT_CONTENT = {
|
||||
content: "# Big Title\n\nSome content here.",
|
||||
stage: "current",
|
||||
name: "Big Title Story",
|
||||
agent: null,
|
||||
};
|
||||
|
||||
const sampleTestResults: TestResultsResponse = {
|
||||
@@ -436,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);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { api } from "../api/client";
|
||||
const { useEffect, useRef, useState } = React;
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
upcoming: "Upcoming",
|
||||
backlog: "Backlog",
|
||||
current: "Current",
|
||||
qa: "QA",
|
||||
merge: "To Merge",
|
||||
@@ -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"
|
||||
@@ -410,75 +420,96 @@ export function WorkItemDetailPanel({
|
||||
}}
|
||||
>
|
||||
{/* Agent Logs section */}
|
||||
<div
|
||||
data-testid={
|
||||
agentInfo ? "agent-logs-section" : "placeholder-agent-logs"
|
||||
}
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
{!agentInfo && (
|
||||
<div
|
||||
data-testid="placeholder-agent-logs"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: agentInfo ? "6px" : "4px",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: agentInfo ? "#888" : "#555",
|
||||
color: "#555",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Agent Logs
|
||||
</div>
|
||||
{agentInfo && agentStatus && (
|
||||
<div
|
||||
data-testid="agent-status-badge"
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
color: STATUS_COLORS[agentStatus],
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{agentInfo.agent_name} — {agentStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{agentInfo && agentLog.length > 0 ? (
|
||||
<div
|
||||
data-testid="agent-log-output"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontFamily: "monospace",
|
||||
color: "#ccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: "1.5",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{agentLog.join("")}
|
||||
</div>
|
||||
) : agentInfo ? (
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
{agentStatus === "running" || agentStatus === "pending"
|
||||
? "Waiting for output..."
|
||||
: "No output."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
Coming soon
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{agentInfo && (
|
||||
<div
|
||||
data-testid="agent-logs-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Agent Logs
|
||||
</div>
|
||||
{agentStatus && (
|
||||
<div
|
||||
data-testid="agent-status-badge"
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
color: STATUS_COLORS[agentStatus],
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{agentInfo.agent_name} — {agentStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{agentLog.length > 0 ? (
|
||||
<div
|
||||
data-testid="agent-log-output"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontFamily: "monospace",
|
||||
color: "#ccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: "1.5",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{agentLog.join("")}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
{agentStatus === "running" || agentStatus === "pending"
|
||||
? "Waiting for output..."
|
||||
: "No output."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder sections for future content */}
|
||||
{(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
16
script/test
16
script/test
@@ -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"
|
||||
npm 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 ==="
|
||||
npm run 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "story-kit"
|
||||
version = "0.1.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -33,6 +33,7 @@ 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 }
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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.
|
||||
@@ -65,48 +70,20 @@ pub(crate) fn run_project_tests(path: &Path) -> Result<(bool, String), String> {
|
||||
let script_test = path.join("script").join("test");
|
||||
if script_test.exists() {
|
||||
let mut output = String::from("=== script/test ===\n");
|
||||
let result = Command::new(&script_test)
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run script/test: {e}"))?;
|
||||
let out = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let (success, out) = run_command_with_timeout(&script_test, &[], path)?;
|
||||
output.push_str(&out);
|
||||
output.push('\n');
|
||||
return Ok((result.status.success(), output));
|
||||
return Ok((success, output));
|
||||
}
|
||||
|
||||
// Fallback: cargo nextest run / cargo test
|
||||
let mut output = String::from("=== tests ===\n");
|
||||
let (success, test_out) = match Command::new("cargo")
|
||||
.args(["nextest", "run"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&o.stdout),
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
(o.status.success(), combined)
|
||||
}
|
||||
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
|
||||
let o = Command::new("cargo")
|
||||
.args(["test"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run cargo test: {e}"))?;
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&o.stdout),
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
(o.status.success(), combined)
|
||||
run_command_with_timeout("cargo", &["test"], path)
|
||||
.map_err(|e| format!("Failed to run cargo test: {e}"))?
|
||||
}
|
||||
};
|
||||
output.push_str(&test_out);
|
||||
@@ -114,6 +91,66 @@ pub(crate) fn run_project_tests(path: &Path) -> Result<(bool, String), String> {
|
||||
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)`.
|
||||
|
||||
@@ -4,8 +4,7 @@ use std::process::Command;
|
||||
use crate::io::story_metadata::clear_front_matter_field;
|
||||
use crate::slog;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn item_type_from_id(item_id: &str) -> &'static str {
|
||||
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_") {
|
||||
@@ -17,9 +16,9 @@ fn item_type_from_id(item_id: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the source directory path for a work item (always work/1_upcoming/).
|
||||
/// Return the source directory path for a work item (always work/1_backlog/).
|
||||
fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf {
|
||||
project_root.join(".story_kit").join("work").join("1_upcoming")
|
||||
project_root.join(".story_kit").join("work").join("1_backlog")
|
||||
}
|
||||
|
||||
/// Return the done directory path for a work item (always work/5_done/).
|
||||
@@ -27,10 +26,10 @@ 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/`.
|
||||
/// Move a work item (story, bug, or spike) from `work/1_backlog/` 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.
|
||||
/// If the item is not found in `1_backlog/`, 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");
|
||||
@@ -220,16 +219,16 @@ pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_done/` and auto-commit.
|
||||
/// Move a bug from `work/2_current/` or `work/1_backlog/` 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 still in `1_backlog/` (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 backlog_path = sk.join("1_backlog").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"));
|
||||
|
||||
@@ -239,11 +238,11 @@ pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), Str
|
||||
|
||||
let source_path = if current_path.exists() {
|
||||
current_path.clone()
|
||||
} else if upcoming_path.exists() {
|
||||
upcoming_path.clone()
|
||||
} else if backlog_path.exists() {
|
||||
backlog_path.clone()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Bug '{bug_id}' not found in work/2_current/ or work/1_upcoming/. Cannot close bug."
|
||||
"Bug '{bug_id}' not found in work/2_current/ or work/1_backlog/. Cannot close bug."
|
||||
));
|
||||
};
|
||||
|
||||
@@ -270,15 +269,15 @@ mod tests {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(upcoming.join("10_story_foo.md"), "test").unwrap();
|
||||
fs::write(backlog.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!(!backlog.join("10_story_foo.md").exists());
|
||||
assert!(current.join("10_story_foo.md").exists());
|
||||
}
|
||||
|
||||
@@ -296,25 +295,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_current_noop_when_not_in_upcoming() {
|
||||
fn move_story_to_current_noop_when_not_in_backlog() {
|
||||
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() {
|
||||
fn move_bug_to_current_moves_from_backlog() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(upcoming.join("1_bug_test.md"), "# Bug 1\n").unwrap();
|
||||
fs::write(backlog.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!(!backlog.join("1_bug_test.md").exists());
|
||||
assert!(current.join("1_bug_test.md").exists());
|
||||
}
|
||||
|
||||
@@ -336,17 +335,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_bug_moves_from_upcoming_when_not_started() {
|
||||
fn close_bug_moves_from_backlog_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();
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.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!(!backlog.join("3_bug_test.md").exists());
|
||||
assert!(root.join(".story_kit/work/5_done/3_bug_test.md").exists());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -7,6 +8,29 @@ use crate::config::ProjectConfig;
|
||||
|
||||
use super::gates::run_project_tests;
|
||||
|
||||
/// Global lock ensuring only one squash-merge runs at a time.
|
||||
///
|
||||
/// The merge pipeline uses a shared `.story_kit/merge_workspace` directory and
|
||||
/// temporary `merge-queue/{story_id}` branches. If two merges run concurrently,
|
||||
/// the second call's initial cleanup destroys the first call's branch mid-flight,
|
||||
/// causing `git cherry-pick merge-queue/…` to fail with "bad revision".
|
||||
static MERGE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Status of an async merge job.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum MergeJobStatus {
|
||||
Running,
|
||||
Completed(MergeReport),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Tracks a background merge job started by `merge_agent_work`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MergeJob {
|
||||
pub story_id: String,
|
||||
pub status: MergeJobStatus,
|
||||
}
|
||||
|
||||
/// Result of a mergemaster merge operation.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct MergeReport {
|
||||
@@ -57,6 +81,11 @@ pub(crate) fn run_squash_merge(
|
||||
branch: &str,
|
||||
story_id: &str,
|
||||
) -> Result<SquashMergeResult, String> {
|
||||
// Acquire the merge lock so concurrent calls don't clobber each other.
|
||||
let _lock = MERGE_LOCK
|
||||
.lock()
|
||||
.map_err(|e| format!("Merge lock poisoned: {e}"))?;
|
||||
|
||||
let mut all_output = String::new();
|
||||
let merge_branch = format!("merge-queue/{story_id}");
|
||||
let merge_wt_path = project_root
|
||||
|
||||
@@ -124,6 +124,10 @@ pub struct AgentPool {
|
||||
/// an `AgentStateChanged` event is emitted so the frontend can refresh the
|
||||
/// pipeline board without waiting for a filesystem event.
|
||||
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
/// Tracks background merge jobs started by `merge_agent_work`, keyed by story_id.
|
||||
/// The MCP tool returns immediately and the mergemaster agent polls
|
||||
/// `get_merge_status` until the job reaches a terminal state.
|
||||
merge_jobs: Arc<Mutex<HashMap<String, super::merge::MergeJob>>>,
|
||||
}
|
||||
|
||||
impl AgentPool {
|
||||
@@ -133,6 +137,7 @@ impl AgentPool {
|
||||
port,
|
||||
child_killers: Arc::new(Mutex::new(HashMap::new())),
|
||||
watcher_tx,
|
||||
merge_jobs: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +212,7 @@ impl AgentPool {
|
||||
let event_log: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let log_session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Move story from upcoming/ to current/ before checking agent
|
||||
// Move story from backlog/ to current/ before checking agent
|
||||
// availability so that auto_assign_available_work can pick it up even
|
||||
// when all coders are currently busy (story 203). This is idempotent:
|
||||
// if the story is already in 2_current/ or a later stage, the call is
|
||||
@@ -884,21 +889,39 @@ impl AgentPool {
|
||||
};
|
||||
|
||||
if coverage_passed {
|
||||
slog!(
|
||||
"[pipeline] QA passed gates and coverage for '{story_id}'. Moving to merge."
|
||||
);
|
||||
if let Err(e) = super::lifecycle::move_story_to_merge(&project_root, story_id) {
|
||||
slog_error!("[pipeline] Failed to move '{story_id}' to 4_merge/: {e}");
|
||||
return;
|
||||
// Spikes skip merge — they stay in 3_qa/ for human review.
|
||||
if super::lifecycle::item_type_from_id(story_id) == "spike" {
|
||||
// Mark the spike as held for review so auto-assign won't
|
||||
// restart QA on it.
|
||||
let qa_dir = project_root.join(".story_kit/work/3_qa");
|
||||
let spike_path = qa_dir.join(format!("{story_id}.md"));
|
||||
if let Err(e) = crate::io::story_metadata::write_review_hold(&spike_path) {
|
||||
slog_error!("[pipeline] Failed to set review_hold on '{story_id}': {e}");
|
||||
}
|
||||
slog!(
|
||||
"[pipeline] QA passed for spike '{story_id}'. \
|
||||
Stopping for human review (skipping merge). \
|
||||
Worktree preserved at: {worktree_path:?}"
|
||||
);
|
||||
// Free up the QA slot without advancing the spike.
|
||||
self.auto_assign_available_work(&project_root).await;
|
||||
} else {
|
||||
slog!(
|
||||
"[pipeline] QA passed gates and coverage for '{story_id}'. Moving to merge."
|
||||
);
|
||||
if let Err(e) = super::lifecycle::move_story_to_merge(&project_root, story_id) {
|
||||
slog_error!("[pipeline] Failed to move '{story_id}' to 4_merge/: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self
|
||||
.start_agent(&project_root, story_id, Some("mergemaster"), None)
|
||||
.await
|
||||
{
|
||||
slog_error!("[pipeline] Failed to start mergemaster for '{story_id}': {e}");
|
||||
}
|
||||
// QA slot is now free — pick up any other unassigned work in 3_qa/.
|
||||
self.auto_assign_available_work(&project_root).await;
|
||||
}
|
||||
if let Err(e) = self
|
||||
.start_agent(&project_root, story_id, Some("mergemaster"), None)
|
||||
.await
|
||||
{
|
||||
slog_error!("[pipeline] Failed to start mergemaster for '{story_id}': {e}");
|
||||
}
|
||||
// QA slot is now free — pick up any other unassigned work in 3_qa/.
|
||||
self.auto_assign_available_work(&project_root).await;
|
||||
} else {
|
||||
slog!(
|
||||
"[pipeline] QA coverage gate failed for '{story_id}'. Restarting QA."
|
||||
@@ -1110,6 +1133,7 @@ impl AgentPool {
|
||||
port: self.port,
|
||||
child_killers: Arc::clone(&self.child_killers),
|
||||
watcher_tx: self.watcher_tx.clone(),
|
||||
merge_jobs: Arc::clone(&self.merge_jobs),
|
||||
};
|
||||
let sid = story_id.to_string();
|
||||
let aname = agent_name.to_string();
|
||||
@@ -1138,8 +1162,71 @@ impl AgentPool {
|
||||
/// 4. If gates pass: cherry-pick the squash commit onto master and archive the story.
|
||||
///
|
||||
/// Returns a `MergeReport` with full details of what happened.
|
||||
pub async fn merge_agent_work(
|
||||
&self,
|
||||
/// Start the merge pipeline as a background task.
|
||||
///
|
||||
/// Returns immediately so the MCP tool call doesn't time out (the full
|
||||
/// pipeline — squash merge + quality gates — takes well over 60 seconds,
|
||||
/// exceeding Claude Code's MCP tool-call timeout).
|
||||
///
|
||||
/// The mergemaster agent should poll [`get_merge_status`](Self::get_merge_status)
|
||||
/// until the job reaches a terminal state.
|
||||
pub fn start_merge_agent_work(
|
||||
self: &Arc<Self>,
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
) -> Result<(), String> {
|
||||
// Guard against double-starts.
|
||||
{
|
||||
let jobs = self.merge_jobs.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(job) = jobs.get(story_id)
|
||||
&& matches!(job.status, super::merge::MergeJobStatus::Running)
|
||||
{
|
||||
return Err(format!(
|
||||
"Merge already in progress for '{story_id}'. \
|
||||
Use get_merge_status to poll for completion."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Insert Running job.
|
||||
{
|
||||
let mut jobs = self.merge_jobs.lock().map_err(|e| e.to_string())?;
|
||||
jobs.insert(
|
||||
story_id.to_string(),
|
||||
super::merge::MergeJob {
|
||||
story_id: story_id.to_string(),
|
||||
status: super::merge::MergeJobStatus::Running,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let pool = Arc::clone(self);
|
||||
let root = project_root.to_path_buf();
|
||||
let sid = story_id.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let report = pool.run_merge_pipeline(&root, &sid).await;
|
||||
let failed = report.is_err();
|
||||
let status = match report {
|
||||
Ok(r) => super::merge::MergeJobStatus::Completed(r),
|
||||
Err(e) => super::merge::MergeJobStatus::Failed(e),
|
||||
};
|
||||
if let Ok(mut jobs) = pool.merge_jobs.lock()
|
||||
&& let Some(job) = jobs.get_mut(&sid)
|
||||
{
|
||||
job.status = status;
|
||||
}
|
||||
if failed {
|
||||
pool.auto_assign_available_work(&root).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The actual merge pipeline, run inside a background task.
|
||||
async fn run_merge_pipeline(
|
||||
self: &Arc<Self>,
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
) -> Result<super::merge::MergeReport, String> {
|
||||
@@ -1149,8 +1236,6 @@ impl AgentPool {
|
||||
let sid = story_id.to_string();
|
||||
let br = branch.clone();
|
||||
|
||||
// Run blocking operations (git + cargo + quality gates) off the async runtime.
|
||||
// Quality gates now run inside run_squash_merge before the fast-forward.
|
||||
let merge_result =
|
||||
tokio::task::spawn_blocking(move || super::merge::run_squash_merge(&root, &br, &sid))
|
||||
.await
|
||||
@@ -1170,13 +1255,11 @@ impl AgentPool {
|
||||
});
|
||||
}
|
||||
|
||||
// Merge + gates both passed — archive the story and clean up agent entries.
|
||||
let story_archived = super::lifecycle::move_story_to_archived(project_root, story_id).is_ok();
|
||||
if story_archived {
|
||||
self.remove_agents_for_story(story_id);
|
||||
}
|
||||
|
||||
// Clean up the worktree if it exists.
|
||||
let worktree_cleaned_up = if wt_path.exists() {
|
||||
let config = crate::config::ProjectConfig::load(project_root)
|
||||
.unwrap_or_default();
|
||||
@@ -1187,10 +1270,6 @@ impl AgentPool {
|
||||
false
|
||||
};
|
||||
|
||||
// Mergemaster slot is now free — trigger auto-assign so remaining
|
||||
// items in 4_merge/ (or other stages) get picked up. The normal
|
||||
// server-owned completion handler won't run because we already
|
||||
// removed the agent entry above.
|
||||
self.auto_assign_available_work(project_root).await;
|
||||
|
||||
Ok(super::merge::MergeReport {
|
||||
@@ -1206,9 +1285,12 @@ impl AgentPool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the port this server is running on.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
/// Check the status of a background merge job.
|
||||
pub fn get_merge_status(&self, story_id: &str) -> Option<super::merge::MergeJob> {
|
||||
self.merge_jobs
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|jobs| jobs.get(story_id).cloned())
|
||||
}
|
||||
|
||||
/// Get project root helper.
|
||||
@@ -1348,7 +1430,7 @@ impl AgentPool {
|
||||
///
|
||||
/// Scans `work/2_current/`, `work/3_qa/`, and `work/4_merge/` for items that have no
|
||||
/// active agent and assigns the first free agent of the appropriate role. Items in
|
||||
/// `work/1_upcoming/` are never auto-started.
|
||||
/// `work/1_backlog/` are never auto-started.
|
||||
///
|
||||
/// Respects the configured agent roster: the maximum number of concurrently active agents
|
||||
/// per role is bounded by the count of agents of that role defined in `project.toml`.
|
||||
@@ -1375,9 +1457,23 @@ impl AgentPool {
|
||||
}
|
||||
|
||||
for story_id in &items {
|
||||
// Items marked with review_hold (e.g. spikes after QA passes) stay
|
||||
// in their current stage for human review — don't auto-assign agents.
|
||||
if has_review_hold(project_root, stage_dir, story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-acquire the lock on each iteration to see state changes
|
||||
// from previous start_agent calls in the same pass.
|
||||
let (already_assigned, free_agent) = {
|
||||
let preferred_agent =
|
||||
read_story_front_matter_agent(project_root, stage_dir, story_id);
|
||||
|
||||
// Outcome: (already_assigned, chosen_agent, preferred_busy, stage_mismatch)
|
||||
// preferred_busy=true means the story has a specific agent requested but it is
|
||||
// currently occupied — the story should wait rather than fall back.
|
||||
// stage_mismatch=true means the preferred agent's stage doesn't match the
|
||||
// pipeline stage, so we fell back to a generic stage agent.
|
||||
let (already_assigned, free_agent, preferred_busy, stage_mismatch) = {
|
||||
let agents = match self.agents.lock() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
@@ -1386,13 +1482,31 @@ impl AgentPool {
|
||||
}
|
||||
};
|
||||
let assigned = is_story_assigned_for_stage(&config, &agents, story_id, stage);
|
||||
let free = if assigned {
|
||||
None
|
||||
if assigned {
|
||||
(true, None, false, false)
|
||||
} else if let Some(ref pref) = preferred_agent {
|
||||
// Story has a front-matter agent preference.
|
||||
// Verify the preferred agent's stage matches the current
|
||||
// pipeline stage — a coder shouldn't be assigned to QA.
|
||||
let pref_stage_matches = config
|
||||
.find_agent(pref)
|
||||
.map(|cfg| agent_config_stage(cfg) == *stage)
|
||||
.unwrap_or(false);
|
||||
if !pref_stage_matches {
|
||||
// Stage mismatch — fall back to any free agent for this stage.
|
||||
let free = find_free_agent_for_stage(&config, &agents, stage)
|
||||
.map(|s| s.to_string());
|
||||
(false, free, false, true)
|
||||
} else if is_agent_free(&agents, pref) {
|
||||
(false, Some(pref.clone()), false, false)
|
||||
} else {
|
||||
(false, None, true, false)
|
||||
}
|
||||
} else {
|
||||
find_free_agent_for_stage(&config, &agents, stage)
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
(assigned, free)
|
||||
let free = find_free_agent_for_stage(&config, &agents, stage)
|
||||
.map(|s| s.to_string());
|
||||
(false, free, false, false)
|
||||
}
|
||||
};
|
||||
|
||||
if already_assigned {
|
||||
@@ -1400,6 +1514,23 @@ impl AgentPool {
|
||||
continue;
|
||||
}
|
||||
|
||||
if preferred_busy {
|
||||
// The story requests a specific agent that is currently busy.
|
||||
// Do not fall back to a different agent; let this story wait.
|
||||
slog!(
|
||||
"[auto-assign] Preferred agent '{}' busy for '{story_id}'; story will wait.",
|
||||
preferred_agent.as_deref().unwrap_or("?")
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if stage_mismatch {
|
||||
slog!(
|
||||
"[auto-assign] Preferred agent '{}' stage mismatch for '{story_id}' in {stage_dir}/; falling back to stage-appropriate agent.",
|
||||
preferred_agent.as_deref().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
match free_agent {
|
||||
Some(agent_name) => {
|
||||
slog!(
|
||||
@@ -1472,7 +1603,7 @@ impl AgentPool {
|
||||
// Determine which active stage the story is in.
|
||||
let stage_dir = match find_active_story_stage(project_root, story_id) {
|
||||
Some(s) => s,
|
||||
None => continue, // Not in any active stage (upcoming/archived or unknown).
|
||||
None => continue, // Not in any active stage (backlog/archived or unknown).
|
||||
};
|
||||
|
||||
// 4_merge/ is left for auto_assign to handle with a fresh mergemaster.
|
||||
@@ -1615,7 +1746,25 @@ impl AgentPool {
|
||||
};
|
||||
|
||||
if coverage_passed {
|
||||
if let Err(e) = super::lifecycle::move_story_to_merge(project_root, story_id) {
|
||||
// Spikes skip the merge stage — stay in 3_qa/ for human review.
|
||||
if super::lifecycle::item_type_from_id(story_id) == "spike" {
|
||||
let spike_path = project_root
|
||||
.join(".story_kit/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Err(e) = crate::io::story_metadata::write_review_hold(&spike_path) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to set review_hold on spike '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"[startup:reconcile] Spike '{story_id}' passed QA — holding for human review."
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "review_hold".to_string(),
|
||||
message: "Spike passed QA — waiting for human review.".to_string(),
|
||||
});
|
||||
} else if let Err(e) = super::lifecycle::move_story_to_merge(project_root, story_id) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||
);
|
||||
@@ -1815,6 +1964,47 @@ fn find_active_story_stage(project_root: &Path, story_id: &str) -> Option<&'stat
|
||||
|
||||
/// Scan a work pipeline stage directory and return story IDs, sorted alphabetically.
|
||||
/// Returns an empty `Vec` if the directory does not exist.
|
||||
/// Read the optional `agent:` field from the front matter of a story file.
|
||||
///
|
||||
/// Returns `Some(agent_name)` if the front matter specifies an agent, or `None`
|
||||
/// if the field is absent or the file cannot be read / parsed.
|
||||
fn read_story_front_matter_agent(project_root: &Path, stage_dir: &str, story_id: &str) -> Option<String> {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let path = project_root
|
||||
.join(".story_kit")
|
||||
.join("work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let contents = std::fs::read_to_string(path).ok()?;
|
||||
parse_front_matter(&contents).ok()?.agent
|
||||
}
|
||||
|
||||
/// Return `true` if the story file in the given stage has `review_hold: true` in its front matter.
|
||||
fn has_review_hold(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let path = project_root
|
||||
.join(".story_kit")
|
||||
.join("work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.review_hold)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if `agent_name` has no active (pending/running) entry in the pool.
|
||||
fn is_agent_free(agents: &HashMap<String, StoryAgent>, agent_name: &str) -> bool {
|
||||
!agents.values().any(|a| {
|
||||
a.agent_name == agent_name
|
||||
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_stage_items(project_root: &Path, stage_dir: &str) -> Vec<String> {
|
||||
let dir = project_root
|
||||
.join(".story_kit")
|
||||
@@ -2082,6 +2272,7 @@ fn spawn_pipeline_advance(
|
||||
port,
|
||||
child_killers: Arc::new(Mutex::new(HashMap::new())),
|
||||
watcher_tx,
|
||||
merge_jobs: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
pool.run_pipeline_advance(
|
||||
&sid,
|
||||
@@ -2098,6 +2289,7 @@ fn spawn_pipeline_advance(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::merge::{MergeJob, MergeJobStatus};
|
||||
use crate::agents::{
|
||||
AgentEvent, AgentStatus, CompletionReport, PipelineStage, ReconciliationEvent,
|
||||
lifecycle::move_story_to_archived,
|
||||
@@ -2536,8 +2728,8 @@ mod tests {
|
||||
fs::write(current.join("173_story_test.md"), "test").unwrap();
|
||||
// Ensure 3_qa/ exists for the move target
|
||||
fs::create_dir_all(root.join(".story_kit/work/3_qa")).unwrap();
|
||||
// Ensure 1_upcoming/ exists (start_agent calls move_story_to_current)
|
||||
fs::create_dir_all(root.join(".story_kit/work/1_upcoming")).unwrap();
|
||||
// Ensure 1_backlog/ exists (start_agent calls move_story_to_current)
|
||||
fs::create_dir_all(root.join(".story_kit/work/1_backlog")).unwrap();
|
||||
|
||||
// Write a project.toml with a qa agent so start_agent can resolve it.
|
||||
fs::create_dir_all(root.join(".story_kit")).unwrap();
|
||||
@@ -3306,14 +3498,14 @@ stage = "coder"
|
||||
}
|
||||
|
||||
/// Story 203: when all coders are busy the story file must be moved from
|
||||
/// 1_upcoming/ to 2_current/ so that auto_assign_available_work can pick
|
||||
/// 1_backlog/ to 2_current/ so that auto_assign_available_work can pick
|
||||
/// it up once a coder finishes.
|
||||
#[tokio::test]
|
||||
async fn start_agent_moves_story_to_current_when_coders_busy() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
let upcoming = sk.join("work/1_upcoming");
|
||||
std::fs::create_dir_all(&upcoming).unwrap();
|
||||
let backlog = sk.join("work/1_backlog");
|
||||
std::fs::create_dir_all(&backlog).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
r#"
|
||||
@@ -3323,9 +3515,9 @@ stage = "coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Place the story in 1_upcoming/.
|
||||
// Place the story in 1_backlog/.
|
||||
std::fs::write(
|
||||
upcoming.join("story-3.md"),
|
||||
backlog.join("story-3.md"),
|
||||
"---\nname: Story 3\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -3355,10 +3547,10 @@ stage = "coder"
|
||||
current_path.exists(),
|
||||
"story should be in 2_current/ after busy error, but was not"
|
||||
);
|
||||
let upcoming_path = upcoming.join("story-3.md");
|
||||
let backlog_path = backlog.join("story-3.md");
|
||||
assert!(
|
||||
!upcoming_path.exists(),
|
||||
"story should no longer be in 1_upcoming/"
|
||||
!backlog_path.exists(),
|
||||
"story should no longer be in 1_backlog/"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3582,7 +3774,7 @@ stage = "coder"
|
||||
|
||||
// Create the story in upcoming so `move_story_to_current` succeeds,
|
||||
// but do NOT init a git repo — `create_worktree` will fail in the spawn.
|
||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
||||
let upcoming = root.join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::write(
|
||||
upcoming.join("50_story_test.md"),
|
||||
@@ -3732,7 +3924,7 @@ stage = "coder"
|
||||
let root = tmp.path().to_path_buf();
|
||||
|
||||
let sk_dir = root.join(".story_kit");
|
||||
fs::create_dir_all(sk_dir.join("work/1_upcoming")).unwrap();
|
||||
fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap();
|
||||
fs::write(
|
||||
root.join(".story_kit/project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\n",
|
||||
@@ -3741,12 +3933,12 @@ stage = "coder"
|
||||
// Both stories must exist in upcoming so move_story_to_current can run
|
||||
// (only the winner reaches that point, but we set both up defensively).
|
||||
fs::write(
|
||||
root.join(".story_kit/work/1_upcoming/86_story_foo.md"),
|
||||
root.join(".story_kit/work/1_backlog/86_story_foo.md"),
|
||||
"---\nname: Foo\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join(".story_kit/work/1_upcoming/130_story_bar.md"),
|
||||
root.join(".story_kit/work/1_backlog/130_story_bar.md"),
|
||||
"---\nname: Bar\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -3946,14 +4138,14 @@ stage = "coder"
|
||||
let root = tmp.path();
|
||||
|
||||
let sk_dir = root.join(".story_kit");
|
||||
fs::create_dir_all(sk_dir.join("work/1_upcoming")).unwrap();
|
||||
fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap();
|
||||
fs::write(
|
||||
root.join(".story_kit/project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\n\n[[agent]]\nname = \"coder-2\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join(".story_kit/work/1_upcoming/99_story_baz.md"),
|
||||
root.join(".story_kit/work/1_backlog/99_story_baz.md"),
|
||||
"---\nname: Baz\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -4041,6 +4233,23 @@ stage = "coder"
|
||||
|
||||
// ── merge_agent_work tests ────────────────────────────────────────────────
|
||||
|
||||
/// Helper: start a merge and poll until terminal state.
|
||||
async fn run_merge_to_completion(
|
||||
pool: &Arc<AgentPool>,
|
||||
repo: &std::path::Path,
|
||||
story_id: &str,
|
||||
) -> MergeJob {
|
||||
pool.start_merge_agent_work(repo, story_id).unwrap();
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
if let Some(job) = pool.get_merge_status(story_id)
|
||||
&& !matches!(job.status, MergeJobStatus::Running)
|
||||
{
|
||||
return job;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_agent_work_returns_error_when_branch_not_found() {
|
||||
use tempfile::tempdir;
|
||||
@@ -4049,14 +4258,19 @@ stage = "coder"
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
// branch feature/story-99_nonexistent does not exist
|
||||
let result = pool
|
||||
.merge_agent_work(repo, "99_nonexistent")
|
||||
.await
|
||||
.unwrap();
|
||||
// Should fail (no branch) — not panic
|
||||
assert!(!result.success, "should fail when branch missing");
|
||||
let pool = Arc::new(AgentPool::new_test(3001));
|
||||
let job = run_merge_to_completion(&pool, repo, "99_nonexistent").await;
|
||||
match &job.status {
|
||||
MergeJobStatus::Completed(report) => {
|
||||
assert!(!report.success, "should fail when branch missing");
|
||||
}
|
||||
MergeJobStatus::Failed(_) => {
|
||||
// Also acceptable — the pipeline errored out
|
||||
}
|
||||
MergeJobStatus::Running => {
|
||||
panic!("should not still be running");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4109,22 +4323,29 @@ stage = "coder"
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let report = pool.merge_agent_work(repo, "23_test").await.unwrap();
|
||||
let pool = Arc::new(AgentPool::new_test(3001));
|
||||
let job = run_merge_to_completion(&pool, repo, "23_test").await;
|
||||
|
||||
// Merge should succeed (gates will run but cargo/pnpm results will depend on env)
|
||||
// At minimum the merge itself should succeed
|
||||
assert!(!report.had_conflicts, "should have no conflicts");
|
||||
// Note: gates_passed may be false in test env without Rust project, that's OK
|
||||
// The important thing is the merge itself ran
|
||||
assert!(
|
||||
report.success || report.gate_output.contains("Failed to run") || !report.gates_passed,
|
||||
"report should be coherent: {report:?}"
|
||||
);
|
||||
// Story should be in done if gates passed
|
||||
if report.story_archived {
|
||||
let done = repo.join(".story_kit/work/5_done/23_test.md");
|
||||
assert!(done.exists(), "done file should exist");
|
||||
match &job.status {
|
||||
MergeJobStatus::Completed(report) => {
|
||||
assert!(!report.had_conflicts, "should have no conflicts");
|
||||
assert!(
|
||||
report.success || report.gate_output.contains("Failed to run") || !report.gates_passed,
|
||||
"report should be coherent: {report:?}"
|
||||
);
|
||||
if report.story_archived {
|
||||
let done = repo.join(".story_kit/work/5_done/23_test.md");
|
||||
assert!(done.exists(), "done file should exist");
|
||||
}
|
||||
}
|
||||
MergeJobStatus::Failed(e) => {
|
||||
// Gate failures are acceptable in test env
|
||||
assert!(
|
||||
e.contains("Failed") || e.contains("failed"),
|
||||
"unexpected failure: {e}"
|
||||
);
|
||||
}
|
||||
MergeJobStatus::Running => panic!("should not still be running"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4302,8 +4523,8 @@ stage = "coder"
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let report = pool.merge_agent_work(repo, "42_story_foo").await.unwrap();
|
||||
let pool = Arc::new(AgentPool::new_test(3001));
|
||||
let job = run_merge_to_completion(&pool, repo, "42_story_foo").await;
|
||||
|
||||
// Master should NEVER have conflict markers, regardless of merge outcome.
|
||||
let master_code = fs::read_to_string(repo.join("code.rs")).unwrap();
|
||||
@@ -4317,7 +4538,15 @@ stage = "coder"
|
||||
);
|
||||
|
||||
// The report should accurately reflect what happened.
|
||||
assert!(report.had_conflicts, "should report conflicts");
|
||||
match &job.status {
|
||||
MergeJobStatus::Completed(report) => {
|
||||
assert!(report.had_conflicts, "should report conflicts");
|
||||
}
|
||||
MergeJobStatus::Failed(_) => {
|
||||
// Acceptable — merge aborted due to conflicts
|
||||
}
|
||||
MergeJobStatus::Running => panic!("should not still be running"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── reconcile_on_startup tests ────────────────────────────────────────────
|
||||
@@ -4467,4 +4696,202 @@ stage = "coder"
|
||||
"story should be in 2_current/ or 3_qa/ after reconciliation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_true_when_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let qa_dir = tmp.path().join(".story_kit/work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
let spike_path = qa_dir.join("10_spike_research.md");
|
||||
std::fs::write(
|
||||
&spike_path,
|
||||
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_not_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let qa_dir = tmp.path().join(".story_kit/work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
let spike_path = qa_dir.join("10_spike_research.md");
|
||||
std::fs::write(
|
||||
&spike_path,
|
||||
"---\nname: Research spike\n---\n# Spike\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
|
||||
}
|
||||
|
||||
/// Story 265: auto_assign_available_work must skip spikes in 3_qa/ that
|
||||
/// have review_hold: true set in their front matter.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_skips_spikes_with_review_hold() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Create project.toml with a QA agent.
|
||||
let sk = root.join(".story_kit");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agents]]\nname = \"qa\"\nrole = \"qa\"\nmodel = \"test\"\nprompt = \"test\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Put a spike in 3_qa/ with review_hold: true.
|
||||
let qa_dir = root.join(".story_kit/work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
std::fs::write(
|
||||
qa_dir.join("20_spike_test.md"),
|
||||
"---\nname: Test Spike\nreview_hold: true\n---\n# Spike\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(4);
|
||||
let pool = AgentPool::new(3001, watcher_tx);
|
||||
|
||||
pool.auto_assign_available_work(root).await;
|
||||
|
||||
// No agent should have been started for the spike.
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
assert!(
|
||||
agents.is_empty(),
|
||||
"No agents should be assigned to a spike with review_hold"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story 279: auto-assign respects agent stage from front matter ──────────
|
||||
|
||||
/// When a story in 3_qa/ has `agent: coder-1` in its front matter but
|
||||
/// coder-1 is a coder-stage agent, auto-assign must NOT assign coder-1.
|
||||
/// Instead it should fall back to a free QA-stage agent.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_ignores_coder_preference_when_story_is_in_qa_stage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
let qa_dir = sk.join("work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
|
||||
[[agent]]\nname = \"qa-1\"\nstage = \"qa\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Story in 3_qa/ with a preferred coder-stage agent.
|
||||
std::fs::write(
|
||||
qa_dir.join("story-qa1.md"),
|
||||
"---\nname: QA Story\nagent: coder-1\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
// coder-1 must NOT have been assigned (wrong stage for 3_qa/).
|
||||
let coder_assigned = agents
|
||||
.values()
|
||||
.any(|a| a.agent_name == "coder-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
|
||||
assert!(
|
||||
!coder_assigned,
|
||||
"coder-1 should not be assigned to a QA-stage story"
|
||||
);
|
||||
// qa-1 should have been assigned instead.
|
||||
let qa_assigned = agents
|
||||
.values()
|
||||
.any(|a| a.agent_name == "qa-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
|
||||
assert!(
|
||||
qa_assigned,
|
||||
"qa-1 should be assigned as fallback for the QA-stage story"
|
||||
);
|
||||
}
|
||||
|
||||
/// When a story in 2_current/ has `agent: coder-1` in its front matter and
|
||||
/// coder-1 is a coder-stage agent, auto-assign must respect the preference
|
||||
/// and assign coder-1 (not fall back to some other coder).
|
||||
#[tokio::test]
|
||||
async fn auto_assign_respects_coder_preference_when_story_is_in_current_stage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
let current_dir = sk.join("work/2_current");
|
||||
std::fs::create_dir_all(¤t_dir).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
|
||||
[[agent]]\nname = \"coder-2\"\nstage = \"coder\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Story in 2_current/ with a preferred coder-1 agent.
|
||||
std::fs::write(
|
||||
current_dir.join("story-pref.md"),
|
||||
"---\nname: Coder Story\nagent: coder-1\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
// coder-1 should have been picked (it matches the stage and is preferred).
|
||||
let coder1_assigned = agents
|
||||
.values()
|
||||
.any(|a| a.agent_name == "coder-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
|
||||
assert!(
|
||||
coder1_assigned,
|
||||
"coder-1 should be assigned when it matches the stage and is preferred"
|
||||
);
|
||||
// coder-2 must NOT be assigned (not preferred).
|
||||
let coder2_assigned = agents
|
||||
.values()
|
||||
.any(|a| a.agent_name == "coder-2" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
|
||||
assert!(
|
||||
!coder2_assigned,
|
||||
"coder-2 should not be assigned when coder-1 is explicitly preferred"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the preferred agent's stage mismatches and no other agent of the
|
||||
/// correct stage is available, auto-assign must not start any agent for that
|
||||
/// story (no panic, no error).
|
||||
#[tokio::test]
|
||||
async fn auto_assign_stage_mismatch_with_no_fallback_starts_no_agent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
let qa_dir = sk.join("work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
// Only a coder agent is configured — no QA agent exists.
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Story in 3_qa/ requests coder-1 (wrong stage) and no QA agent exists.
|
||||
std::fs::write(
|
||||
qa_dir.join("story-noqa.md"),
|
||||
"---\nname: QA Story No Agent\nagent: coder-1\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
|
||||
// Must not panic.
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
assert!(
|
||||
agents.is_empty(),
|
||||
"No agent should be started when no stage-appropriate agent is available"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
@@ -332,7 +339,7 @@ impl AgentsApi {
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let stages = [
|
||||
("1_upcoming", "upcoming"),
|
||||
("1_backlog", "backlog"),
|
||||
("2_current", "current"),
|
||||
("3_qa", "qa"),
|
||||
("4_merge", "merge"),
|
||||
@@ -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>> {
|
||||
@@ -762,12 +809,12 @@ allowed_tools = ["Read", "Bash"]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_content_from_upcoming() {
|
||||
async fn get_work_item_content_returns_content_from_backlog() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
make_stage_dir(root, "1_upcoming");
|
||||
make_stage_dir(root, "1_backlog");
|
||||
std::fs::write(
|
||||
root.join(".story_kit/work/1_upcoming/42_story_foo.md"),
|
||||
root.join(".story_kit/work/1_backlog/42_story_foo.md"),
|
||||
"---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -781,7 +828,7 @@ allowed_tools = ["Read", "Bash"]
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(result.content.contains("Some content."));
|
||||
assert_eq!(result.stage, "upcoming");
|
||||
assert_eq!(result.stage, "backlog");
|
||||
assert_eq!(result.name, Some("Foo Story".to_string()));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -972,7 +1113,7 @@ allowed_tools = ["Read", "Bash"]
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
// Create work dirs including 2_current for the story file.
|
||||
for stage in &["1_upcoming", "2_current", "5_done", "6_archived"] {
|
||||
for stage in &["1_backlog", "2_current", "5_done", "6_archived"] {
|
||||
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::agents::{close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived, move_story_to_merge, move_story_to_qa, PipelineStage};
|
||||
use crate::agents::{close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived, move_story_to_merge, move_story_to_qa, AgentStatus, PipelineStage};
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::log_buffer;
|
||||
use crate::slog;
|
||||
@@ -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"]
|
||||
@@ -660,7 +672,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
{
|
||||
"name": "create_spike",
|
||||
"description": "Create a spike file in .story_kit/work/1_upcoming/ with a deterministic filename and YAML front matter. Returns the spike_id.",
|
||||
"description": "Create a spike file in .story_kit/work/1_backlog/ with a deterministic filename and YAML front matter. Returns the spike_id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -678,7 +690,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
{
|
||||
"name": "create_bug",
|
||||
"description": "Create a bug file in work/1_upcoming/ with a deterministic filename and auto-commit to master. Returns the bug_id.",
|
||||
"description": "Create a bug file in work/1_backlog/ with a deterministic filename and auto-commit to master. Returns the bug_id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -713,7 +725,38 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
{
|
||||
"name": "list_bugs",
|
||||
"description": "List all open bugs in work/1_upcoming/ matching the _bug_ naming convention.",
|
||||
"description": "List all open bugs in work/1_backlog/ matching the _bug_ naming convention.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_refactor",
|
||||
"description": "Create a refactor work item in work/1_backlog/ 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_backlog/ matching the _refactor_ naming convention.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
@@ -721,7 +764,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
{
|
||||
"name": "close_bug",
|
||||
"description": "Archive a bug from work/2_current/ or work/1_upcoming/ to work/5_done/ and auto-commit to master.",
|
||||
"description": "Archive a bug from work/2_current/ or work/1_backlog/ to work/5_done/ and auto-commit to master.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -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.",
|
||||
@@ -826,6 +891,14 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rebuild_and_restart",
|
||||
"description": "Rebuild the server binary from source and re-exec with the new binary. Gracefully stops all running agents before restart. If the build fails, the server stays up and returns the build error.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_permission",
|
||||
"description": "Present a permission request to the user via the web UI. Used by Claude Code's --permission-prompt-tool to delegate permission decisions to the frontend dialog. Returns on approval; returns an error on denial.",
|
||||
@@ -896,14 +969,22 @@ 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),
|
||||
// Server lifecycle
|
||||
"rebuild_and_restart" => tool_rebuild_and_restart(ctx).await,
|
||||
// Permission bridge (Claude Code → frontend dialog)
|
||||
"prompt_permission" => tool_prompt_permission(&args, ctx).await,
|
||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||
@@ -941,7 +1022,7 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
.get("acceptance_criteria")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
// Spike 61: write the file only — the filesystem watcher detects the new
|
||||
// .md file in work/1_upcoming/ and auto-commits with a deterministic message.
|
||||
// .md file in work/1_backlog/ and auto-commits with a deterministic message.
|
||||
let commit = false;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
@@ -983,6 +1064,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 backlog: Vec<Value> = state
|
||||
.backlog
|
||||
.iter()
|
||||
.map(|s| json!({ "story_id": s.story_id, "name": s.name }))
|
||||
.collect();
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"active": active,
|
||||
"backlog": backlog,
|
||||
"backlog_count": backlog.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 +1617,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 +1720,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 +1877,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() {
|
||||
@@ -1913,6 +2118,100 @@ fn add_permission_rule(project_root: &std::path::Path, rule: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the server binary and re-exec.
|
||||
///
|
||||
/// 1. Gracefully stops all running agents (kills PTY children).
|
||||
/// 2. Runs `cargo build [-p story-kit]` from the workspace root, matching
|
||||
/// the current build profile (debug or release).
|
||||
/// 3. If the build fails, returns the build error (server stays up).
|
||||
/// 4. If the build succeeds, re-execs the process with the new binary via
|
||||
/// `std::os::unix::process::CommandExt::exec()`.
|
||||
async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String, String> {
|
||||
slog!("[rebuild] Rebuild and restart requested via MCP tool");
|
||||
|
||||
// 1. Gracefully stop all running agents.
|
||||
let running_agents = ctx.agents.list_agents().unwrap_or_default();
|
||||
let running_count = running_agents
|
||||
.iter()
|
||||
.filter(|a| a.status == AgentStatus::Running)
|
||||
.count();
|
||||
if running_count > 0 {
|
||||
slog!("[rebuild] Stopping {running_count} running agent(s) before rebuild");
|
||||
}
|
||||
ctx.agents.kill_all_children();
|
||||
|
||||
// 2. Find the workspace root (parent of the server binary's source).
|
||||
// CARGO_MANIFEST_DIR at compile time points to the `server/` crate;
|
||||
// the workspace root is its parent.
|
||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = manifest_dir
|
||||
.parent()
|
||||
.ok_or_else(|| "Cannot determine workspace root from CARGO_MANIFEST_DIR".to_string())?;
|
||||
|
||||
slog!(
|
||||
"[rebuild] Building server from workspace root: {}",
|
||||
workspace_root.display()
|
||||
);
|
||||
|
||||
// 3. Build the server binary, matching the current build profile so the
|
||||
// re-exec via current_exe() picks up the new binary.
|
||||
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
||||
vec!["build", "-p", "story-kit"]
|
||||
} else {
|
||||
vec!["build", "--release", "-p", "story-kit"]
|
||||
};
|
||||
slog!("[rebuild] cargo {}", build_args.join(" "));
|
||||
let output = tokio::task::spawn_blocking({
|
||||
let workspace_root = workspace_root.to_path_buf();
|
||||
move || {
|
||||
std::process::Command::new("cargo")
|
||||
.args(&build_args)
|
||||
.current_dir(&workspace_root)
|
||||
.output()
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Build task panicked: {e}"))?
|
||||
.map_err(|e| format!("Failed to run cargo build: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
slog!("[rebuild] Build failed:\n{stderr}");
|
||||
return Err(format!("Build failed:\n{stderr}"));
|
||||
}
|
||||
|
||||
slog!("[rebuild] Build succeeded, re-execing with new binary");
|
||||
|
||||
// 4. Re-exec with the new binary.
|
||||
// Collect current argv so we preserve any CLI arguments (e.g. project path).
|
||||
let current_exe = std::env::current_exe()
|
||||
.map_err(|e| format!("Cannot determine current executable: {e}"))?;
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Remove the port file before re-exec so the new process can write its own.
|
||||
if let Ok(root) = ctx.state.get_project_root() {
|
||||
let port_file = root.join(".story_kit_port");
|
||||
if port_file.exists() {
|
||||
let _ = std::fs::remove_file(&port_file);
|
||||
}
|
||||
}
|
||||
// Also check cwd for port file.
|
||||
let cwd_port_file = std::path::Path::new(".story_kit_port");
|
||||
if cwd_port_file.exists() {
|
||||
let _ = std::fs::remove_file(cwd_port_file);
|
||||
}
|
||||
|
||||
// Use exec() to replace the current process.
|
||||
// This never returns on success.
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = std::process::Command::new(¤t_exe)
|
||||
.args(&args[1..])
|
||||
.exec();
|
||||
|
||||
// If we get here, exec() failed.
|
||||
Err(format!("Failed to exec new binary: {err}"))
|
||||
}
|
||||
|
||||
/// MCP tool called by Claude Code via `--permission-prompt-tool`.
|
||||
///
|
||||
/// Forwards the permission request through the shared channel to the active
|
||||
@@ -2077,13 +2376,18 @@ 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!(names.contains(&"rebuild_and_restart"));
|
||||
assert_eq!(tools.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2150,6 +2454,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_backlog", "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"));
|
||||
|
||||
// Backlog
|
||||
let backlog = parsed["backlog"].as_array().unwrap();
|
||||
assert_eq!(backlog.len(), 1);
|
||||
assert_eq!(backlog[0]["story_id"], "10_story_upcoming");
|
||||
assert_eq!(parsed["backlog_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(¤t).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();
|
||||
@@ -2430,8 +2809,8 @@ mod tests {
|
||||
let t = tool.unwrap();
|
||||
let desc = t["description"].as_str().unwrap();
|
||||
assert!(
|
||||
desc.contains("work/1_upcoming/"),
|
||||
"create_bug description should reference work/1_upcoming/, got: {desc}"
|
||||
desc.contains("work/1_backlog/"),
|
||||
"create_bug description should reference work/1_backlog/, got: {desc}"
|
||||
);
|
||||
assert!(
|
||||
!desc.contains(".story_kit/bugs"),
|
||||
@@ -2455,8 +2834,8 @@ mod tests {
|
||||
let t = tool.unwrap();
|
||||
let desc = t["description"].as_str().unwrap();
|
||||
assert!(
|
||||
desc.contains("work/1_upcoming/"),
|
||||
"list_bugs description should reference work/1_upcoming/, got: {desc}"
|
||||
desc.contains("work/1_backlog/"),
|
||||
"list_bugs description should reference work/1_backlog/, got: {desc}"
|
||||
);
|
||||
assert!(
|
||||
!desc.contains(".story_kit/bugs"),
|
||||
@@ -2540,7 +2919,7 @@ mod tests {
|
||||
assert!(result.contains("1_bug_login_crash"));
|
||||
let bug_file = tmp
|
||||
.path()
|
||||
.join(".story_kit/work/1_upcoming/1_bug_login_crash.md");
|
||||
.join(".story_kit/work/1_backlog/1_bug_login_crash.md");
|
||||
assert!(bug_file.exists());
|
||||
}
|
||||
|
||||
@@ -2556,15 +2935,15 @@ mod tests {
|
||||
#[test]
|
||||
fn tool_list_bugs_returns_open_bugs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
std::fs::create_dir_all(&upcoming_dir).unwrap();
|
||||
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
|
||||
std::fs::create_dir_all(&backlog_dir).unwrap();
|
||||
std::fs::write(
|
||||
upcoming_dir.join("1_bug_crash.md"),
|
||||
backlog_dir.join("1_bug_crash.md"),
|
||||
"# Bug 1: App Crash\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
upcoming_dir.join("2_bug_typo.md"),
|
||||
backlog_dir.join("2_bug_typo.md"),
|
||||
"# Bug 2: Typo in Header\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2592,9 +2971,9 @@ mod tests {
|
||||
fn tool_close_bug_moves_to_archive() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo_in(tmp.path());
|
||||
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
std::fs::create_dir_all(&upcoming_dir).unwrap();
|
||||
let bug_file = upcoming_dir.join("1_bug_crash.md");
|
||||
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
|
||||
std::fs::create_dir_all(&backlog_dir).unwrap();
|
||||
let bug_file = backlog_dir.join("1_bug_crash.md");
|
||||
std::fs::write(&bug_file, "# Bug 1: Crash\n").unwrap();
|
||||
// Stage the file so it's tracked
|
||||
std::process::Command::new("git")
|
||||
@@ -2664,7 +3043,7 @@ mod tests {
|
||||
assert!(result.contains("1_spike_compare_encoders"));
|
||||
let spike_file = tmp
|
||||
.path()
|
||||
.join(".story_kit/work/1_upcoming/1_spike_compare_encoders.md");
|
||||
.join(".story_kit/work/1_backlog/1_spike_compare_encoders.md");
|
||||
assert!(spike_file.exists());
|
||||
let contents = std::fs::read_to_string(&spike_file).unwrap();
|
||||
assert!(contents.starts_with("---\nname: \"Compare Encoders\"\n---"));
|
||||
@@ -2679,7 +3058,7 @@ mod tests {
|
||||
let result = tool_create_spike(&json!({"name": "My Spike"}), &ctx).unwrap();
|
||||
assert!(result.contains("1_spike_my_spike"));
|
||||
|
||||
let spike_file = tmp.path().join(".story_kit/work/1_upcoming/1_spike_my_spike.md");
|
||||
let spike_file = tmp.path().join(".story_kit/work/1_backlog/1_spike_my_spike.md");
|
||||
assert!(spike_file.exists());
|
||||
let contents = std::fs::read_to_string(&spike_file).unwrap();
|
||||
assert!(contents.starts_with("---\nname: \"My Spike\"\n---"));
|
||||
@@ -2718,11 +3097,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 +3148,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]
|
||||
@@ -3869,4 +4274,56 @@ stage = "coder"
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0], "story-kit");
|
||||
}
|
||||
|
||||
// ── rebuild_and_restart ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn rebuild_and_restart_in_tools_list() {
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "rebuild_and_restart");
|
||||
assert!(
|
||||
tool.is_some(),
|
||||
"rebuild_and_restart missing from tools list"
|
||||
);
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].as_str().unwrap().contains("Rebuild"));
|
||||
assert!(t["inputSchema"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_and_restart_kills_agents_before_build() {
|
||||
// Verify that calling rebuild_and_restart on an empty pool doesn't
|
||||
// panic and proceeds to the build step. We can't test exec() in a
|
||||
// unit test, but we can verify the build attempt happens.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// The build will succeed (we're running in the real workspace) and
|
||||
// then exec() will be called — which would replace our test process.
|
||||
// So we only test that the function *runs* without panicking up to
|
||||
// the agent-kill step. We do this by checking the pool is empty.
|
||||
assert_eq!(ctx.agents.list_agents().unwrap().len(), 0);
|
||||
ctx.agents.kill_all_children(); // should not panic on empty pool
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_uses_matching_build_profile() {
|
||||
// The build must use the same profile (debug/release) as the running
|
||||
// binary, otherwise cargo build outputs to a different target dir and
|
||||
// current_exe() still points at the old binary.
|
||||
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
||||
vec!["build", "-p", "story-kit"]
|
||||
} else {
|
||||
vec!["build", "--release", "-p", "story-kit"]
|
||||
};
|
||||
|
||||
// Tests always run in debug mode, so --release must NOT be present.
|
||||
assert!(
|
||||
!build_args.contains(&"--release"),
|
||||
"In debug builds, rebuild must not pass --release (would put \
|
||||
the binary in target/release/ while current_exe() points to \
|
||||
target/debug/)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
@@ -35,7 +35,7 @@ pub struct StoryValidationResult {
|
||||
/// Full pipeline state across all stages.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PipelineState {
|
||||
pub upcoming: Vec<UpcomingStory>,
|
||||
pub backlog: Vec<UpcomingStory>,
|
||||
pub current: Vec<UpcomingStory>,
|
||||
pub qa: Vec<UpcomingStory>,
|
||||
pub merge: Vec<UpcomingStory>,
|
||||
@@ -46,7 +46,7 @@ pub struct PipelineState {
|
||||
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||
let agent_map = build_active_agent_map(ctx);
|
||||
Ok(PipelineState {
|
||||
upcoming: load_stage_items(ctx, "1_upcoming", &HashMap::new())?,
|
||||
backlog: load_stage_items(ctx, "1_backlog", &HashMap::new())?,
|
||||
current: load_stage_items(ctx, "2_current", &agent_map)?,
|
||||
qa: load_stage_items(ctx, "3_qa", &agent_map)?,
|
||||
merge: load_stage_items(ctx, "4_merge", &agent_map)?,
|
||||
@@ -130,7 +130,7 @@ fn load_stage_items(
|
||||
}
|
||||
|
||||
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
||||
load_stage_items(ctx, "1_upcoming", &HashMap::new())
|
||||
load_stage_items(ctx, "1_backlog", &HashMap::new())
|
||||
}
|
||||
|
||||
/// Shared create-story logic used by both the OpenApi and MCP handlers.
|
||||
@@ -152,11 +152,11 @@ pub fn create_story_file(
|
||||
}
|
||||
|
||||
let filename = format!("{story_number}_story_{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 backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||
fs::create_dir_all(&backlog_dir)
|
||||
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||
|
||||
let filepath = upcoming_dir.join(&filename);
|
||||
let filepath = backlog_dir.join(&filename);
|
||||
if filepath.exists() {
|
||||
return Err(format!("Story file already exists: {filename}"));
|
||||
}
|
||||
@@ -206,7 +206,7 @@ pub fn create_story_file(
|
||||
|
||||
// ── Bug file helpers ──────────────────────────────────────────────
|
||||
|
||||
/// Create a bug file in `work/1_upcoming/` with a deterministic filename and auto-commit.
|
||||
/// Create a bug file in `work/1_backlog/` with a deterministic filename and auto-commit.
|
||||
///
|
||||
/// Returns the bug_id (e.g. `"4_bug_login_crash"`).
|
||||
pub fn create_bug_file(
|
||||
@@ -226,9 +226,9 @@ pub fn create_bug_file(
|
||||
}
|
||||
|
||||
let filename = format!("{bug_number}_bug_{slug}.md");
|
||||
let bugs_dir = root.join(".story_kit").join("work").join("1_upcoming");
|
||||
let bugs_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||
fs::create_dir_all(&bugs_dir)
|
||||
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
|
||||
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||
|
||||
let filepath = bugs_dir.join(&filename);
|
||||
if filepath.exists() {
|
||||
@@ -276,7 +276,7 @@ pub fn create_bug_file(
|
||||
|
||||
// ── Spike file helpers ────────────────────────────────────────────
|
||||
|
||||
/// Create a spike file in `work/1_upcoming/` with a deterministic filename.
|
||||
/// Create a spike file in `work/1_backlog/` with a deterministic filename.
|
||||
///
|
||||
/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`).
|
||||
pub fn create_spike_file(
|
||||
@@ -292,11 +292,11 @@ pub fn create_spike_file(
|
||||
}
|
||||
|
||||
let filename = format!("{spike_number}_spike_{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 backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||
fs::create_dir_all(&backlog_dir)
|
||||
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||
|
||||
let filepath = upcoming_dir.join(&filename);
|
||||
let filepath = backlog_dir.join(&filename);
|
||||
if filepath.exists() {
|
||||
return Err(format!("Spike file already exists: {filename}"));
|
||||
}
|
||||
@@ -338,6 +338,73 @@ pub fn create_spike_file(
|
||||
Ok(spike_id)
|
||||
}
|
||||
|
||||
/// Create a refactor work item file in `work/1_backlog/`.
|
||||
///
|
||||
/// 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 backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||
fs::create_dir_all(&backlog_dir)
|
||||
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||
|
||||
let filepath = backlog_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 {
|
||||
@@ -360,18 +427,18 @@ fn extract_bug_name(path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// List all open bugs — files in `work/1_upcoming/` matching the `_bug_` naming pattern.
|
||||
/// List all open bugs — files in `work/1_backlog/` matching the `_bug_` naming pattern.
|
||||
///
|
||||
/// Returns a sorted list of `(bug_id, name)` pairs.
|
||||
pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
||||
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
|
||||
if !upcoming_dir.exists() {
|
||||
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||
if !backlog_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut bugs = Vec::new();
|
||||
for entry in
|
||||
fs::read_dir(&upcoming_dir).map_err(|e| format!("Failed to read upcoming directory: {e}"))?
|
||||
fs::read_dir(&backlog_dir).map_err(|e| format!("Failed to read backlog directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||
let path = entry.path();
|
||||
@@ -403,13 +470,66 @@ 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_backlog/` 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 backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||
if !backlog_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut refactors = Vec::new();
|
||||
for entry in fs::read_dir(&backlog_dir)
|
||||
.map_err(|e| format!("Failed to read backlog 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.
|
||||
/// Searches in priority order: 2_current, 1_backlog, 3_qa, 4_merge, 5_done, 6_archived.
|
||||
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
|
||||
let filename = format!("{story_id}.md");
|
||||
let sk = project_root.join(".story_kit").join("work");
|
||||
for stage in &["2_current", "1_upcoming", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
for stage in &["2_current", "1_backlog", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
let path = sk.join(stage).join(&filename);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
@@ -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)?;
|
||||
}
|
||||
@@ -648,7 +778,7 @@ fn next_item_number(root: &std::path::Path) -> Result<u32, String> {
|
||||
let work_base = root.join(".story_kit").join("work");
|
||||
let mut max_num: u32 = 0;
|
||||
|
||||
for subdir in &["1_upcoming", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
for subdir in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
let dir = work_base.join(subdir);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
@@ -843,10 +973,10 @@ pub fn validate_story_dirs(
|
||||
) -> Result<Vec<StoryValidationResult>, String> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Directories to validate: work/2_current/ + work/1_upcoming/
|
||||
// Directories to validate: work/2_current/ + work/1_backlog/
|
||||
let dirs_to_validate: Vec<PathBuf> = vec![
|
||||
root.join(".story_kit").join("work").join("2_current"),
|
||||
root.join(".story_kit").join("work").join("1_upcoming"),
|
||||
root.join(".story_kit").join("work").join("1_backlog"),
|
||||
];
|
||||
|
||||
for dir in &dirs_to_validate {
|
||||
@@ -912,7 +1042,7 @@ mod tests {
|
||||
let root = tmp.path().to_path_buf();
|
||||
|
||||
for (stage, id) in &[
|
||||
("1_upcoming", "10_story_upcoming"),
|
||||
("1_backlog", "10_story_upcoming"),
|
||||
("2_current", "20_story_current"),
|
||||
("3_qa", "30_story_qa"),
|
||||
("4_merge", "40_story_merge"),
|
||||
@@ -930,8 +1060,8 @@ mod tests {
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
let state = load_pipeline_state(&ctx).unwrap();
|
||||
|
||||
assert_eq!(state.upcoming.len(), 1);
|
||||
assert_eq!(state.upcoming[0].story_id, "10_story_upcoming");
|
||||
assert_eq!(state.backlog.len(), 1);
|
||||
assert_eq!(state.backlog[0].story_id, "10_story_upcoming");
|
||||
|
||||
assert_eq!(state.current.len(), 1);
|
||||
assert_eq!(state.current[0].story_id, "20_story_current");
|
||||
@@ -1034,15 +1164,15 @@ mod tests {
|
||||
#[test]
|
||||
fn load_upcoming_parses_metadata() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(
|
||||
upcoming.join("31_story_view_upcoming.md"),
|
||||
backlog.join("31_story_view_upcoming.md"),
|
||||
"---\nname: View Upcoming\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
upcoming.join("32_story_worktree.md"),
|
||||
backlog.join("32_story_worktree.md"),
|
||||
"---\nname: Worktree Orchestration\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1059,11 +1189,11 @@ mod tests {
|
||||
#[test]
|
||||
fn load_upcoming_skips_non_md_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::write(upcoming.join(".gitkeep"), "").unwrap();
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join(".gitkeep"), "").unwrap();
|
||||
fs::write(
|
||||
upcoming.join("31_story_example.md"),
|
||||
backlog.join("31_story_example.md"),
|
||||
"---\nname: A Story\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1078,16 +1208,16 @@ mod tests {
|
||||
fn validate_story_dirs_valid_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".story_kit/work/2_current");
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(
|
||||
current.join("28_story_todos.md"),
|
||||
"---\nname: Show TODOs\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
upcoming.join("36_story_front_matter.md"),
|
||||
backlog.join("36_story_front_matter.md"),
|
||||
"---\nname: Enforce Front Matter\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1172,7 +1302,7 @@ mod tests {
|
||||
#[test]
|
||||
fn next_item_number_empty_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
let base = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 1);
|
||||
}
|
||||
@@ -1180,13 +1310,13 @@ mod tests {
|
||||
#[test]
|
||||
fn next_item_number_scans_all_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
let current = tmp.path().join(".story_kit/work/2_current");
|
||||
let archived = tmp.path().join(".story_kit/work/5_done");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&archived).unwrap();
|
||||
fs::write(upcoming.join("10_story_foo.md"), "").unwrap();
|
||||
fs::write(backlog.join("10_story_foo.md"), "").unwrap();
|
||||
fs::write(current.join("20_story_bar.md"), "").unwrap();
|
||||
fs::write(archived.join("15_story_baz.md"), "").unwrap();
|
||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 21);
|
||||
@@ -1204,9 +1334,9 @@ mod tests {
|
||||
#[test]
|
||||
fn create_story_writes_correct_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::write(upcoming.join("36_story_existing.md"), "").unwrap();
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join("36_story_existing.md"), "").unwrap();
|
||||
|
||||
let number = next_item_number(tmp.path()).unwrap();
|
||||
assert_eq!(number, 37);
|
||||
@@ -1215,7 +1345,7 @@ mod tests {
|
||||
assert_eq!(slug, "my_new_feature");
|
||||
|
||||
let filename = format!("{number}_{slug}.md");
|
||||
let filepath = upcoming.join(&filename);
|
||||
let filepath = backlog.join(&filename);
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
@@ -1247,10 +1377,10 @@ mod tests {
|
||||
let result = create_story_file(tmp.path(), name, None, None, false);
|
||||
assert!(result.is_ok(), "create_story_file failed: {result:?}");
|
||||
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
let story_id = result.unwrap();
|
||||
let filename = format!("{story_id}.md");
|
||||
let contents = fs::read_to_string(upcoming.join(&filename)).unwrap();
|
||||
let contents = fs::read_to_string(backlog.join(&filename)).unwrap();
|
||||
|
||||
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
|
||||
assert_eq!(meta.name.as_deref(), Some(name));
|
||||
@@ -1259,10 +1389,10 @@ mod tests {
|
||||
#[test]
|
||||
fn create_story_rejects_duplicate() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
|
||||
let filepath = upcoming.join("1_story_my_feature.md");
|
||||
let filepath = backlog.join("1_story_my_feature.md");
|
||||
fs::write(&filepath, "existing").unwrap();
|
||||
|
||||
// Simulate the check
|
||||
@@ -1381,17 +1511,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_story_file_searches_current_then_upcoming() {
|
||||
fn find_story_file_searches_current_then_backlog() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".story_kit/work/2_current");
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
|
||||
// Only in upcoming
|
||||
fs::write(upcoming.join("6_test.md"), "").unwrap();
|
||||
// Only in backlog
|
||||
fs::write(backlog.join("6_test.md"), "").unwrap();
|
||||
let found = find_story_file(tmp.path(), "6_test").unwrap();
|
||||
assert!(found.ends_with("1_upcoming/6_test.md") || found.ends_with("1_upcoming\\6_test.md"));
|
||||
assert!(found.ends_with("1_backlog/6_test.md") || found.ends_with("1_backlog\\6_test.md"));
|
||||
|
||||
// Also in current — current should win
|
||||
fs::write(current.join("6_test.md"), "").unwrap();
|
||||
@@ -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(¤t).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(¤t).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(¤t).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(¤t).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]
|
||||
@@ -1540,19 +1724,19 @@ mod tests {
|
||||
#[test]
|
||||
fn next_item_number_increments_from_existing_bugs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::write(upcoming.join("1_bug_crash.md"), "").unwrap();
|
||||
fs::write(upcoming.join("3_bug_another.md"), "").unwrap();
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join("1_bug_crash.md"), "").unwrap();
|
||||
fs::write(backlog.join("3_bug_another.md"), "").unwrap();
|
||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_item_number_scans_archived_too() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
let archived = tmp.path().join(".story_kit/work/5_done");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(&archived).unwrap();
|
||||
fs::write(archived.join("5_bug_old.md"), "").unwrap();
|
||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 6);
|
||||
@@ -1568,11 +1752,11 @@ mod tests {
|
||||
#[test]
|
||||
fn list_bug_files_excludes_archive_subdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
|
||||
let archived_dir = tmp.path().join(".story_kit/work/5_done");
|
||||
fs::create_dir_all(&upcoming_dir).unwrap();
|
||||
fs::create_dir_all(&backlog_dir).unwrap();
|
||||
fs::create_dir_all(&archived_dir).unwrap();
|
||||
fs::write(upcoming_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap();
|
||||
fs::write(backlog_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap();
|
||||
fs::write(archived_dir.join("2_bug_closed.md"), "# Bug 2: Closed Bug\n").unwrap();
|
||||
|
||||
let result = list_bug_files(tmp.path()).unwrap();
|
||||
@@ -1584,11 +1768,11 @@ mod tests {
|
||||
#[test]
|
||||
fn list_bug_files_sorted_by_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
fs::create_dir_all(&upcoming_dir).unwrap();
|
||||
fs::write(upcoming_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap();
|
||||
fs::write(upcoming_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap();
|
||||
fs::write(upcoming_dir.join("2_bug_second.md"), "# Bug 2: Second\n").unwrap();
|
||||
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog_dir).unwrap();
|
||||
fs::write(backlog_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap();
|
||||
fs::write(backlog_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap();
|
||||
fs::write(backlog_dir.join("2_bug_second.md"), "# Bug 2: Second\n").unwrap();
|
||||
|
||||
let result = list_bug_files(tmp.path()).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
@@ -1626,7 +1810,7 @@ mod tests {
|
||||
|
||||
let filepath = tmp
|
||||
.path()
|
||||
.join(".story_kit/work/1_upcoming/1_bug_login_crash.md");
|
||||
.join(".story_kit/work/1_backlog/1_bug_login_crash.md");
|
||||
assert!(filepath.exists());
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(
|
||||
@@ -1670,7 +1854,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filepath = tmp.path().join(".story_kit/work/1_upcoming/1_bug_some_bug.md");
|
||||
let filepath = tmp.path().join(".story_kit/work/1_backlog/1_bug_some_bug.md");
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(
|
||||
contents.starts_with("---\nname: \"Some Bug\"\n---"),
|
||||
@@ -1692,7 +1876,7 @@ mod tests {
|
||||
|
||||
let filepath = tmp
|
||||
.path()
|
||||
.join(".story_kit/work/1_upcoming/1_spike_filesystem_watcher_architecture.md");
|
||||
.join(".story_kit/work/1_backlog/1_spike_filesystem_watcher_architecture.md");
|
||||
assert!(filepath.exists());
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(
|
||||
@@ -1716,7 +1900,7 @@ mod tests {
|
||||
create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap();
|
||||
|
||||
let filepath =
|
||||
tmp.path().join(".story_kit/work/1_upcoming/1_spike_fs_watcher_spike.md");
|
||||
tmp.path().join(".story_kit/work/1_backlog/1_spike_fs_watcher_spike.md");
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains(description));
|
||||
}
|
||||
@@ -1726,7 +1910,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
||||
|
||||
let filepath = tmp.path().join(".story_kit/work/1_upcoming/1_spike_my_spike.md");
|
||||
let filepath = tmp.path().join(".story_kit/work/1_backlog/1_spike_my_spike.md");
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
// Should have placeholder TBD in Question section
|
||||
assert!(contents.contains("## Question\n\n- TBD\n"));
|
||||
@@ -1747,10 +1931,10 @@ mod tests {
|
||||
let result = create_spike_file(tmp.path(), name, None);
|
||||
assert!(result.is_ok(), "create_spike_file failed: {result:?}");
|
||||
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
let spike_id = result.unwrap();
|
||||
let filename = format!("{spike_id}.md");
|
||||
let contents = fs::read_to_string(upcoming.join(&filename)).unwrap();
|
||||
let contents = fs::read_to_string(backlog.join(&filename)).unwrap();
|
||||
|
||||
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
|
||||
assert_eq!(meta.name.as_deref(), Some(name));
|
||||
@@ -1759,9 +1943,9 @@ mod tests {
|
||||
#[test]
|
||||
fn create_spike_file_increments_from_existing_items() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::write(upcoming.join("5_story_existing.md"), "").unwrap();
|
||||
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join("5_story_existing.md"), "").unwrap();
|
||||
|
||||
let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
||||
assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}");
|
||||
|
||||
@@ -79,7 +79,7 @@ enum WsResponse {
|
||||
},
|
||||
/// Full pipeline state pushed on connect and after every work-item watcher event.
|
||||
PipelineState {
|
||||
upcoming: Vec<crate::http::workflow::UpcomingStory>,
|
||||
backlog: Vec<crate::http::workflow::UpcomingStory>,
|
||||
current: Vec<crate::http::workflow::UpcomingStory>,
|
||||
qa: Vec<crate::http::workflow::UpcomingStory>,
|
||||
merge: Vec<crate::http::workflow::UpcomingStory>,
|
||||
@@ -150,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +160,7 @@ impl From<WatcherEvent> for Option<WsResponse> {
|
||||
impl From<PipelineState> for WsResponse {
|
||||
fn from(s: PipelineState) -> Self {
|
||||
WsResponse::PipelineState {
|
||||
upcoming: s.upcoming,
|
||||
backlog: s.backlog,
|
||||
current: s.current,
|
||||
qa: s.qa,
|
||||
merge: s.merge,
|
||||
@@ -692,7 +695,7 @@ mod tests {
|
||||
agent: None,
|
||||
};
|
||||
let resp = WsResponse::PipelineState {
|
||||
upcoming: vec![story],
|
||||
backlog: vec![story],
|
||||
current: vec![],
|
||||
qa: vec![],
|
||||
merge: vec![],
|
||||
@@ -700,8 +703,8 @@ mod tests {
|
||||
};
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(json["type"], "pipeline_state");
|
||||
assert_eq!(json["upcoming"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(json["upcoming"][0]["story_id"], "10_story_test");
|
||||
assert_eq!(json["backlog"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(json["backlog"][0]["story_id"], "10_story_test");
|
||||
assert!(json["current"].as_array().unwrap().is_empty());
|
||||
assert!(json["done"].as_array().unwrap().is_empty());
|
||||
}
|
||||
@@ -821,7 +824,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pipeline_state_converts_to_ws_response() {
|
||||
let state = PipelineState {
|
||||
upcoming: vec![UpcomingStory {
|
||||
backlog: vec![UpcomingStory {
|
||||
story_id: "1_story_a".to_string(),
|
||||
name: Some("Story A".to_string()),
|
||||
error: None,
|
||||
@@ -848,8 +851,8 @@ mod tests {
|
||||
let resp: WsResponse = state.into();
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(json["type"], "pipeline_state");
|
||||
assert_eq!(json["upcoming"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(json["upcoming"][0]["story_id"], "1_story_a");
|
||||
assert_eq!(json["backlog"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(json["backlog"][0]["story_id"], "1_story_a");
|
||||
assert_eq!(json["current"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(json["current"][0]["story_id"], "2_story_b");
|
||||
assert!(json["qa"].as_array().unwrap().is_empty());
|
||||
@@ -861,7 +864,7 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_pipeline_state_converts_to_ws_response() {
|
||||
let state = PipelineState {
|
||||
upcoming: vec![],
|
||||
backlog: vec![],
|
||||
current: vec![],
|
||||
qa: vec![],
|
||||
merge: vec![],
|
||||
@@ -870,7 +873,7 @@ mod tests {
|
||||
let resp: WsResponse = state.into();
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(json["type"], "pipeline_state");
|
||||
assert!(json["upcoming"].as_array().unwrap().is_empty());
|
||||
assert!(json["backlog"].as_array().unwrap().is_empty());
|
||||
assert!(json["current"].as_array().unwrap().is_empty());
|
||||
assert!(json["qa"].as_array().unwrap().is_empty());
|
||||
assert!(json["merge"].as_array().unwrap().is_empty());
|
||||
@@ -988,7 +991,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pipeline_state_with_agent_converts_correctly() {
|
||||
let state = PipelineState {
|
||||
upcoming: vec![],
|
||||
backlog: vec![],
|
||||
current: vec![UpcomingStory {
|
||||
story_id: "10_story_x".to_string(),
|
||||
name: Some("Story X".to_string()),
|
||||
@@ -1043,7 +1046,7 @@ mod tests {
|
||||
let root = tmp.path().to_path_buf();
|
||||
|
||||
// Create minimal pipeline dirs so load_pipeline_state succeeds.
|
||||
for stage in &["1_upcoming", "2_current", "3_qa", "4_merge"] {
|
||||
for stage in &["1_backlog", "2_current", "3_qa", "4_merge"] {
|
||||
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
|
||||
}
|
||||
|
||||
@@ -1152,7 +1155,7 @@ mod tests {
|
||||
|
||||
assert_eq!(initial["type"], "pipeline_state");
|
||||
// All stages should be empty arrays since no .md files were created.
|
||||
assert!(initial["upcoming"].as_array().unwrap().is_empty());
|
||||
assert!(initial["backlog"].as_array().unwrap().is_empty());
|
||||
assert!(initial["current"].as_array().unwrap().is_empty());
|
||||
assert!(initial["qa"].as_array().unwrap().is_empty());
|
||||
assert!(initial["merge"].as_array().unwrap().is_empty());
|
||||
|
||||
@@ -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 Kit–specific
|
||||
/// 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)
|
||||
@@ -366,7 +409,7 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
|
||||
|
||||
// Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone
|
||||
let work_stages = [
|
||||
"1_upcoming",
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
@@ -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();
|
||||
@@ -1011,7 +1085,7 @@ mod tests {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
|
||||
let stages = ["1_upcoming", "2_current", "3_qa", "4_merge", "5_done", "6_archived"];
|
||||
let stages = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"];
|
||||
for stage in &stages {
|
||||
let path = dir.path().join(".story_kit/work").join(stage);
|
||||
assert!(path.is_dir(), "work/{} should be a directory", stage);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +101,17 @@ 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.
|
||||
@@ -144,7 +161,7 @@ fn remove_front_matter_field(contents: &str, key: &str) -> String {
|
||||
/// 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();
|
||||
@@ -325,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -69,7 +78,7 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
|
||||
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
|
||||
fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
|
||||
let (action, prefix) = match stage {
|
||||
"1_upcoming" => ("create", format!("story-kit: create {item_id}")),
|
||||
"1_backlog" => ("create", format!("story-kit: create {item_id}")),
|
||||
"2_current" => ("start", format!("story-kit: start {item_id}")),
|
||||
"3_qa" => ("qa", format!("story-kit: queue {item_id} for QA")),
|
||||
"4_merge" => ("merge", format!("story-kit: queue {item_id} for merge")),
|
||||
@@ -102,7 +111,7 @@ fn stage_for_path(path: &Path) -> Option<String> {
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())?;
|
||||
matches!(stage, "1_upcoming" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived")
|
||||
matches!(stage, "1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived")
|
||||
.then(|| stage.to_string())
|
||||
}
|
||||
|
||||
@@ -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_backlog", "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_backlog` 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_backlog");
|
||||
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_backlog".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_backlog");
|
||||
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,12 +643,24 @@ 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]
|
||||
fn flush_pending_broadcasts_for_all_pipeline_stages() {
|
||||
let stages = [
|
||||
("1_upcoming", "create", "story-kit: create 10_story_x"),
|
||||
("1_backlog", "create", "story-kit: create 10_story_x"),
|
||||
("3_qa", "qa", "story-kit: queue 10_story_x for QA"),
|
||||
("4_merge", "merge", "story-kit: queue 10_story_x for merge"),
|
||||
("5_done", "done", "story-kit: done 10_story_x"),
|
||||
@@ -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_backlog() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
let stage_dir = make_stage_dir(tmp.path(), "1_backlog");
|
||||
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_backlog".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_backlog"
|
||||
);
|
||||
}
|
||||
|
||||
#[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_backlog"));
|
||||
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();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user