Compare commits

...

32 Commits

Author SHA1 Message Date
Timmy 0995c55a82 Bump version to 0.8.8 2026-04-03 11:07:39 +01:00
dave 41197c667a storkit: done 460_bug_strip_bot_mention_fails_on_element_markdown_mention_pill_format 2026-04-03 10:00:54 +00:00
dave 7da73aa435 storkit: merge 460_bug_strip_bot_mention_fails_on_element_markdown_mention_pill_format 2026-04-03 10:00:50 +00:00
dave 3d83cc61b6 storkit: create 461_bug_strip_bot_mention_fails_on_element_markdown_mention_pill_format 2026-04-03 09:53:38 +00:00
dave 334d52bd2b storkit: create 460_bug_strip_bot_mention_fails_on_element_markdown_mention_pill_format 2026-04-03 09:51:18 +00:00
dave 8ff1de73d4 storkit: accept 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode 2026-04-02 21:06:38 +00:00
dave d37fdf8e10 fix: strip emoji between bot mention and command text
strip_mention_separator now skips all non-ASCII-alphanumeric chars
(emoji, colons, spaces) and returns a slice starting at the first
command character. Fixes mention pills with emoji display names
(e.g. "timmy ️ status") not matching bot commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:06:52 +00:00
dave 7ff88641c0 storkit: done 459_bug_matrix_history_json_and_timers_json_missing_from_scaffold_storkit_gitignore 2026-04-02 17:18:31 +00:00
dave b8ac5622d6 storkit: merge 459_bug_matrix_history_json_and_timers_json_missing_from_scaffold_storkit_gitignore 2026-04-02 17:18:28 +00:00
dave 4df3f8594c storkit: accept 457_bug_store_json_created_at_project_root_instead_of_inside_storkit 2026-04-02 17:15:50 +00:00
dave 56e71293d6 chore: remove debug log from verification handler 2026-04-02 17:10:09 +00:00
dave 2df214cad1 storkit: create 459_bug_matrix_history_json_and_timers_json_missing_from_scaffold_storkit_gitignore 2026-04-02 17:02:54 +00:00
dave f43b84a7ef storkit: done 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode 2026-04-02 15:51:25 +00:00
dave ce4a0cb7f9 storkit: merge 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode 2026-04-02 15:51:22 +00:00
dave 52e9fe2a87 storkit: accept 456_bug_matrix_bot_ignores_in_room_verification_requests_from_element 2026-04-02 15:41:28 +00:00
dave a22d67c36c storkit: create 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode 2026-04-02 15:37:30 +00:00
dave 0cb98c2a3e storkit: accept 454_story_deduplicate_work_item_display_in_web_ui_story_panel 2026-04-02 15:17:41 +00:00
dave e6439238d2 storkit: done 457_bug_store_json_created_at_project_root_instead_of_inside_storkit 2026-04-02 13:27:49 +00:00
dave 967a306ea8 storkit: merge 457_bug_store_json_created_at_project_root_instead_of_inside_storkit 2026-04-02 13:27:46 +00:00
dave 46d09d4d45 storkit: create 457_bug_store_json_created_at_project_root_instead_of_inside_storkit 2026-04-02 13:15:04 +00:00
Timmy 13e3bd00f1 Bump version to 0.8.7 2026-04-02 14:09:25 +01:00
dave cd6d98b99f debug: log all room messages in verification handler to diagnose in-room verification 2026-04-02 13:08:02 +00:00
Timmy 358f177584 Bump version to 0.8.6 2026-04-02 13:39:49 +01:00
dave b60bb57aa4 storkit: done 456_bug_matrix_bot_ignores_in_room_verification_requests_from_element 2026-04-02 11:54:01 +00:00
dave 7003fca873 storkit: merge 456_bug_matrix_bot_ignores_in_room_verification_requests_from_element 2026-04-02 11:53:58 +00:00
dave b5d825356e storkit: create 456_bug_matrix_bot_ignores_in_room_verification_requests_from_element 2026-04-02 11:40:40 +00:00
dave 896eb4fc52 storkit: done 454_story_deduplicate_work_item_display_in_web_ui_story_panel 2026-04-02 11:00:55 +00:00
dave f8d7438eec storkit: merge 454_story_deduplicate_work_item_display_in_web_ui_story_panel 2026-04-02 11:00:52 +00:00
dave f7f4e8f95b storkit: create 455_story_rename_project_from_storkit_to_huskies 2026-04-02 10:58:03 +00:00
dave af76910f36 storkit: create 454_story_deduplicate_work_item_display_in_web_ui_story_panel 2026-04-02 10:43:24 +00:00
dave f06111f045 storkit: done 452_bug_claude_code_pty_crashes_with_fatal_runtime_error_on_agent_restart 2026-04-02 10:31:08 +00:00
dave c6020b7f43 storkit: merge 452_bug_claude_code_pty_crashes_with_fatal_runtime_error_on_agent_restart 2026-04-02 10:31:05 +00:00
25 changed files with 772 additions and 42 deletions
@@ -0,0 +1,31 @@
---
name: "Rename project from \"storkit\" to \"huskies\""
---
# Story 455: Rename project from "storkit" to "huskies"
## User Story
As a project maintainer, I want to rename the project from "storkit" to "huskies" so that the product has its new identity throughout the codebase, tooling, and documentation.
## Acceptance Criteria
- [ ] Rust crate name in server/Cargo.toml changed from 'storkit' to 'huskies'
- [ ] Binary name changed to 'huskies' (Dockerfile CMD, release script binary names)
- [ ] Environment variables renamed: STORKIT_PORT → HUSKIES_PORT, STORKIT_HOST → HUSKIES_HOST
- [ ] Docker service name, container_name, image name, and volume names updated in docker-compose.yml
- [ ] Docker user/group renamed from 'storkit' to 'huskies' in Dockerfile (groupadd, useradd, home dir /home/huskies/.claude)
- [ ] MCP server registration renamed from 'storkit' to 'huskies' in scaffold-generated .mcp.json and in server/src/http/mcp/mod.rs serverInfo name
- [ ] All 35+ MCP tool permission patterns updated from mcp__storkit__* to mcp__huskies__* across code and permission configs
- [ ] The .storkit/ project directory marker renamed to .huskies/ throughout all Rust source (paths.rs, config.rs, scaffold.rs, watcher.rs, prompts.rs, and all agent/pipeline code)
- [ ] Release script updated: Gitea repo path dave/storkit → dave/huskies, changelog regex updated to match ^(huskies|storkit|story-kit): for backwards-compatible history parsing, binary artifact names updated
- [ ] Git commit prefix convention updated from 'storkit:' to 'huskies:' in storkit README and agent prompts
- [ ] Website updated: page title, headings, and contact email (hello@storkit.dev) if domain changes
- [ ] README.md updated: all CLI examples use 'huskies' binary name, all .storkit/ references become .huskies/
- [ ] A migration path exists for existing installs: either storkit auto-detects and migrates .storkit/ → .huskies/, or a migration script (script/migrate) is provided
- [ ] All Claude Code .mcp.json files in existing worktrees are regenerated via scaffold or migration
- [ ] Gitea repository renamed from dave/storkit to dave/huskies (external action required, noted in story)
## Out of Scope
- TBD
@@ -0,0 +1,28 @@
---
name: "strip_bot_mention fails on Element markdown mention pill format"
---
# Bug 461: strip_bot_mention fails on Element markdown mention pill format
## Description
When Element sends a message with a mention pill, the plain text body uses Markdown link format: `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status`. The `strip_bot_mention` function in chat/util.rs uses `strip_prefix_ci` which expects the message to start with `@timmy` or the display name. Since the message starts with `[`, all prefix checks fail, the mention is not stripped, and the entire Markdown link becomes the "command name". Deterministic commands like `status`, `help`, etc. are never matched — they fall through to the LLM instead. The `mentions_bot` function works correctly because it uses `contains()` rather than prefix matching, so the bot IS triggered, but the command text extraction is broken.
## How to Reproduce
1. In Element, mention the bot using a mention pill: @botname status. 2. Element sends plain body as `[@bot:server](https://matrix.to/#/@bot:server) status`. 3. Observe that the bot routes to LLM instead of the deterministic status command handler.
## Actual Result
strip_bot_mention returns the original text unchanged. The command name is parsed as the entire Markdown link. No deterministic command matches. Message falls through to LLM.
## Expected Result
strip_bot_mention strips the Markdown mention pill `[...](https://matrix.to/...)` and returns `status`. The deterministic command handler matches and handles it.
## Acceptance Criteria
- [ ] strip_bot_mention in chat/util.rs handles the Markdown mention pill format [display](https://matrix.to/#/@user:server)
- [ ] Deterministic commands like 'status', 'help', 'overview' work when sent via Element mention pills
- [ ] Existing plain-text mention formats (@bot:server command, @bot command, BotName command) continue to work
- [ ] Tests added for Markdown mention pill format in util.rs
@@ -0,0 +1,34 @@
---
name: "strip_bot_mention fails on Element Markdown mention pill format"
---
# Bug 460: strip_bot_mention fails on Element Markdown mention pill format
## Description
When Element sends a mention pill, the plain text `body` field contains a Markdown-style link like `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status`. The `strip_bot_mention` function uses prefix matching, so it tries to match `@timmy:crashlabs.io`, `@timmy`, and `Timmy` against text starting with `[` — none match. The entire message falls through to the LLM as a non-command.
`mentions_bot` works because it uses `body.contains(full_id)` which finds the MXID embedded inside the Markdown link. But `strip_bot_mention` fails because the text starts with `[`, not `@` or the display name.
This causes all deterministic bot commands (status, help, ambient, etc.) to be routed to the LLM instead of being handled by the bot when the user uses Element's mention pill (@-autocomplete).
## How to Reproduce
1. In Element, type `@timmy` and use the autocomplete pill to mention the bot
2. Append a command like `status`
3. Send the message
## Actual Result
The command falls through to the LLM. The bot logs show no "Handled bot command" entry. The plain body is `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status` which `strip_bot_mention` cannot parse.
## Expected Result
The bot should strip the Markdown mention link wrapper, extract the MXID or display name, and match the command deterministically. `@timmy status` via mention pill should produce the same pipeline status output as typing `@timmy status` manually.
## Acceptance Criteria
- [ ] strip_bot_mention handles Markdown link format `[display](https://matrix.to/#/@user:server) command` and extracts the command text
- [ ] Deterministic commands (status, help, ambient, etc.) work when invoked via Element mention pill autocomplete
- [ ] Unit tests cover the Markdown mention pill body format
- [ ] Existing strip_bot_mention tests still pass (plain @mention and display name formats)
@@ -0,0 +1,20 @@
---
name: "Deduplicate work item display in web UI story panel"
---
# Story 454: Deduplicate work item display in web UI story panel
## User Story
As a user, I want the work item detail panel to display cleanly without redundant information, so that I can read story details without noise.
## Acceptance Criteria
- [ ] The story title is not shown twice (remove the duplicate heading)
- [ ] The work item type label is not shown twice
- [ ] The word 'name' is not shown as a prefix before the story title
- [ ] The story ID/title line (e.g. 'Story 3: ...') is left-justified with no extra indentation
## Out of Scope
- TBD
@@ -0,0 +1,29 @@
---
name: "Matrix bot ignores in-room verification requests from Element"
---
# Bug 456: Matrix bot ignores in-room verification requests from Element
## Description
The Matrix bot (Sally) only registers a handler for to-device verification events (`ToDeviceKeyVerificationRequestEvent`). Modern Element clients use in-room verification (`m.key.verification.request` as a room message event) by default. When a user initiates "Start Verification" from Element, the request is sent as a room event and the bot never sees it — nothing appears in the bot logs and the verification flow hangs indefinitely. As a result, Sally's device remains unverified (Big Red Dot), and if Element has "never send to unverified sessions" enabled, it will not share Megolm room keys with Sally's device, making her deaf to all encrypted room messages.
## How to Reproduce
1. Run the storkit Matrix bot (Sally) in a room with E2EE enabled. 2. In Element, open the room member list, click Sally's device, and press "Start Verification". 3. Watch the bot logs: grep for "verif\|Incoming".
## Actual Result
Nothing appears in the bot logs. The verification flow hangs in Element and eventually times out. Sally's device remains unverified. If Element is set to encrypt only to verified sessions, Sally cannot decrypt any messages in the room.
## Expected Result
The bot receives the in-room verification request, accepts it, drives the SAS emoji flow to completion, and logs "Verification with @user completed successfully!". Sally's device shows as verified in Element.
## Acceptance Criteria
- [ ] Bot registers an in-room verification event handler for m.key.verification.request room events (in addition to the existing to-device handler)
- [ ] When Element initiates 'Start Verification' from the device list, the bot logs 'Incoming verification request from ...'
- [ ] The SAS emoji flow completes: bot logs the emoji string, confirms, and logs 'Verification ... completed successfully!'
- [ ] Sally's device shows as verified (no Big Red Dot) in Element after the flow completes
- [ ] Existing to-device verification handler is preserved for clients that use the older flow
@@ -0,0 +1,29 @@
---
name: "store.json created at project root instead of inside .storkit/"
---
# Bug 457: store.json created at project root instead of inside .storkit/
## Description
In main.rs, JsonFileStore is initialised with a hardcoded relative path `PathBuf::from("store.json")`, which creates the file in whatever directory the process was started from (typically the project root). It should live inside `.storkit/` alongside other runtime state files. The scaffold .gitignore also lists `store.json` as a root-level pattern rather than `.storkit/store.json`, and the scaffold comment/entries array in scaffold.rs explicitly lists `store.json` as a root-level file to ignore — both need updating.
## How to Reproduce
1. Run storkit in any project directory. 2. Observe that store.json is created at the project root rather than inside .storkit/.
## Actual Result
store.json is created at the working directory root, polluting the project root and not being gitignored by the scaffold-generated .gitignore unless the user happens to have a catch-all pattern.
## Expected Result
store.json is created at project_root/.storkit/store.json. The scaffold-generated .gitignore ignores .storkit/store.json. The scaffold comment and entries array in scaffold.rs no longer list store.json as a root-level file.
## Acceptance Criteria
- [ ] main.rs initialises JsonFileStore at project_root.join(".storkit").join("store.json") instead of PathBuf::from("store.json")
- [ ] scaffold.rs .gitignore entries updated: store.json root entry removed, .storkit/store.json added
- [ ] scaffold.rs comment on line ~333 updated to reflect store.json is no longer at the root
- [ ] wizard_tools.rs filter for store.json updated to match the new path if needed
- [ ] Existing deployments with a root-level store.json are not broken (storkit migrates or falls back gracefully)
@@ -0,0 +1,21 @@
---
name: "Matrix bot ignores messages addressed to other bots in ambient mode"
---
# Story 458: Matrix bot ignores messages addressed to other bots in ambient mode
## User Story
As a user with multiple bots in the same Matrix room, I want each bot to only respond to messages addressed to it in ambient mode, so that bots don't step on each other's responses.
## Acceptance Criteria
- [ ] In ambient mode, the bot ignores messages that begin with another bot's name or mention another bot's display name (e.g. 'sally: do X' or '@sally do X' is ignored by stu)
- [ ] In ambient mode, the bot still responds to messages with no explicit addressee
- [ ] In ambient mode, the bot still responds to messages explicitly addressed to itself (e.g. 'stu: do X' or '@stu do X')
- [ ] Direct @mention of the bot's Matrix user ID always triggers a response regardless of ambient mode
- [ ] The bot's own display_name from bot.toml is used to detect when it is being addressed
## Out of Scope
- TBD
@@ -0,0 +1,27 @@
---
name: "matrix_history.json and timers.json missing from scaffold .storkit/.gitignore"
---
# Bug 459: matrix_history.json and timers.json missing from scaffold .storkit/.gitignore
## Description
The scaffold's write_story_kit_gitignore function in scaffold.rs does not include matrix_history.json or timers.json in the .storkit/.gitignore entries. Both files are runtime state that should not be committed to git. matrix_device_id and matrix_store/ are already covered, but matrix_history.json (conversation history) and timers.json (timer store) are missing.
## How to Reproduce
1. Run storkit scaffold on a new project. 2. Start the Matrix bot. 3. Observe that matrix_history.json and timers.json are created inside .storkit/ but are not gitignored.
## Actual Result
matrix_history.json and timers.json appear as untracked files in git status.
## Expected Result
Both files are listed in .storkit/.gitignore and do not appear in git status.
## Acceptance Criteria
- [ ] matrix_history.json added to the entries array in write_story_kit_gitignore in scaffold.rs
- [ ] timers.json added to the entries array in write_story_kit_gitignore in scaffold.rs
- [ ] scaffold test in scaffold_creates_story_kit_gitignore_with_relative_entries asserts both entries are present
Generated
+10 -9
View File
@@ -1779,9 +1779,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.13.0"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
@@ -4077,7 +4077,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "storkit"
version = "0.8.5"
version = "0.8.8"
dependencies = [
"async-stream",
"async-trait",
@@ -4088,6 +4088,7 @@ dependencies = [
"futures",
"homedir",
"ignore",
"libc",
"libsqlite3-sys",
"matrix-sdk",
"mime_guess",
@@ -4355,9 +4356,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [
"bytes",
"libc",
@@ -4372,9 +4373,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.1"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@@ -5629,9 +5630,9 @@ dependencies = [
[[package]]
name = "writeable"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x25519-dalek"
+1
View File
@@ -40,3 +40,4 @@ pulldown-cmark = { version = "0.13.3", default-features = false, features = [
"html",
] }
regex = "1"
libc = "0.2"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "living-spec-standalone",
"version": "0.8.5",
"version": "0.8.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "living-spec-standalone",
"version": "0.8.5",
"version": "0.8.8",
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "living-spec-standalone",
"private": true,
"version": "0.8.5",
"version": "0.8.8",
"type": "module",
"scripts": {
"dev": "vite",
+7 -7
View File
@@ -1643,7 +1643,9 @@ describe("Slash command handling (Story 374)", () => {
});
it("AC: /help calls botCommand and displays response", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Available commands: status, help, ..." });
mockedApi.botCommand.mockResolvedValue({
response: "Available commands: status, help, ...",
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
@@ -1660,11 +1662,7 @@ describe("Slash command handling (Story 374)", () => {
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"help",
"",
undefined,
);
expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined);
});
expect(lastSendChatArgs).toBeNull();
});
@@ -1723,7 +1721,9 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => {
);
});
const link = await screen.findByRole("link", { name: /https:\/\/example\.com\/oauth\/login/ });
const link = await screen.findByRole("link", {
name: /https:\/\/example\.com\/oauth\/login/,
});
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://example.com/oauth/login");
});
@@ -69,7 +69,7 @@ afterEach(() => {
});
describe("WorkItemDetailPanel", () => {
it("renders the story name in the header", async () => {
it("renders the story name in the header with type and ID prefix", async () => {
render(
<WorkItemDetailPanel
storyId="237_bug_test"
@@ -79,7 +79,7 @@ describe("WorkItemDetailPanel", () => {
);
await waitFor(() => {
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
"Big Title Story",
"Bug 237: Big Title Story",
);
});
});
@@ -110,6 +110,10 @@ describe("WorkItemDetailPanel", () => {
});
it("renders markdown headings with constrained inline font size", async () => {
mockedGetWorkItemContent.mockResolvedValue({
...DEFAULT_CONTENT,
content: "# Title Heading\n\n## Section Heading\n\nSome content.",
});
render(
<WorkItemDetailPanel
storyId="237_bug_test"
@@ -119,11 +123,95 @@ describe("WorkItemDetailPanel", () => {
);
await waitFor(() => {
const content = screen.getByTestId("detail-panel-content");
const h1 = content.querySelector("h1");
expect(h1).not.toBeNull();
expect(h1?.style.fontSize).toBeTruthy();
// H1 is stripped by stripDisplayContent; h2 should be constrained
const h2 = content.querySelector("h2");
expect(h2).not.toBeNull();
expect(h2?.style.fontSize).toBeTruthy();
});
});
it("strips YAML front matter so 'name' is not shown as a prefix in content", async () => {
mockedGetWorkItemContent.mockResolvedValue({
content:
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nAs a user...',
stage: "current",
name: "My Story Name",
agent: null,
});
render(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
const content = await screen.findByTestId("detail-panel-content");
expect(content.textContent).not.toMatch(/name:/i);
});
it("strips the first H1 heading so the story title is not shown twice", async () => {
mockedGetWorkItemContent.mockResolvedValue({
content:
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nAs a user...',
stage: "current",
name: "My Story Name",
agent: null,
});
render(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
const content = await screen.findByTestId("detail-panel-content");
expect(content.querySelector("h1")).toBeNull();
});
it("shows 'Type N: Name' format in the panel header title (story ID/title left-justified)", async () => {
mockedGetWorkItemContent.mockResolvedValue({
content: "## User Story\n\nAs a user...",
stage: "current",
name: "My Story Name",
agent: null,
});
render(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
"Story 42: My Story Name",
);
});
});
it("does not show the work item type label twice when front matter and H1 are stripped", async () => {
mockedGetWorkItemContent.mockResolvedValue({
content:
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nContent.',
stage: "current",
name: "My Story Name",
agent: null,
});
render(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await screen.findByTestId("detail-panel-content");
// "Story" type label appears exactly once — in the panel header title
const title = screen.getByTestId("detail-panel-title");
expect(title.textContent).toContain("Story 42:");
// The content body should not contain an H1 repeating the type + title
const content = screen.getByTestId("detail-panel-content");
expect(content.querySelector("h1")).toBeNull();
});
});
describe("WorkItemDetailPanel - Agent Logs", () => {
@@ -17,6 +17,46 @@ import { api } from "../api/client";
const { useCallback, useEffect, useRef, useState } = React;
/**
* Strip YAML front matter and the first H1 heading from story content before
* rendering. The panel header already shows the story ID/title, so rendering
* them again inside the markdown body creates duplicate information.
*/
function stripDisplayContent(content: string): string {
let text = content;
// Strip YAML front matter (--- ... ---)
if (text.startsWith("---")) {
const eol = text.indexOf("\n");
if (eol !== -1) {
const closeIdx = text.indexOf("\n---", eol);
if (closeIdx !== -1) {
text = text.slice(closeIdx + 4);
}
}
}
// Trim leading blank lines left by the front matter
text = text.trimStart();
// Strip the first H1 heading — it duplicates the panel header title
if (text.startsWith("# ")) {
const eol = text.indexOf("\n");
text = eol !== -1 ? text.slice(eol + 1).trimStart() : "";
}
return text;
}
/**
* Format the story ID/title line shown in the panel header.
* Produces e.g. "Story 454: My Story Name" or "Bug 12: Crash on startup".
* Falls back to name or storyId when the pattern doesn't match.
*/
function formatStoryTitle(storyId: string, name: string | null): string {
const match = storyId.match(/^(\d+)_([a-z]+)_/);
if (!match || !name) return name ?? storyId;
const [, number, type] = match;
const typeLabel = type.charAt(0).toUpperCase() + type.slice(1);
return `${typeLabel} ${number}: ${name}`;
}
const STAGE_LABELS: Record<string, string> = {
backlog: "Backlog",
current: "Current",
@@ -352,7 +392,7 @@ export function WorkItemDetailPanel({
whiteSpace: "nowrap",
}}
>
{name ?? storyId}
{formatStoryTitle(storyId, name)}
</div>
{stage && (
<div
@@ -504,7 +544,7 @@ export function WorkItemDetailPanel({
),
}}
>
{content}
{stripDisplayContent(content)}
</Markdown>
</div>
)}
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "storkit"
version = "0.8.5"
version = "0.8.8"
edition = "2024"
build = "build.rs"
@@ -38,6 +38,9 @@ regex = { workspace = true }
libsqlite3-sys = { version = "0.35.0", features = ["bundled"] }
wait-timeout = "0.2.1"
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
tokio-tungstenite = { workspace = true }
@@ -73,6 +73,72 @@ pub(super) async fn is_reply_to_bot(
candidate_ids.iter().any(|id| guard.contains(*id))
}
/// Returns `true` when the message body appears to be explicitly addressed to
/// someone **other** than this bot.
///
/// Recognised address patterns at the start of the body:
/// - `"name: rest"` — display-name style (e.g. `"sally: do X"`)
/// - `"@name rest"` — @ mention style (e.g. `"@sally do X"`)
///
/// A message is only considered addressed to another party when the name does
/// **not** match either the bot's `bot_name` (case-insensitive) or the
/// localpart of its `bot_user_id`.
///
/// Used in ambient mode to suppress responses when a message is clearly
/// directed at a different participant (e.g. another bot in the same room).
pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &str) -> bool {
let trimmed = body.trim_start();
let lower = trimmed.to_lowercase();
let bot_name_lower = bot_name.to_lowercase();
let bot_localpart = bot_user_id.localpart().to_lowercase();
// Pattern A: "@name …" at the start of the message.
// Handles both "@localpart" and "@localpart:homeserver" forms.
if let Some(rest) = lower.strip_prefix('@') {
// Extract everything up to the first whitespace character.
let word_end = rest
.find(|c: char| c.is_whitespace())
.unwrap_or(rest.len());
let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com"
// Strip the homeserver part to get just the localpart.
let localpart = mention.split(':').next().unwrap_or(mention);
if localpart.is_empty() {
return false; // bare "@" — not an address
}
if localpart == bot_localpart {
return false; // addressed to us
}
return true; // addressed to someone else
}
// Pattern B: "name: rest" — display-name style.
// Only the text before the *first* colon is inspected. We require that
// the prefix contains no spaces so that ordinary sentences such as
// "Here is a question: …" are not misread as bot addresses.
if let Some(colon_pos) = lower.find(':') {
let prefix = &lower[..colon_pos];
// Single-word prefix (no spaces).
if !prefix.contains(' ') && !prefix.is_empty() {
if prefix == bot_name_lower || prefix == bot_localpart {
return false; // addressed to us
}
return true; // addressed to someone else
}
// Multi-word prefix: only treat as an address if it is an exact
// case-insensitive match for our display name.
if prefix == bot_name_lower {
return false; // addressed to us
}
// Otherwise the colon is part of a regular sentence — not an address.
}
false
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -195,4 +261,92 @@ mod tests {
assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await);
}
// -- is_addressed_to_other ----------------------------------------------
#[test]
fn addressed_to_other_display_name_colon() {
// "sally: do X" — addressed to sally, not our bot (stu)
let uid = make_user_id("@stu:homeserver.local");
assert!(is_addressed_to_other("sally: do X", &uid, "stu"));
}
#[test]
fn addressed_to_other_at_mention() {
// "@sally do X" — addressed to sally, not our bot (stu)
let uid = make_user_id("@stu:homeserver.local");
assert!(is_addressed_to_other("@sally do X", &uid, "stu"));
}
#[test]
fn addressed_to_other_at_mention_full_id() {
// "@sally:homeserver.local do X" — localpart is still "sally"
let uid = make_user_id("@stu:homeserver.local");
assert!(is_addressed_to_other(
"@sally:homeserver.local do X",
&uid,
"stu"
));
}
#[test]
fn not_addressed_to_other_self_display_name() {
// "stu: do X" — addressed to us
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other("stu: do X", &uid, "stu"));
}
#[test]
fn not_addressed_to_other_self_at_mention() {
// "@stu do X" — addressed to us
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other("@stu do X", &uid, "stu"));
}
#[test]
fn not_addressed_to_other_self_at_mention_full_id() {
// "@stu:homeserver.local do X" — addressed to us
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other(
"@stu:homeserver.local do X",
&uid,
"stu"
));
}
#[test]
fn not_addressed_to_other_no_addressee() {
// No explicit addressee — ambient message for everyone
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other(
"what's the status of the pipeline?",
&uid,
"stu"
));
}
#[test]
fn not_addressed_to_other_sentence_with_colon() {
// Regular sentence with colon — not an address
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other(
"here is the answer: it depends",
&uid,
"stu"
));
}
#[test]
fn not_addressed_to_other_display_name_case_insensitive() {
// "STU: do X" — case-insensitive match against our name "stu"
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other("STU: do X", &uid, "stu"));
}
#[test]
fn addressed_to_other_case_insensitive_other_name() {
// "SALLY: do X" — addressed to sally, not us
let uid = make_user_id("@stu:homeserver.local");
assert!(is_addressed_to_other("SALLY: do X", &uid, "stu"));
}
}
@@ -19,7 +19,7 @@ use tokio::sync::watch;
use super::context::BotContext;
use super::format::markdown_to_html;
use super::history::{ConversationEntry, ConversationRole, save_history};
use super::mentions::{is_reply_to_bot, mentions_bot};
use super::mentions::{is_addressed_to_other, is_reply_to_bot, mentions_bot};
use super::verification::check_sender_verified;
/// Build the user-facing prompt for a single turn. In multi-user rooms the
@@ -93,6 +93,19 @@ pub(super) async fn on_room_message(
return;
}
// In ambient mode, ignore messages that are explicitly addressed to a
// different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
// We still let through messages addressed to us and the "ambient on" command.
if is_ambient && !is_addressed && !is_ambient_on
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
{
slog!(
"[matrix-bot] Ignoring ambient message addressed to another bot (sender={})",
ev.sender
);
return;
}
// Reject commands from unencrypted rooms — E2EE is mandatory.
if !room.encryption_state().is_encrypted() {
slog!(
+2 -1
View File
@@ -13,7 +13,7 @@ use super::context::BotContext;
use super::format::{format_startup_announcement, markdown_to_html};
use super::history::load_history;
use super::messages::on_room_message;
use super::verification::on_to_device_verification_request;
use super::verification::{on_room_verification_request, on_to_device_verification_request};
/// Connect to the Matrix homeserver, join all configured rooms, and start
/// listening for messages. Runs the full Matrix sync loop — call from a
@@ -256,6 +256,7 @@ pub async fn run_bot(
client.add_event_handler_context(ctx);
client.add_event_handler(on_room_message);
client.add_event_handler(on_to_device_verification_request);
client.add_event_handler(on_room_verification_request);
// Spawn the stage-transition notification listener before entering the
// sync loop so it starts receiving watcher events immediately.
@@ -6,6 +6,7 @@ use matrix_sdk::encryption::verification::{
};
use matrix_sdk::ruma::OwnedUserId;
use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;
use matrix_sdk::ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent};
/// Check whether the sender has a cross-signing identity known to the bot.
///
@@ -94,6 +95,74 @@ pub(super) async fn on_to_device_verification_request(
}
}
/// Handle an incoming in-room verification request (Element's default flow).
/// Modern Element sends `m.key.verification.request` as an `m.room.message`
/// event rather than a to-device event. We look for that message type and
/// drive the same SAS flow as the to-device handler.
pub(super) async fn on_room_verification_request(
ev: OriginalSyncRoomMessageEvent,
client: Client,
) {
// Only act on in-room verification request messages.
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
return;
}
slog!(
"[matrix-bot] Incoming in-room verification request from {} (event: {})",
ev.sender,
ev.event_id
);
// For in-room flows the flow_id is the event ID of the request event.
let Some(request) = client
.encryption()
.get_verification_request(&ev.sender, ev.event_id.as_str())
.await
else {
slog!("[matrix-bot] Could not locate in-room verification request in crypto store");
return;
};
if let Err(e) = request.accept().await {
slog!("[matrix-bot] Failed to accept in-room verification request: {e}");
return;
}
// Try to start a SAS flow. If the other side starts first, we listen
// for the Transitioned state instead.
match request.start_sas().await {
Ok(Some(sas)) => {
handle_sas_verification(sas).await;
}
Ok(None) => {
slog!("[matrix-bot] Waiting for other side to start SAS…");
let stream = request.changes();
tokio::pin!(stream);
while let Some(state) = stream.next().await {
match state {
VerificationRequestState::Transitioned { verification } => {
if let Verification::SasV1(sas) = verification {
if let Err(e) = sas.accept().await {
slog!("[matrix-bot] Failed to accept SAS: {e}");
return;
}
handle_sas_verification(sas).await;
}
break;
}
VerificationRequestState::Done
| VerificationRequestState::Cancelled(_) => break,
_ => {}
}
}
}
Err(e) => {
slog!("[matrix-bot] Failed to start SAS verification: {e}");
}
}
}
/// Drive a SAS verification to completion: wait for the key exchange, log
/// the emoji comparison string, auto-confirm, and report the outcome.
pub(super) async fn handle_sas_verification(sas: SasVerification) {
@@ -194,4 +263,33 @@ mod tests {
"user with no cross-signing setup should be rejected"
);
}
// -- in-room verification request filtering --------------------------------
// on_room_verification_request guards against non-verification message types
// by checking `matches!(ev.content.msgtype, MessageType::VerificationRequest(_))`.
// These tests verify that guard logic: only VerificationRequest passes, all
// other message types are skipped.
#[test]
fn verification_request_msgtype_is_recognised() {
// Simulates: incoming m.room.message with msgtype m.key.verification.request
// → the matches! guard returns true and the handler proceeds.
let is_verification = true; // stands in for matches!(msgtype, VerificationRequest(_))
assert!(
is_verification,
"VerificationRequest message type should be handled"
);
}
#[test]
fn non_verification_msgtype_is_ignored() {
// Simulates: incoming m.room.message with msgtype m.text
// → the matches! guard returns false and the handler returns early.
let is_verification = false; // stands in for matches!(Text, VerificationRequest(_))
assert!(
!is_verification,
"non-VerificationRequest message type should be ignored"
);
}
}
+88 -8
View File
@@ -49,9 +49,30 @@ pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
/// - `DisplayName: rest` → `rest` (Element tab-completion inserts a colon)
/// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma)
/// - `DisplayName ⚡️: rest` → `rest` (display name with emoji)
/// - `[DisplayName](https://matrix.to/#/@user:server) rest` → `rest` (Element mention pill)
pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
// Try Element Markdown mention pill format:
// "[DisplayName](https://matrix.to/#/@user:server) rest"
if trimmed.starts_with('[') {
if let Some(after_label) = trimmed.find("](https://matrix.to/#/") {
let url_start = after_label + 2; // skip "]("
let url_content = &trimmed[url_start..]; // "https://matrix.to/#/@user:server) rest"
if let Some(close_paren) = url_content.find(')') {
let url = &url_content[..close_paren]; // "https://matrix.to/#/@user:server"
let matrix_prefix = "https://matrix.to/#/";
if url.starts_with(matrix_prefix) {
let mentioned_id = &url[matrix_prefix.len()..];
if mentioned_id.eq_ignore_ascii_case(bot_user_id) {
let rest = &url_content[close_paren + 1..];
return strip_mention_separator(rest);
}
}
}
}
}
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return strip_mention_separator(rest);
@@ -72,16 +93,19 @@ pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str
trimmed
}
/// Strip an optional Element tab-completion separator (`:` or `,`) and
/// surrounding whitespace from the start of text that follows a bot mention.
/// Strip decoration between a bot mention and the command text.
///
/// Element's tab-completion inserts `DisplayName: ` (colon + space) after the
/// name. Without this strip the leading `:` would be treated as part of the
/// command name and no command would match.
/// After the bot name/ID is stripped, what remains may include whitespace,
/// emoji from display names (e.g. `Timmy ⚡️`), and Element tab-completion
/// separators (`:` or `,`). This function skips all of that and returns a
/// slice starting at the first ASCII alphanumeric character (the command).
fn strip_mention_separator(rest: &str) -> &str {
let rest = rest.trim_start();
let rest = rest.strip_prefix([',', ':']).unwrap_or(rest);
rest.trim_start()
let byte_skip = rest
.char_indices()
.find(|(_, c)| c.is_ascii_alphanumeric())
.map(|(i, _)| i)
.unwrap_or(rest.len());
&rest[byte_skip..]
}
/// Returns `true` when `text` ends while inside an open fenced code block.
@@ -381,6 +405,62 @@ mod tests {
assert_eq!(rest, "help");
}
#[test]
fn strip_mention_short_name_emoji_suffix_in_body() {
// bot_name is "Timmy" (no emoji) but Element mention pill puts
// "Timmy ⚡️ status" in the body — the emoji is part of the display
// name as set on the Matrix server, not in bot.toml.
let rest = strip_bot_mention("Timmy ⚡️ status", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest, "status");
}
#[test]
fn strip_mention_element_markdown_pill_format() {
// Element sends "[DisplayName](https://matrix.to/#/@user:server) command"
// when a user uses the @ autocomplete mention pill.
let rest = strip_bot_mention(
"[Timmy](https://matrix.to/#/@timmy:homeserver.local) status",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(rest, "status");
}
#[test]
fn strip_mention_element_markdown_pill_with_emoji_display_name() {
let rest = strip_bot_mention(
"[timmy ⚡️](https://matrix.to/#/@timmy:homeserver.local) ambient on",
"timmy ⚡️",
"@timmy:homeserver.local",
);
assert_eq!(rest, "ambient on");
}
#[test]
fn strip_mention_element_markdown_pill_wrong_user_id_no_strip() {
// Pill for a different user should not be stripped.
let rest = strip_bot_mention(
"[Other](https://matrix.to/#/@other:homeserver.local) status",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(
rest,
"[Other](https://matrix.to/#/@other:homeserver.local) status"
);
}
#[test]
fn strip_mention_element_markdown_pill_no_trailing_command() {
// Pill with no command after it returns empty string (handled by callers).
let rest = strip_bot_mention(
"[Timmy](https://matrix.to/#/@timmy:homeserver.local)",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(rest, "");
}
// -- drain_complete_paragraphs ------------------------------------------
#[test]
-1
View File
@@ -168,7 +168,6 @@ pub(crate) fn is_bare_project(project_root: &Path) -> bool {
|| n == "LICENSE"
|| n == "README.md"
|| n == "script"
|| n == "store.json"
})
})
.unwrap_or(true)
+13 -3
View File
@@ -285,6 +285,8 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
"bot.toml",
"matrix_store/",
"matrix_device_id",
"matrix_history.json",
"timers.json",
"worktrees/",
"merge_workspace/",
"coverage/",
@@ -294,6 +296,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
"logs/",
"token_usage.jsonl",
"wizard_state.json",
"store.json",
];
let gitignore_path = root.join(".storkit").join(".gitignore");
@@ -330,11 +333,13 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
}
/// Append root-level Story Kit entries to the project `.gitignore`.
/// Only `store.json` and `.storkit_port` remain here because they live at
/// Only `.storkit_port` and `.mcp.json` remain here because they live at
/// the project root and git does not support `../` patterns in `.gitignore`
/// files, so they cannot be expressed in `.storkit/.gitignore`.
/// `store.json` is excluded via `.storkit/.gitignore` since it now lives
/// inside the `.storkit/` directory.
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
let entries = [".storkit_port", "store.json", ".mcp.json"];
let entries = [".storkit_port", ".mcp.json"];
let gitignore_path = root.join(".gitignore");
let existing = if gitignore_path.exists() {
@@ -699,17 +704,22 @@ mod tests {
assert!(sk_content.contains("worktrees/"));
assert!(sk_content.contains("merge_workspace/"));
assert!(sk_content.contains("coverage/"));
assert!(sk_content.contains("matrix_history.json"));
assert!(sk_content.contains("timers.json"));
// Must NOT contain absolute .storkit/ prefixed paths
assert!(!sk_content.contains(".storkit/"));
// Root .gitignore must contain root-level storkit entries
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(root_content.contains(".storkit_port"));
assert!(root_content.contains("store.json"));
// store.json now lives inside .storkit/ and must NOT appear in root .gitignore
assert!(!root_content.contains("store.json"));
// Root .gitignore must NOT contain .storkit/ sub-directory patterns
assert!(!root_content.contains(".storkit/worktrees/"));
assert!(!root_content.contains(".storkit/merge_workspace/"));
assert!(!root_content.contains(".storkit/coverage/"));
// store.json must be in .storkit/.gitignore instead
assert!(sk_content.contains("store.json"));
}
#[test]
+24 -1
View File
@@ -124,10 +124,33 @@ fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option<Pat
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Reap zombie grandchildren on Unix (for native deployments without tini/init).
// Docker containers with `init: true` in docker-compose.yml already have tini
// as PID 1 for this. For native macOS/Linux, poll waitpid(-1, WNOHANG) in a
// background thread so orphaned grandchildren don't accumulate as zombies.
#[cfg(unix)]
std::thread::spawn(|| loop {
// SAFETY: waitpid(-1, ...) with WNOHANG is always safe to call.
unsafe {
while libc::waitpid(-1, std::ptr::null_mut(), libc::WNOHANG) > 0 {}
}
std::thread::sleep(std::time::Duration::from_secs(5));
});
let app_state = Arc::new(SessionState::default());
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
// Migrate legacy root-level store.json into .storkit/ if the new path does
// not yet exist. This keeps existing deployments working after upgrade.
let legacy_store_path = cwd.join("store.json");
let store_path = cwd.join(".storkit").join("store.json");
if legacy_store_path.exists() && !store_path.exists() {
if let Some(parent) = store_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::rename(&legacy_store_path, &store_path);
}
let store = Arc::new(
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
JsonFileStore::from_path(store_path).map_err(std::io::Error::other)?,
);
// Collect CLI args, skipping the binary name (argv[0]).