32 Commits

Author SHA1 Message Date
Timmy
71a6c72614 Adding in a holding page for a website 2026-03-25 14:27:06 +00:00
dave
fae7b3be20 storkit: create 388_story_whatsapp_webhook_hmac_signature_verification 2026-03-25 14:08:00 +00:00
dave
775b9ac7e3 storkit: create 388_story_whatsapp_webhook_hmac_signature_verification 2026-03-25 14:07:37 +00:00
dave
5a87d55dd4 storkit: done 391_bug_strip_prefix_ci_panics_on_multi_byte_utf_8_characters 2026-03-25 14:05:03 +00:00
dave
0457fbfecc storkit: merge 391_bug_strip_prefix_ci_panics_on_multi_byte_utf_8_characters 2026-03-25 14:04:59 +00:00
dave
13b16138b5 storkit: create 392_refactor_extract_shared_transport_utilities_from_matrix_module_into_chat_submodule 2026-03-25 13:59:02 +00:00
dave
8249896449 storkit: create 392_refactor_extract_shared_transport_utilities_from_matrix_module_into_chat_submodule 2026-03-25 13:58:06 +00:00
dave
dbd932bf46 storkit: create 392_bug_strip_prefix_ci_panics_on_multi_byte_utf_8_characters 2026-03-25 13:55:49 +00:00
dave
eef49678ce storkit: create 391_bug_strip_prefix_ci_panics_on_multi_byte_utf_8_characters 2026-03-25 13:54:13 +00:00
dave
58ee82c988 storkit: done 390_bug_whatsapp_missing_async_command_handlers_for_start_rebuild_reset_rmtree_assign 2026-03-25 13:47:24 +00:00
dave
49ac23044a storkit: done 389_story_whatsapp_phone_number_allowlist_authorization 2026-03-25 13:42:35 +00:00
dave
84a775be77 storkit: merge 389_story_whatsapp_phone_number_allowlist_authorization 2026-03-25 13:42:31 +00:00
dave
60c0c95f38 storkit: done 387_story_configurable_base_branch_name_in_project_toml 2026-03-25 13:33:45 +00:00
dave
a1a30bcc42 storkit: merge 387_story_configurable_base_branch_name_in_project_toml 2026-03-25 13:33:38 +00:00
dave
96ebd7ecb8 storkit: create 390_bug_whatsapp_missing_async_command_handlers_for_start_rebuild_reset_rmtree_assign 2026-03-25 13:22:16 +00:00
dave
25c8b1ec25 storkit: accept 386_story_unreleased_command_shows_list_of_stories_since_last_release 2026-03-25 02:13:11 +00:00
dave
bcb7cfabee storkit: accept 385_story_slack_markdown_to_mrkdwn_formatting_conversion 2026-03-25 02:02:10 +00:00
dave
d4dad1d556 storkit: accept 384_story_whatsapp_markdown_to_whatsapp_formatting_conversion 2026-03-25 01:34:08 +00:00
dave
195c7c51c4 storkit: create 389_story_whatsapp_phone_number_allowlist_authorization 2026-03-24 22:25:59 +00:00
dave
968d973cff storkit: create 388_story_whatsapp_webhook_hmac_signature_verification 2026-03-24 22:25:55 +00:00
dave
4394ab3fed storkit: done 386_story_unreleased_command_shows_list_of_stories_since_last_release 2026-03-24 22:23:05 +00:00
dave
11bbfca3da storkit: merge 386_story_unreleased_command_shows_list_of_stories_since_last_release 2026-03-24 22:23:01 +00:00
dave
a9aa88b655 storkit: create 387_story_configurable_base_branch_name_in_project_toml 2026-03-24 22:20:49 +00:00
dave
b62974dd88 storkit: create 387_story_configurable_base_branch_name_in_project_toml 2026-03-24 22:17:46 +00:00
dave
ac52a8bb4e storkit: done 385_story_slack_markdown_to_mrkdwn_formatting_conversion 2026-03-24 22:12:39 +00:00
dave
18755aac96 storkit: merge 385_story_slack_markdown_to_mrkdwn_formatting_conversion 2026-03-24 22:12:31 +00:00
dave
5d37421f70 storkit: create 386_story_unreleased_command_shows_list_of_stories_since_last_release 2026-03-24 22:12:16 +00:00
dave
224d269971 storkit: create 385_story_slack_markdown_to_mrkdwn_formatting_conversion 2026-03-24 22:01:58 +00:00
dave
6146a173f1 storkit: done 384_story_whatsapp_markdown_to_whatsapp_formatting_conversion 2026-03-24 21:54:19 +00:00
dave
821345d266 storkit: accept 383_refactor_reorganize_chat_system_into_chat_module_with_transport_submodules 2026-03-24 21:41:42 +00:00
dave
0fa63e2de3 storkit: accept 382_story_whatsapp_transport_supports_twilio_api_as_alternative_to_meta_cloud_api 2026-03-24 21:33:45 +00:00
dave
d8cbec8268 storkit: create 384_story_whatsapp_markdown_to_whatsapp_formatting_conversion 2026-03-24 21:33:44 +00:00
29 changed files with 1434 additions and 11 deletions

View File

@@ -26,3 +26,8 @@ whatsapp_verify_token = "my-secret-verify-token"
# Maximum conversation turns to remember per user (default: 20). # Maximum conversation turns to remember per user (default: 20).
# history_size = 20 # history_size = 20
# Optional: restrict which phone numbers can interact with the bot.
# When set, only listed numbers are processed; all others are silently ignored.
# When absent or empty, all numbers are allowed (open by default).
# whatsapp_allowed_phones = ["+15551234567", "+15559876543"]

View File

@@ -22,3 +22,8 @@ twilio_whatsapp_number = "+14155238886"
# Maximum conversation turns to remember per user (default: 20). # Maximum conversation turns to remember per user (default: 20).
# history_size = 20 # history_size = 20
# Optional: restrict which phone numbers can interact with the bot.
# When set, only listed numbers are processed; all others are silently ignored.
# When absent or empty, all numbers are allowed (open by default).
# whatsapp_allowed_phones = ["+15551234567", "+15559876543"]

View File

@@ -13,6 +13,11 @@ max_coders = 3
# Set to 0 to disable retry limits. # Set to 0 to disable retry limits.
max_retries = 2 max_retries = 2
# Base branch name for this project. Worktree creation, merges, and agent prompts
# use this value for {{base_branch}}. When not set, falls back to auto-detection
# (reads current HEAD branch).
base_branch = "master"
[[component]] [[component]]
name = "frontend" name = "frontend"
path = "frontend" path = "frontend"

View File

@@ -0,0 +1,43 @@
# Example project.toml — copy to .storkit/project.toml and customise.
# This file is checked in; project.toml itself is gitignored (it may contain
# instance-specific settings).
# Project-wide default QA mode: "server", "agent", or "human".
# Per-story `qa` front matter overrides this setting.
default_qa = "server"
# Default model for coder agents. Only agents with this model are auto-assigned.
# Opus coders are reserved for explicit per-story `agent:` front matter requests.
default_coder_model = "sonnet"
# Maximum concurrent coder agents. Stories wait in 2_current/ when all slots are full.
max_coders = 3
# Maximum retries per story per pipeline stage before marking as blocked.
# Set to 0 to disable retry limits.
max_retries = 2
# Base branch name for this project. Worktree creation, merges, and agent prompts
# use this value for {{base_branch}}. When not set, falls back to auto-detection
# (reads current HEAD branch).
base_branch = "main"
[[component]]
name = "server"
path = "."
setup = ["cargo build"]
teardown = []
[[agent]]
name = "coder-1"
role = "Full-stack engineer"
stage = "coder"
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = """
You are working in a git worktree on story {{story_id}}.
Read CLAUDE.md first, then .storkit/README.md to understand the dev process.
Run: cd "{{worktree_path}}" && git difftool {{base_branch}}...HEAD
Commit all your work before your process exits.
"""

View File

@@ -0,0 +1,24 @@
---
name: "WhatsApp webhook HMAC signature verification"
retry_count: 3
blocked: true
---
# Story 388: WhatsApp webhook HMAC signature verification
## User Story
As a bot operator, I want incoming WhatsApp webhook requests to be cryptographically verified, so that forged requests from unauthorized sources are rejected.
## Acceptance Criteria
- [ ] Meta webhooks: validate X-Hub-Signature-256 HMAC-SHA256 header using the app secret before processing
- [ ] Twilio webhooks: validate request signature using the auth token before processing
- [ ] Requests with missing or invalid signatures are rejected with 403 Forbidden
- [ ] Verification is fail-closed: if signature checking is configured, unsigned requests are rejected
- [ ] Existing bot.toml config is extended with any needed secrets (e.g. Meta app_secret for HMAC verification)
- [ ] MUST use audited crypto crates (hmac, sha2, sha1, base64) — no hand-rolled cryptographic primitives
## Out of Scope
- TBD

View File

@@ -0,0 +1,27 @@
---
name: "Extract shared transport utilities from matrix module into chat submodule"
agent: "coder-opus"
---
# Refactor 392: Extract shared transport utilities from matrix module into chat submodule
## Current State
- TBD
## Desired State
Several functions currently living in the matrix transport module are used by all transports (WhatsApp, Slack, Matrix). These should be pulled up into a shared location under the chat module. Candidates include: strip_prefix_ci, strip_bot_mention, try_handle_command, drain_complete_paragraphs, markdown_to_whatsapp (pattern could generalize), chunk_for_whatsapp, and the command dispatch infrastructure. A chat::util or chat::text submodule would be a natural home for string utilities like strip_prefix_ci. The command dispatch (try_handle_command, CommandDispatch, BotCommand registry) could live in chat::commands.
## Acceptance Criteria
- [ ] Shared string utilities (strip_prefix_ci, strip_bot_mention, drain_complete_paragraphs) moved to a chat::util or chat::text submodule
- [ ] Command dispatch infrastructure (try_handle_command, CommandDispatch, BotCommand, command registry) moved to chat::commands
- [ ] Per-transport formatting functions (markdown_to_whatsapp, markdown_to_slack) remain in their respective transport modules
- [ ] All transports import from the new shared location instead of reaching into matrix::
- [ ] No functional changes — purely structural refactor
- [ ] All existing tests pass and move with their code
## Out of Scope
- TBD

View File

@@ -0,0 +1,23 @@
---
name: "Configurable base branch name in project.toml"
---
# Story 387: Configurable base branch name in project.toml
## User Story
As a project owner, I want to configure the main branch name in project.toml (e.g. "main", "master", "develop"), so that the system doesn't hardcode "master" and works with any branching convention.
## Acceptance Criteria
- [ ] New optional `base_branch` setting in project.toml (e.g. base_branch = "main")
- [ ] When set, all worktree creation, merge operations, and agent prompts use the configured branch name
- [ ] When not set, falls back to the existing auto-detection logic (detect_base_branch) which reads the current git branch
- [ ] The hardcoded "master" fallback in detect_base_branch is replaced by the project.toml setting when available
- [ ] Agent prompt template {{base_branch}} resolves to the configured value
- [ ] Existing projects without the setting continue to work unchanged (backwards compatible)
- [ ] project.toml.example uses base_branch = \"main\" as the example value; the actual project.toml uses base_branch = \"master\"
## Out of Scope
- TBD

View File

@@ -0,0 +1,21 @@
---
name: "WhatsApp phone number allowlist authorization"
---
# Story 389: WhatsApp phone number allowlist authorization
## User Story
As a bot operator, I want to restrict which phone numbers can interact with the bot, so that only authorized users can send commands.
## Acceptance Criteria
- [ ] New optional allowed_phones list in bot.toml for WhatsApp (similar to Matrix allowed_users)
- [ ] When configured, only messages from listed phone numbers are processed; all others are silently ignored
- [ ] When not configured (empty or absent), all phone numbers are allowed (backwards compatible)
- [ ] Unauthorized senders are logged but receive no response
- [ ] The allowlist applies to all message types: commands, LLM conversations, and async commands (htop, delete)
## Out of Scope
- TBD

View File

@@ -0,0 +1,30 @@
---
name: "WhatsApp missing async command handlers for start, rebuild, reset, rmtree, assign"
---
# Bug 390: WhatsApp missing async command handlers for start, rebuild, reset, rmtree, assign
## Description
Five bot commands listed in help don't work in WhatsApp. Matrix's on_room_message pre-dispatches these via extract_*_command() functions before calling try_handle_command(), but WhatsApp's handle_incoming_message only pre-dispatches htop and delete. The missing commands have fallback handlers that return None, so they silently fall through to the LLM instead of executing.
## How to Reproduce
1. Send "rebuild" (or "start 386", "reset", "rmtree 386", "assign 386 opus") to the WhatsApp bot\n2. Observe the message is forwarded to the LLM instead of executing the command
## Actual Result
The 5 commands (start, rebuild, reset, rmtree, assign) fall through to the LLM and generate a conversational response instead of executing the bot command.
## Expected Result
All commands listed in help should work in WhatsApp, matching Matrix behavior. start should spawn an agent, rebuild should rebuild the server, reset should clear the session, rmtree should remove a worktree, assign should pre-assign a model.
## Acceptance Criteria
- [ ] start command works in WhatsApp (extract_start_command dispatch)
- [ ] rebuild command works in WhatsApp (extract_rebuild_command dispatch)
- [ ] reset command works in WhatsApp (extract_reset_command dispatch)
- [ ] rmtree command works in WhatsApp (extract_rmtree_command dispatch)
- [ ] assign command works in WhatsApp (extract_assign_command dispatch)
- [ ] Same 5 commands also work in Slack transport if similarly missing

View File

@@ -0,0 +1,27 @@
---
name: "strip_prefix_ci panics on multi-byte UTF-8 characters"
---
# Bug 391: strip_prefix_ci panics on multi-byte UTF-8 characters
## Description
strip_prefix_ci in commands/mod.rs slices text by byte offset using prefix.len(), which panics when the slice boundary falls inside a multi-byte UTF-8 character (e.g. right single quote U+2019, emojis). The function assumes ASCII-safe byte boundaries but real WhatsApp/Matrix messages contain Unicode.
## How to Reproduce
1. Send a message to the bot containing a smart quote or emoji within the first N bytes (where N = bot name length)\n2. e.g. "For now let\u2019s just deal with it" where the bot name prefix check slices at byte 12, inside the 3-byte \u2019 character
## Actual Result
Thread panics: "byte index 12 is not a char boundary; it is inside \u2018\u2019\u2019 (bytes 11..14)"
## Expected Result
The function should safely handle multi-byte UTF-8 without panicking. If the slice boundary isn't a char boundary, the prefix doesn't match — return None.
## Acceptance Criteria
- [ ] strip_prefix_ci does not panic on messages containing multi-byte UTF-8 characters (smart quotes, emojis, CJK, etc.)
- [ ] Use text.get(..prefix.len()) or text.is_char_boundary() instead of direct indexing
- [ ] Add test cases for messages with emojis and smart quotes

View File

@@ -0,0 +1,23 @@
---
name: "WhatsApp markdown-to-WhatsApp formatting conversion"
---
# Story 384: WhatsApp markdown-to-WhatsApp formatting conversion
## User Story
As a WhatsApp user, I want bot messages to use WhatsApp-native formatting instead of raw markdown, so that headers, bold text, and links render properly.
## Acceptance Criteria
- [ ] Headers (# ## ### etc.) are converted to bold text (*Header*) in WhatsApp messages
- [ ] Markdown bold (**text**) is converted to WhatsApp bold (*text*)
- [ ] Markdown strikethrough (~~text~~) is converted to WhatsApp strikethrough (~text~)
- [ ] Markdown links [text](url) are converted to readable format: text (url)
- [ ] Code blocks and inline code are preserved as-is (already compatible)
- [ ] Matrix bot formatting is completely unaffected (conversion only applied in WhatsApp send paths)
- [ ] Existing WhatsApp chunking (4096 char limit) still works correctly after conversion
## Out of Scope
- TBD

View File

@@ -0,0 +1,23 @@
---
name: "Slack markdown-to-mrkdwn formatting conversion"
---
# Story 385: Slack markdown-to-mrkdwn formatting conversion
## User Story
As a Slack user, I want bot messages to use Slack-native mrkdwn formatting instead of raw markdown, so that headers, bold text, and links render properly.
## Acceptance Criteria
- [ ] Headers (# ## ### etc.) are converted to bold text (*Header*) in Slack messages
- [ ] Markdown bold (**text**) is converted to Slack bold (*text*)
- [ ] Markdown strikethrough (~~text~~) is converted to Slack strikethrough (~text~)
- [ ] Markdown links [text](url) are converted to Slack format: <url|text>
- [ ] Code blocks and inline code are preserved as-is (already compatible)
- [ ] WhatsApp and Matrix bot formatting are completely unaffected (conversion only applied in Slack send paths)
- [ ] Conversion is applied to all Slack send paths: command responses, LLM streaming, htop snapshots, delete responses, and slash command responses
## Out of Scope
- TBD

View File

@@ -0,0 +1,22 @@
---
name: "Unreleased command shows list of stories since last release"
---
# Story 386: Unreleased command shows list of stories since last release
## User Story
As a user, I want a bot command and web UI slash command called "unreleased" that shows a list of stories completed since the last release, so that I can see what's ready to ship.
## Acceptance Criteria
- [ ] Bot command `unreleased` returns a list of stories merged to master since the last release tag
- [ ] Web UI slash command /unreleased returns the same list
- [ ] Each entry shows story number and name
- [ ] If there are no unreleased stories, a clear message is shown
- [ ] Command is registered in the help command output
- [ ] WhatsApp, Slack, and Matrix transports all support the command via the shared command dispatcher
## Out of Scope
- TBD

1
Cargo.lock generated
View File

@@ -4037,6 +4037,7 @@ dependencies = [
"poem-openapi", "poem-openapi",
"portable-pty", "portable-pty",
"pulldown-cmark", "pulldown-cmark",
"regex",
"reqwest 0.13.2", "reqwest 0.13.2",
"rust-embed", "rust-embed",
"serde", "serde",

View File

@@ -38,3 +38,4 @@ matrix-sdk = { version = "0.16.0", default-features = false, features = [
pulldown-cmark = { version = "0.13.3", default-features = false, features = [ pulldown-cmark = { version = "0.13.3", default-features = false, features = [
"html", "html",
] } ] }
regex = "1"

View File

@@ -31,6 +31,7 @@ uuid = { workspace = true, features = ["v4", "serde"] }
walkdir = { workspace = true } walkdir = { workspace = true }
matrix-sdk = { workspace = true } matrix-sdk = { workspace = true }
pulldown-cmark = { workspace = true } pulldown-cmark = { workspace = true }
regex = { workspace = true }
# Force bundled SQLite so static musl builds don't need a system libsqlite3 # Force bundled SQLite so static musl builds don't need a system libsqlite3
libsqlite3-sys = { version = "0.35.0", features = ["bundled"] } libsqlite3-sys = { version = "0.35.0", features = ["bundled"] }

View File

@@ -15,6 +15,7 @@ mod overview;
mod show; mod show;
mod status; mod status;
mod triage; mod triage;
mod unreleased;
use crate::agents::AgentPool; use crate::agents::AgentPool;
use std::collections::HashSet; use std::collections::HashSet;
@@ -152,6 +153,11 @@ pub fn commands() -> &'static [BotCommand] {
description: "Rebuild the server binary and restart", description: "Rebuild the server binary and restart",
handler: handle_rebuild_fallback, handler: handle_rebuild_fallback,
}, },
BotCommand {
name: "unreleased",
description: "Show stories merged to master since the last release tag",
handler: unreleased::handle_unreleased,
},
] ]
} }
@@ -222,10 +228,8 @@ fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) ->
/// Case-insensitive prefix strip that also requires the match to end at a /// Case-insensitive prefix strip that also requires the match to end at a
/// word boundary (whitespace, punctuation, or end-of-string). /// word boundary (whitespace, punctuation, or end-of-string).
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() { let candidate = text.get(..prefix.len())?;
return None; if !candidate.eq_ignore_ascii_case(prefix) {
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None; return None;
} }
let rest = &text[prefix.len()..]; let rest = &text[prefix.len()..];
@@ -445,6 +449,22 @@ pub(crate) mod tests {
assert_eq!(strip_prefix_ci("hello", "hello"), Some("")); assert_eq!(strip_prefix_ci("hello", "hello"), Some(""));
} }
#[test]
fn strip_prefix_ci_multibyte_no_panic_smart_quote() {
// "abcde\u{2019}xyz" — U+2019 is 3 bytes starting at byte 5.
// A prefix of length 6 (e.g. "abcdef") lands inside the 3-byte char.
// Previously this caused: "byte index 6 is not a char boundary".
let text = "abcde\u{2019}xyz";
assert_eq!(strip_prefix_ci(text, "abcdef"), None);
}
#[test]
fn strip_prefix_ci_multibyte_no_panic_emoji() {
// U+1F600 is 4 bytes starting at byte 3. Prefix length 4 lands inside it.
let text = "abc\u{1F600}def";
assert_eq!(strip_prefix_ci(text, "abcd"), None);
}
// -- commands registry -------------------------------------------------- // -- commands registry --------------------------------------------------
#[test] #[test]

View File

@@ -0,0 +1,308 @@
//! Handler for the `unreleased` command.
//!
//! Shows a list of stories merged to master since the last release tag.
use super::CommandContext;
/// Show stories merged since the last release tag.
///
/// Finds the most recent git tag, then lists all story merge commits between
/// that tag and HEAD on master. Each entry shows the story number and name.
/// Returns a clear message when there are no unreleased stories or no tags.
pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
let root = ctx.project_root;
let tag = find_last_release_tag(root);
let commits = list_merge_commits_since(root, tag.as_deref());
if commits.is_empty() {
let msg = match &tag {
Some(t) => format!(
"No unreleased stories since the last release tag **{t}**."
),
None => "No release tags found and no story merge commits on master.".to_string(),
};
return Some(msg);
}
let mut stories: Vec<(u64, String)> = commits
.iter()
.filter_map(|subject| parse_story_from_subject(subject))
.collect();
// Sort by story number, deduplicate.
stories.sort_by_key(|(n, _)| *n);
stories.dedup_by_key(|(n, _)| *n);
if stories.is_empty() {
let msg = match &tag {
Some(t) => format!(
"No unreleased stories since the last release tag **{t}**."
),
None => "No release tags found and no story merge commits on master.".to_string(),
};
return Some(msg);
}
// Look up human-readable names for each story.
let mut out = match &tag {
Some(t) => format!("**Unreleased stories since {t}:**\n\n"),
None => "**Unreleased stories (no prior release tag):**\n\n".to_string(),
};
for (num, slug) in &stories {
let name = find_story_name(root, &num.to_string())
.unwrap_or_else(|| slug_to_name(slug));
out.push_str(&format!("- **{num}** — {name}\n"));
}
Some(out)
}
// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------
/// Return the most recent release tag, or `None` if there are no tags.
///
/// Uses `git tag --sort=-creatordate` to get the newest tag first.
fn find_last_release_tag(root: &std::path::Path) -> Option<String> {
use std::process::Command;
let output = Command::new("git")
.args(["tag", "--sort=-creatordate"])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())?;
let text = String::from_utf8_lossy(&output.stdout);
let tag = text.lines().next()?.trim().to_string();
if tag.is_empty() { None } else { Some(tag) }
}
/// Return the subjects of all `storkit: merge …` commits reachable from HEAD
/// but not from `since_tag` (or all commits when `since_tag` is `None`).
fn list_merge_commits_since(
root: &std::path::Path,
since_tag: Option<&str>,
) -> Vec<String> {
use std::process::Command;
let range = match since_tag {
Some(tag) => format!("{tag}..HEAD"),
None => "HEAD".to_string(),
};
let output = Command::new("git")
.args([
"log",
&range,
"--format=%s",
"--extended-regexp",
"--grep",
"(storkit|story-kit): merge [0-9]+_",
])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success());
match output {
Some(o) => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect(),
None => Vec::new(),
}
}
/// Parse a story number and slug from a merge commit subject like
/// `storkit: merge 386_story_unreleased_command`.
///
/// Returns `(story_number, slug_remainder)` or `None` if the subject doesn't
/// match the expected pattern.
fn parse_story_from_subject(subject: &str) -> Option<(u64, String)> {
// Match "storkit: merge NNN_rest" or "story-kit: merge NNN_rest"
let rest = subject
.strip_prefix("storkit: merge ")
.or_else(|| subject.strip_prefix("story-kit: merge "))?;
let (num_str, slug) = rest.split_once('_')?;
let num: u64 = num_str.parse().ok()?;
Some((num, slug.to_string()))
}
/// Convert an underscore-separated slug to a title-case name.
///
/// Used as a fallback when no pipeline file is found.
fn slug_to_name(slug: &str) -> String {
let words: Vec<String> = slug
.split('_')
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect();
words.join(" ")
}
/// Find the human-readable name of a story by searching all pipeline stages.
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
const STAGES: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in STAGES {
let dir = root.join(".storkit").join("work").join(stage);
if !dir.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
return std::fs::read_to_string(&path).ok().and_then(|c| {
crate::io::story_metadata::parse_front_matter(&c)
.ok()
.and_then(|m| m.name)
});
}
}
}
}
}
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn unreleased_cmd_with_root(root: &std::path::Path) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, "@timmy unreleased")
}
#[test]
fn unreleased_command_is_registered() {
let found = super::super::commands().iter().any(|c| c.name == "unreleased");
assert!(found, "unreleased command must be in the registry");
}
#[test]
fn unreleased_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("unreleased"),
"help should list unreleased command: {output}"
);
}
#[test]
fn unreleased_command_no_tags_returns_message() {
// A temp dir that is not a git repo — git commands will fail gracefully.
let tmp = tempfile::TempDir::new().unwrap();
let output = unreleased_cmd_with_root(tmp.path()).unwrap();
// Should return some message (not panic), either about no tags or no commits.
assert!(!output.is_empty(), "should return a non-empty message: {output}");
}
#[test]
fn unreleased_command_real_repo_returns_response() {
// Run against the actual repo root to exercise the git path.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let output = unreleased_cmd_with_root(repo_root).unwrap();
// The response should mention "unreleased" or "no unreleased" — just make
// sure it's non-empty and doesn't panic.
assert!(!output.is_empty(), "should return a non-empty message: {output}");
}
#[test]
fn unreleased_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy UNRELEASED",
);
assert!(result.is_some(), "UNRELEASED should match case-insensitively");
}
// -- parse_story_from_subject ------------------------------------------
#[test]
fn parse_story_storkit_prefix() {
let result = parse_story_from_subject("storkit: merge 386_story_unreleased_command");
assert_eq!(result, Some((386, "story_unreleased_command".to_string())));
}
#[test]
fn parse_story_legacy_prefix() {
let result = parse_story_from_subject("story-kit: merge 42_story_add_feature");
assert_eq!(result, Some((42, "story_add_feature".to_string())));
}
#[test]
fn parse_story_no_match() {
let result = parse_story_from_subject("fix: typo in README");
assert_eq!(result, None);
}
#[test]
fn parse_story_no_underscore_after_number() {
let result = parse_story_from_subject("storkit: merge 123");
assert_eq!(result, None);
}
// -- slug_to_name --------------------------------------------------
#[test]
fn slug_to_name_basic() {
assert_eq!(slug_to_name("story_add_feature"), "Story Add Feature");
}
#[test]
fn slug_to_name_single_word() {
assert_eq!(slug_to_name("feature"), "Feature");
}
}

View File

@@ -114,6 +114,14 @@ pub struct BotConfig {
#[serde(default)] #[serde(default)]
pub twilio_whatsapp_number: Option<String>, pub twilio_whatsapp_number: Option<String>,
/// Phone numbers allowed to interact with the bot when using WhatsApp.
/// When non-empty, only listed numbers can send commands; all others are
/// silently ignored. When empty or absent, all numbers are allowed
/// (backwards compatible — open by default, unlike Matrix which is
/// fail-closed).
#[serde(default)]
pub whatsapp_allowed_phones: Vec<String>,
// ── Slack Bot API fields ───────────────────────────────────────── // ── Slack Bot API fields ─────────────────────────────────────────
// These are only required when `transport = "slack"`. // These are only required when `transport = "slack"`.
@@ -1010,4 +1018,40 @@ slack_signing_secret = "secret123"
.unwrap(); .unwrap();
assert!(BotConfig::load(tmp.path()).is_none()); assert!(BotConfig::load(tmp.path()).is_none());
} }
#[test]
fn whatsapp_allowed_phones_defaults_to_empty_when_absent() {
let config: BotConfig = toml::from_str(
r#"
enabled = true
transport = "whatsapp"
whatsapp_provider = "meta"
whatsapp_phone_number_id = "123"
whatsapp_access_token = "tok"
whatsapp_verify_token = "ver"
"#,
)
.unwrap();
assert!(config.whatsapp_allowed_phones.is_empty());
}
#[test]
fn whatsapp_allowed_phones_deserializes_list() {
let config: BotConfig = toml::from_str(
r#"
enabled = true
transport = "whatsapp"
whatsapp_provider = "meta"
whatsapp_phone_number_id = "123"
whatsapp_access_token = "tok"
whatsapp_verify_token = "ver"
whatsapp_allowed_phones = ["+15551234567", "+15559876543"]
"#,
)
.unwrap();
assert_eq!(
config.whatsapp_allowed_phones,
vec!["+15551234567", "+15559876543"]
);
}
} }

View File

@@ -682,6 +682,7 @@ pub async fn slash_command_receive(
let response_text = try_handle_command(&dispatch, &synthetic_message) let response_text = try_handle_command(&dispatch, &synthetic_message)
.unwrap_or_else(|| format!("Command `{keyword}` did not produce a response.")); .unwrap_or_else(|| format!("Command `{keyword}` did not produce a response."));
let response_text = markdown_to_slack(&response_text);
let resp = SlashCommandResponse { let resp = SlashCommandResponse {
response_type: "ephemeral", response_type: "ephemeral",
@@ -714,6 +715,7 @@ async fn handle_incoming_message(
if let Some(response) = try_handle_command(&dispatch, message) { if let Some(response) = try_handle_command(&dispatch, message) {
slog!("[slack] Sending command response to {channel}"); slog!("[slack] Sending command response to {channel}");
let response = markdown_to_slack(&response);
if let Err(e) = ctx.transport.send_message(channel, &response, "").await { if let Err(e) = ctx.transport.send_message(channel, &response, "").await {
slog!("[slack] Failed to send reply to {channel}: {e}"); slog!("[slack] Failed to send reply to {channel}: {e}");
} }
@@ -739,6 +741,7 @@ async fn handle_incoming_message(
// On Slack, htop uses native message editing for live updates. // On Slack, htop uses native message editing for live updates.
let snapshot = let snapshot =
crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs); crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
let snapshot = markdown_to_slack(&snapshot);
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await { let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
Ok(id) => id, Ok(id) => id,
Err(e) => { Err(e) => {
@@ -760,6 +763,7 @@ async fn handle_incoming_message(
(tick * 2) as u32, (tick * 2) as u32,
duration_secs, duration_secs,
); );
let updated = markdown_to_slack(&updated);
if let Err(e) = if let Err(e) =
transport.edit_message(&ch, &msg_id, &updated, "").await transport.edit_message(&ch, &msg_id, &updated, "").await
{ {
@@ -793,6 +797,7 @@ async fn handle_incoming_message(
format!("Usage: `{} delete <number>`", ctx.bot_name) format!("Usage: `{} delete <number>`", ctx.bot_name)
} }
}; };
let response = markdown_to_slack(&response);
let _ = ctx.transport.send_message(channel, &response, "").await; let _ = ctx.transport.send_message(channel, &response, "").await;
return; return;
} }
@@ -839,7 +844,8 @@ async fn handle_llm_message(
let post_channel = channel.to_string(); let post_channel = channel.to_string();
let post_task = tokio::spawn(async move { let post_task = tokio::spawn(async move {
while let Some(chunk) = msg_rx.recv().await { while let Some(chunk) = msg_rx.recv().await {
let _ = post_transport.send_message(&post_channel, &chunk, "").await; let formatted = markdown_to_slack(&chunk);
let _ = post_transport.send_message(&post_channel, &formatted, "").await;
} }
}); });
@@ -944,6 +950,63 @@ async fn handle_llm_message(
} }
} }
// ── Markdown → mrkdwn conversion ────────────────────────────────────────
/// Convert Markdown text to Slack mrkdwn format.
///
/// Slack uses its own "mrkdwn" syntax which differs from standard Markdown.
/// This function converts common Markdown constructs so messages render
/// nicely in Slack instead of showing raw Markdown syntax.
pub fn markdown_to_slack(text: &str) -> String {
use regex::Regex;
use std::sync::LazyLock;
// Regexes are compiled once and reused across calls.
static RE_FENCED_BLOCK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?ms)^```.*?\n(.*?)^```").unwrap());
static RE_HEADER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
static RE_BOLD_ITALIC: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
static RE_BOLD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_STRIKETHROUGH: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
static RE_LINK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
// 1. Protect fenced code blocks by replacing them with placeholders.
let mut code_blocks: Vec<String> = Vec::new();
let protected = RE_FENCED_BLOCK.replace_all(text, |caps: &regex::Captures| {
let idx = code_blocks.len();
code_blocks.push(caps[0].to_string());
format!("\x00CODEBLOCK{idx}\x00")
});
let mut out = protected.into_owned();
// 2. Headers → bold text.
out = RE_HEADER.replace_all(&out, "*$1*").into_owned();
// 3. Bold+italic (***text***) → bold italic (*_text_*).
out = RE_BOLD_ITALIC.replace_all(&out, "*_${1}_*").into_owned();
// 4. Bold (**text**) → Slack bold (*text*).
out = RE_BOLD.replace_all(&out, "*$1*").into_owned();
// 5. Strikethrough (~~text~~) → Slack strikethrough (~text~).
out = RE_STRIKETHROUGH.replace_all(&out, "~$1~").into_owned();
// 6. Links [text](url) → Slack mrkdwn format <url|text>.
out = RE_LINK.replace_all(&out, "<$2|$1>").into_owned();
// 7. Restore code blocks.
for (idx, block) in code_blocks.iter().enumerate() {
out = out.replace(&format!("\x00CODEBLOCK{idx}\x00"), block);
}
out
}
// ── Tests ─────────────────────────────────────────────────────────────── // ── Tests ───────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
@@ -1460,4 +1523,71 @@ mod tests {
let output = result.unwrap(); let output = result.unwrap();
assert!(output.contains("999"), "show output should reference the story number: {output}"); assert!(output.contains("999"), "show output should reference the story number: {output}");
} }
// ── markdown_to_slack tests ──────────────────────────────────────────
#[test]
fn slack_headers_become_bold() {
assert_eq!(markdown_to_slack("# Title"), "*Title*");
assert_eq!(markdown_to_slack("## Subtitle"), "*Subtitle*");
assert_eq!(markdown_to_slack("### Section"), "*Section*");
assert_eq!(markdown_to_slack("###### Deep"), "*Deep*");
}
#[test]
fn slack_bold_converted() {
assert_eq!(markdown_to_slack("**bold text**"), "*bold text*");
}
#[test]
fn slack_bold_italic_converted() {
assert_eq!(markdown_to_slack("***emphasis***"), "*_emphasis_*");
}
#[test]
fn slack_strikethrough_converted() {
assert_eq!(markdown_to_slack("~~removed~~"), "~removed~");
}
#[test]
fn slack_links_converted_to_mrkdwn() {
assert_eq!(
markdown_to_slack("[click here](https://example.com)"),
"<https://example.com|click here>"
);
}
#[test]
fn slack_inline_code_preserved() {
assert_eq!(markdown_to_slack("use `foo()` here"), "use `foo()` here");
}
#[test]
fn slack_fenced_code_block_preserved() {
let input = "```rust\nlet x = 1;\n```";
let output = markdown_to_slack(input);
assert!(output.contains("let x = 1;"), "code block content must be preserved");
assert!(output.contains("```"), "fenced code delimiters must be preserved");
}
#[test]
fn slack_code_block_content_not_transformed() {
let input = "```\n**not bold** # not header\n```";
let output = markdown_to_slack(input);
assert!(
output.contains("**not bold**"),
"markdown inside code blocks must not be transformed"
);
}
#[test]
fn slack_plain_text_unchanged() {
let plain = "Hello, this is a plain message with no formatting.";
assert_eq!(markdown_to_slack(plain), plain);
}
#[test]
fn slack_empty_string_unchanged() {
assert_eq!(markdown_to_slack(""), "");
}
} }

View File

@@ -728,6 +728,73 @@ pub fn chunk_for_whatsapp(text: &str) -> Vec<String> {
chunks chunks
} }
// ── Markdown → WhatsApp formatting ───────────────────────────────────
/// Convert standard Markdown formatting to WhatsApp-native formatting.
///
/// WhatsApp supports a limited subset of formatting:
/// - Bold: `*text*`
/// - Italic: `_text_`
/// - Strikethrough: `~text~`
/// - Monospace / code: backtick-delimited (same as Markdown)
///
/// This function converts common Markdown constructs so messages render
/// nicely in WhatsApp instead of showing raw Markdown syntax.
pub fn markdown_to_whatsapp(text: &str) -> String {
use regex::Regex;
use std::sync::LazyLock;
// Regexes are compiled once and reused across calls.
static RE_FENCED_BLOCK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?ms)^```.*?\n(.*?)^```").unwrap());
static RE_HEADER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
static RE_BOLD_ITALIC: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
static RE_BOLD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_STRIKETHROUGH: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
static RE_LINK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
static RE_HR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^---+$").unwrap());
// 1. Protect fenced code blocks by replacing them with placeholders.
let mut code_blocks: Vec<String> = Vec::new();
let protected = RE_FENCED_BLOCK.replace_all(text, |caps: &regex::Captures| {
let idx = code_blocks.len();
code_blocks.push(caps[0].to_string());
format!("\x00CODEBLOCK{idx}\x00")
});
let mut out = protected.into_owned();
// 2. Headers → bold text.
out = RE_HEADER.replace_all(&out, "*$1*").into_owned();
// 3. Bold+italic (***text***) → bold italic (*_text_*).
out = RE_BOLD_ITALIC.replace_all(&out, "*_${1}_*").into_owned();
// 4. Bold (**text**) → WhatsApp bold (*text*).
out = RE_BOLD.replace_all(&out, "*$1*").into_owned();
// 5. Strikethrough (~~text~~) → WhatsApp strikethrough (~text~).
out = RE_STRIKETHROUGH.replace_all(&out, "~$1~").into_owned();
// 6. Links [text](url) → text (url).
out = RE_LINK.replace_all(&out, "$1 ($2)").into_owned();
// 7. Horizontal rules → empty line (just remove them).
out = RE_HR.replace_all(&out, "").into_owned();
// 8. Restore code blocks.
for (idx, block) in code_blocks.iter().enumerate() {
out = out.replace(&format!("\x00CODEBLOCK{idx}\x00"), block);
}
out
}
// ── Conversation history persistence ───────────────────────────────── // ── Conversation history persistence ─────────────────────────────────
/// Per-sender conversation history, keyed by phone number. /// Per-sender conversation history, keyed by phone number.
@@ -814,6 +881,9 @@ pub struct WhatsAppWebhookContext {
pub history_size: usize, pub history_size: usize,
/// Tracks the 24-hour messaging window per user phone number. /// Tracks the 24-hour messaging window per user phone number.
pub window_tracker: Arc<MessagingWindowTracker>, pub window_tracker: Arc<MessagingWindowTracker>,
/// Phone numbers allowed to send messages to the bot.
/// When empty, all numbers are allowed (backwards compatible).
pub allowed_phones: Vec<String>,
} }
/// GET /webhook/whatsapp — webhook verification. /// GET /webhook/whatsapp — webhook verification.
@@ -910,6 +980,14 @@ pub async fn webhook_receive(
async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) { async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command}; use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
// Allowlist check: when configured, silently ignore unauthorized senders.
if !ctx.allowed_phones.is_empty()
&& !ctx.allowed_phones.iter().any(|p| p == sender)
{
slog!("[whatsapp] Ignoring message from unauthorized sender: {sender}");
return;
}
// Record this inbound message to keep the 24-hour window open. // Record this inbound message to keep the 24-hour window open.
ctx.window_tracker.record_message(sender); ctx.window_tracker.record_message(sender);
@@ -924,7 +1002,8 @@ async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, mes
if let Some(response) = try_handle_command(&dispatch, message) { if let Some(response) = try_handle_command(&dispatch, message) {
slog!("[whatsapp] Sending command response to {sender}"); slog!("[whatsapp] Sending command response to {sender}");
if let Err(e) = ctx.transport.send_message(sender, &response, "").await { let formatted = markdown_to_whatsapp(&response);
if let Err(e) = ctx.transport.send_message(sender, &formatted, "").await {
slog!("[whatsapp] Failed to send reply to {sender}: {e}"); slog!("[whatsapp] Failed to send reply to {sender}: {e}");
} }
return; return;
@@ -1020,8 +1099,9 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
let post_sender = sender.to_string(); let post_sender = sender.to_string();
let post_task = tokio::spawn(async move { let post_task = tokio::spawn(async move {
while let Some(chunk) = msg_rx.recv().await { while let Some(chunk) = msg_rx.recv().await {
// Split into WhatsApp-sized chunks. // Convert Markdown to WhatsApp formatting, then split into sized chunks.
for part in chunk_for_whatsapp(&chunk) { let formatted = markdown_to_whatsapp(&chunk);
for part in chunk_for_whatsapp(&formatted) {
let _ = post_transport.send_message(&post_sender, &part, "").await; let _ = post_transport.send_message(&post_sender, &part, "").await;
} }
} }
@@ -1510,6 +1590,81 @@ mod tests {
assert_eq!(chunks, vec![""]); assert_eq!(chunks, vec![""]);
} }
// ── markdown_to_whatsapp tests ────────────────────────────────────────
#[test]
fn md_to_wa_converts_headers_to_bold() {
assert_eq!(markdown_to_whatsapp("# Title"), "*Title*");
assert_eq!(markdown_to_whatsapp("## Subtitle"), "*Subtitle*");
assert_eq!(markdown_to_whatsapp("### Section"), "*Section*");
assert_eq!(markdown_to_whatsapp("###### Deep"), "*Deep*");
}
#[test]
fn md_to_wa_converts_bold() {
assert_eq!(markdown_to_whatsapp("**bold text**"), "*bold text*");
}
#[test]
fn md_to_wa_converts_bold_italic() {
assert_eq!(markdown_to_whatsapp("***emphasis***"), "*_emphasis_*");
}
#[test]
fn md_to_wa_converts_strikethrough() {
assert_eq!(markdown_to_whatsapp("~~removed~~"), "~removed~");
}
#[test]
fn md_to_wa_converts_links() {
assert_eq!(
markdown_to_whatsapp("[click here](https://example.com)"),
"click here (https://example.com)"
);
}
#[test]
fn md_to_wa_removes_horizontal_rules() {
assert_eq!(markdown_to_whatsapp("above\n---\nbelow"), "above\n\nbelow");
}
#[test]
fn md_to_wa_preserves_inline_code() {
assert_eq!(markdown_to_whatsapp("use `foo()` here"), "use `foo()` here");
}
#[test]
fn md_to_wa_preserves_code_blocks() {
let input = "before\n```rust\nfn main() {\n println!(\"**not bold**\");\n}\n```\nafter";
let output = markdown_to_whatsapp(input);
// Code block content must NOT be converted.
assert!(output.contains("\"**not bold**\""));
// But surrounding text is still converted.
assert!(output.contains("before"));
assert!(output.contains("after"));
}
#[test]
fn md_to_wa_mixed_message() {
let input = "### Philosophy\n- **Stories** define the change\n- ~~old~~ is gone\n- See [docs](https://example.com)";
let output = markdown_to_whatsapp(input);
assert!(output.starts_with("*Philosophy*"));
assert!(output.contains("*Stories*"));
assert!(output.contains("~old~"));
assert!(output.contains("docs (https://example.com)"));
}
#[test]
fn md_to_wa_passthrough_plain_text() {
let plain = "Hello, how are you?";
assert_eq!(markdown_to_whatsapp(plain), plain);
}
#[test]
fn md_to_wa_empty_string() {
assert_eq!(markdown_to_whatsapp(""), "");
}
// ── WhatsApp history persistence tests ────────────────────────────── // ── WhatsApp history persistence tests ──────────────────────────────
#[test] #[test]
@@ -1676,6 +1831,106 @@ mod tests {
let _msgs = extract_twilio_text_messages(body); let _msgs = extract_twilio_text_messages(body);
} }
// ── Allowlist tests ───────────────────────────────────────────────────
/// Build a minimal WhatsAppWebhookContext for allowlist tests.
fn make_ctx_with_allowlist(
allowed_phones: Vec<String>,
) -> Arc<WhatsAppWebhookContext> {
use crate::agents::AgentPool;
use crate::io::watcher::WatcherEvent;
struct NullTransport;
#[async_trait::async_trait]
impl crate::chat::ChatTransport for NullTransport {
async fn send_message(
&self,
_room: &str,
_plain: &str,
_html: &str,
) -> Result<crate::chat::MessageId, String> {
Ok(String::new())
}
async fn edit_message(
&self,
_room: &str,
_id: &str,
_plain: &str,
_html: &str,
) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _room: &str, _typing: bool) -> Result<(), String> {
Ok(())
}
}
let tmp = tempfile::tempdir().unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);
let agents = Arc::new(AgentPool::new(3999, tx));
let tracker = Arc::new(MessagingWindowTracker::new());
Arc::new(WhatsAppWebhookContext {
verify_token: "tok".to_string(),
provider: "meta".to_string(),
transport: Arc::new(NullTransport),
project_root: tmp.path().to_path_buf(),
agents,
bot_name: "Bot".to_string(),
bot_user_id: "whatsapp-bot".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(Default::default())),
history: Arc::new(tokio::sync::Mutex::new(Default::default())),
history_size: 20,
window_tracker: tracker,
allowed_phones,
})
}
#[tokio::test]
async fn allowlist_blocks_unauthorized_sender() {
let allowed = vec!["+15551111111".to_string()];
let ctx = make_ctx_with_allowlist(allowed);
let unauthorized = "+15559999999";
handle_incoming_message(&ctx, unauthorized, "hello").await;
// window_tracker is only updated AFTER the allowlist check, so an
// unauthorized sender must leave the tracker untouched.
assert!(
!ctx.window_tracker.is_within_window(unauthorized),
"unauthorized sender should not have updated the window tracker"
);
}
#[tokio::test]
async fn allowlist_empty_allows_all_senders() {
// Empty allowlist = open (backwards compatible).
let ctx = make_ctx_with_allowlist(vec![]);
let sender = "+15551234567";
handle_incoming_message(&ctx, sender, "hello").await;
// window_tracker.record_message is called right after the allowlist
// check passes, so the sender should be recorded.
assert!(
ctx.window_tracker.is_within_window(sender),
"sender should be recorded when allowlist is empty"
);
}
#[tokio::test]
async fn allowlist_allows_listed_sender() {
let sender = "+15551111111";
let ctx = make_ctx_with_allowlist(vec![sender.to_string()]);
handle_incoming_message(&ctx, sender, "hello").await;
assert!(
ctx.window_tracker.is_within_window(sender),
"listed sender should be recorded in the window tracker"
);
}
#[test] #[test]
fn load_whatsapp_history_returns_empty_when_file_missing() { fn load_whatsapp_history_returns_empty_when_file_missing() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();

View File

@@ -30,6 +30,12 @@ pub struct ProjectConfig {
/// Default: 2. Set to 0 to disable retry limits. /// Default: 2. Set to 0 to disable retry limits.
#[serde(default = "default_max_retries")] #[serde(default = "default_max_retries")]
pub max_retries: u32, pub max_retries: u32,
/// Optional base branch name (e.g. "main", "master", "develop").
/// When set, overrides the auto-detection logic (`detect_base_branch`) for all
/// worktree creation, merge operations, and agent prompt `{{base_branch}}` substitution.
/// When not set, the system falls back to `detect_base_branch` (reads current HEAD).
#[serde(default)]
pub base_branch: Option<String>,
} }
/// Configuration for the filesystem watcher's sweep behaviour. /// Configuration for the filesystem watcher's sweep behaviour.
@@ -164,6 +170,8 @@ struct LegacyProjectConfig {
max_coders: Option<usize>, max_coders: Option<usize>,
#[serde(default = "default_max_retries")] #[serde(default = "default_max_retries")]
max_retries: u32, max_retries: u32,
#[serde(default)]
base_branch: Option<String>,
} }
impl Default for ProjectConfig { impl Default for ProjectConfig {
@@ -190,6 +198,7 @@ impl Default for ProjectConfig {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: default_max_retries(), max_retries: default_max_retries(),
base_branch: None,
} }
} }
} }
@@ -235,6 +244,7 @@ impl ProjectConfig {
default_coder_model: legacy.default_coder_model, default_coder_model: legacy.default_coder_model,
max_coders: legacy.max_coders, max_coders: legacy.max_coders,
max_retries: legacy.max_retries, max_retries: legacy.max_retries,
base_branch: legacy.base_branch,
}; };
validate_agents(&config.agent)?; validate_agents(&config.agent)?;
return Ok(config); return Ok(config);
@@ -259,6 +269,7 @@ impl ProjectConfig {
default_coder_model: legacy.default_coder_model, default_coder_model: legacy.default_coder_model,
max_coders: legacy.max_coders, max_coders: legacy.max_coders,
max_retries: legacy.max_retries, max_retries: legacy.max_retries,
base_branch: legacy.base_branch,
}; };
validate_agents(&config.agent)?; validate_agents(&config.agent)?;
Ok(config) Ok(config)
@@ -271,6 +282,7 @@ impl ProjectConfig {
default_coder_model: legacy.default_coder_model, default_coder_model: legacy.default_coder_model,
max_coders: legacy.max_coders, max_coders: legacy.max_coders,
max_retries: legacy.max_retries, max_retries: legacy.max_retries,
base_branch: legacy.base_branch,
}) })
} }
} }
@@ -312,7 +324,9 @@ impl ProjectConfig {
.ok_or_else(|| "No agents configured".to_string())?, .ok_or_else(|| "No agents configured".to_string())?,
}; };
let bb = base_branch.unwrap_or("master"); let bb = base_branch
.or(self.base_branch.as_deref())
.unwrap_or("master");
let aname = agent.name.as_str(); let aname = agent.name.as_str();
let render = |s: &str| { let render = |s: &str| {
s.replace("{{worktree_path}}", worktree_path) s.replace("{{worktree_path}}", worktree_path)
@@ -858,6 +872,80 @@ runtime = "openai"
assert!(err.contains("unknown runtime 'openai'")); assert!(err.contains("unknown runtime 'openai'"));
} }
// ── base_branch config ──────────────────────────────────────────────────
#[test]
fn base_branch_defaults_to_none() {
let toml_str = r#"
[[agent]]
name = "coder"
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
assert_eq!(config.base_branch, None);
}
#[test]
fn base_branch_parsed_when_set() {
let toml_str = r#"
base_branch = "main"
[[agent]]
name = "coder"
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
assert_eq!(config.base_branch, Some("main".to_string()));
}
#[test]
fn render_agent_args_uses_config_base_branch_when_caller_passes_none() {
let toml_str = r#"
base_branch = "develop"
[[agent]]
name = "coder"
prompt = "git difftool {{base_branch}}...HEAD"
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
let (_, _, prompt) = config
.render_agent_args("/tmp/wt", "42_foo", None, None)
.unwrap();
assert!(
prompt.contains("develop"),
"Expected 'develop' in prompt, got: {prompt}"
);
}
#[test]
fn render_agent_args_caller_base_branch_takes_precedence_over_config() {
let toml_str = r#"
base_branch = "develop"
[[agent]]
name = "coder"
prompt = "git difftool {{base_branch}}...HEAD"
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
let (_, _, prompt) = config
.render_agent_args("/tmp/wt", "42_foo", None, Some("feature-x"))
.unwrap();
assert!(
prompt.contains("feature-x"),
"Caller-supplied base_branch should win, got: {prompt}"
);
}
#[test]
fn project_toml_has_base_branch_master() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let project_root = manifest_dir.parent().unwrap();
let config = ProjectConfig::load(project_root).unwrap();
assert_eq!(
config.base_branch,
Some("master".to_string()),
"project.toml must have base_branch = \"master\""
);
}
#[test] #[test]
fn project_toml_has_three_sonnet_coders() { fn project_toml_has_three_sonnet_coders() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));

View File

@@ -306,6 +306,7 @@ async fn main() -> Result<(), std::io::Error> {
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)), history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
history_size: cfg.history_size, history_size: cfg.history_size,
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()), window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
allowed_phones: cfg.whatsapp_allowed_phones.clone(),
}) })
}); });

View File

@@ -69,7 +69,10 @@ pub async fn create_worktree(
) -> Result<WorktreeInfo, String> { ) -> Result<WorktreeInfo, String> {
let wt_path = worktree_path(project_root, story_id); let wt_path = worktree_path(project_root, story_id);
let branch = branch_name(story_id); let branch = branch_name(story_id);
let base_branch = detect_base_branch(project_root); let base_branch = config
.base_branch
.clone()
.unwrap_or_else(|| detect_base_branch(project_root));
let root = project_root.to_path_buf(); let root = project_root.to_path_buf();
// Already exists — reuse (ensure sparse checkout is configured) // Already exists — reuse (ensure sparse checkout is configured)
@@ -199,7 +202,10 @@ pub async fn remove_worktree_by_story_id(
return Err(format!("Worktree not found for story: {story_id}")); return Err(format!("Worktree not found for story: {story_id}"));
} }
let branch = branch_name(story_id); let branch = branch_name(story_id);
let base_branch = detect_base_branch(project_root); let base_branch = config
.base_branch
.clone()
.unwrap_or_else(|| detect_base_branch(project_root));
let info = WorktreeInfo { let info = WorktreeInfo {
path, path,
branch, branch,
@@ -519,6 +525,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
// Should complete without panic // Should complete without panic
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -540,6 +547,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
// Should complete without panic // Should complete without panic
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -561,6 +569,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
// Setup command failures are non-fatal — should not panic or propagate // Setup command failures are non-fatal — should not panic or propagate
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -582,6 +591,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
// Teardown failures are best-effort — should not propagate // Teardown failures are best-effort — should not propagate
assert!(run_teardown_commands(tmp.path(), &config).await.is_ok()); assert!(run_teardown_commands(tmp.path(), &config).await.is_ok());
@@ -602,6 +612,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
let info = create_worktree(&project_root, "42_fresh_test", &config, 3001) let info = create_worktree(&project_root, "42_fresh_test", &config, 3001)
.await .await
@@ -629,6 +640,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
// First creation // First creation
let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001) let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001)
@@ -697,6 +709,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await; let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await;
@@ -723,6 +736,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
create_worktree(&project_root, "88_remove_by_id", &config, 3001) create_worktree(&project_root, "88_remove_by_id", &config, 3001)
.await .await
@@ -796,6 +810,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
// Even though setup commands fail, create_worktree must succeed // Even though setup commands fail, create_worktree must succeed
// so the agent can start and fix the problem itself. // so the agent can start and fix the problem itself.
@@ -825,6 +840,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
// First creation — no setup commands, should succeed // First creation — no setup commands, should succeed
create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001) create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001)
@@ -844,6 +860,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
// Second call — worktree exists, setup commands fail, must still succeed // Second call — worktree exists, setup commands fail, must still succeed
let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await; let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await;
@@ -869,6 +886,7 @@ mod tests {
default_coder_model: None, default_coder_model: None,
max_coders: None, max_coders: None,
max_retries: 2, max_retries: 2,
base_branch: None,
}; };
let info = create_worktree(&project_root, "77_remove_async", &config, 3001) let info = create_worktree(&project_root, "77_remove_async", &config, 3001)
.await .await

70
website/index.html Normal file
View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Storkit — Story-Driven Development for AI Agents</title>
<meta name="description" content="Storkit is an autonomous development pipeline that turns user stories into tested, shipped code using AI agents.">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>stor<span>kit</span></h1>
<p class="tagline">Story-driven development, powered by AI agents.</p>
</header>
<section>
<h2>What is Storkit?</h2>
<p>Storkit is an autonomous development pipeline that turns user stories into tested, shipped code. You describe what you want. AI agents handle the rest &mdash; implementation, testing, code review, and merge.</p>
<p>Talk to it from your IDE, a chat room, or WhatsApp. Stories flow through a structured pipeline: backlog, current, QA, merge, done.</p>
</section>
<section>
<h2>How it works</h2>
<ol class="steps">
<li>You write a user story with acceptance criteria.</li>
<li>An AI agent picks it up, creates a feature branch, and implements the code.</li>
<li>A QA agent runs tests, linters, and quality gates automatically.</li>
<li>A merge agent resolves conflicts and lands it on your main branch.</li>
<li>You review the result. Accept or send it back.</li>
</ol>
</section>
<section>
<h2>Features</h2>
<div class="features">
<div class="feature">
<h3>Story-Driven Workflow</h3>
<p>Stories define the change. Tests define the truth. Code defines the reality. No code ships without acceptance criteria.</p>
</div>
<div class="feature">
<h3>Multi-Agent Pipeline</h3>
<p>Coder, QA, and merge agents work in parallel across isolated git worktrees. Configure agent count, models, and budgets.</p>
</div>
<div class="feature">
<h3>Chat Anywhere</h3>
<p>Control the pipeline from Matrix, WhatsApp, Slack, or the built-in web UI. Create stories, start agents, check status.</p>
</div>
<div class="feature">
<h3>Full Autonomy, Your Oversight</h3>
<p>Agents implement, test, and merge independently. You approve what ships. Every story is traceable from request to release.</p>
</div>
</div>
</section>
<section>
<h2>Get in touch</h2>
<p>Storkit is built by <a href="https://crashlabs.io">Crashlabs</a>. Interested in early access or have questions? Reach out at <a href="mailto:hello@storkit.dev">hello@storkit.dev</a>.</p>
</section>
<footer>
<p>&copy; 2026 Libby Labs Ltd. All rights reserved. &middot; <a href="privacy.html">Privacy Policy</a></p>
</footer>
</div>
</body>
</html>

68
website/privacy.html Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy Policy — Storkit</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1><a href="/" style="color: inherit;">stor<span>kit</span></a></h1>
<p class="tagline">Privacy Policy</p>
</header>
<section>
<p><strong>Last updated:</strong> 25 March 2026</p>
<h2>Who we are</h2>
<p>Storkit is operated by Libby Labs Ltd ("we", "us", "our"), trading as Crashlabs. Our contact email is <a href="mailto:hello@storkit.dev">hello@storkit.dev</a>.</p>
</section>
<section>
<h2>What we collect</h2>
<p>When you interact with Storkit via WhatsApp, Slack, Matrix, or the web interface, we may collect:</p>
<p><strong>Messaging data:</strong> Your phone number or chat identifier and the content of messages you send to the bot. This is used solely to process your requests and maintain conversation context.</p>
<p><strong>Usage data:</strong> Basic server logs including timestamps and request metadata. We do not use analytics trackers on this website.</p>
</section>
<section>
<h2>How we use your data</h2>
<p>We use your data only to provide and improve the Storkit service. Specifically:</p>
<p>- To process commands and respond to your messages.<br>
- To maintain conversation history within active sessions.<br>
- To diagnose and fix technical issues.</p>
<p>We do not sell, rent, or share your personal data with third parties for marketing purposes.</p>
</section>
<section>
<h2>Third-party services</h2>
<p>Messages sent via WhatsApp are processed through Meta's WhatsApp Business API or Twilio's messaging platform, subject to their respective privacy policies. Messages sent via Slack or Matrix pass through those platforms' infrastructure.</p>
</section>
<section>
<h2>Data retention</h2>
<p>Conversation history is stored locally on our servers and retained only for the duration needed to maintain session context. We do not retain message data indefinitely.</p>
</section>
<section>
<h2>Your rights</h2>
<p>You may request access to, correction of, or deletion of your personal data at any time by contacting us at <a href="mailto:hello@storkit.dev">hello@storkit.dev</a>.</p>
</section>
<section>
<h2>Changes to this policy</h2>
<p>We may update this policy from time to time. Changes will be posted on this page with an updated date.</p>
</section>
<footer>
<p>&copy; 2026 Libby Labs Ltd. All rights reserved. &middot; <a href="/">Home</a></p>
</footer>
</div>
</body>
</html>

140
website/style.css Normal file
View File

@@ -0,0 +1,140 @@
:root {
--bg: #0a0a0a;
--fg: #e8e8e8;
--muted: #888;
--accent: #4f9cf7;
--surface: #141414;
--border: #222;
--max-w: 720px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.container {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 24px;
}
/* ── Header ── */
header {
padding: 48px 0 32px;
border-bottom: 1px solid var(--border);
}
header h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.03em;
}
header h1 span { color: var(--accent); }
header p.tagline {
color: var(--muted);
font-size: 1.1rem;
margin-top: 8px;
}
/* ── Sections ── */
section {
padding: 48px 0;
border-bottom: 1px solid var(--border);
}
section:last-of-type { border-bottom: none; }
section h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 16px;
letter-spacing: -0.02em;
}
section p { color: var(--muted); margin-bottom: 12px; }
/* ── Feature grid ── */
.features {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 16px;
}
.feature {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.feature h3 {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 6px;
}
.feature p {
font-size: 0.875rem;
color: var(--muted);
margin: 0;
}
/* ── How it works ── */
.steps {
list-style: none;
counter-reset: step;
margin-top: 16px;
}
.steps li {
counter-increment: step;
padding: 12px 0;
padding-left: 36px;
position: relative;
color: var(--muted);
font-size: 0.95rem;
}
.steps li::before {
content: counter(step);
position: absolute;
left: 0;
top: 12px;
width: 24px;
height: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
/* ── Footer ── */
footer {
padding: 32px 0;
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
/* ── Responsive ── */
@media (max-width: 540px) {
.features { grid-template-columns: 1fr; }
header h1 { font-size: 1.5rem; }
}