huskies: rename project from storkit to huskies

Rename all references from storkit to huskies across the codebase:
- .storkit/ directory → .huskies/
- Binary name, Cargo package name, Docker image references
- Server code, frontend code, config files, scripts
- Fix script/test to build frontend before cargo clippy/test
  so merge worktrees have frontend/dist available for RustEmbed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-04-03 16:12:52 +01:00
parent a7035b6ba7
commit 2d8ccb3eb6
572 changed files with 1340 additions and 1220 deletions
+57 -57
View File
@@ -1,10 +1,10 @@
//! Filesystem watcher for `.storkit/work/` and `.storkit/project.toml`.
//! Filesystem watcher for `.huskies/work/` and `.huskies/project.toml`.
//!
//! Watches the work pipeline directories for file changes, infers the lifecycle
//! stage from the target directory name, auto-commits with a deterministic message,
//! and broadcasts a [`WatcherEvent`] to all connected WebSocket clients.
//!
//! Also watches `.storkit/project.toml` for modifications and broadcasts
//! Also watches `.huskies/project.toml` for modifications and broadcasts
//! [`WatcherEvent::ConfigChanged`] so the frontend can reload the agent roster
//! without a server restart.
//!
@@ -48,7 +48,7 @@ pub enum WatcherEvent {
/// `None` for creations, deletions, or synthetic events.
from_stage: Option<String>,
},
/// `.storkit/project.toml` was modified at the project root (not inside a worktree).
/// `.huskies/project.toml` was modified at the project root (not inside a worktree).
ConfigChanged,
/// An agent's state changed (started, stopped, completed, etc.).
/// Triggers a pipeline state refresh so the frontend can update agent
@@ -90,8 +90,8 @@ pub enum WatcherEvent {
},
}
/// Return `true` if `path` is the root-level `.storkit/project.toml`, i.e.
/// `{git_root}/.storkit/project.toml`.
/// Return `true` if `path` is the root-level `.huskies/project.toml`, i.e.
/// `{git_root}/.huskies/project.toml`.
///
/// Returns `false` for paths inside worktree directories (paths containing
/// a `worktrees` component).
@@ -100,19 +100,19 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
if path.components().any(|c| c.as_os_str() == "worktrees") {
return false;
}
let expected = git_root.join(".storkit").join("project.toml");
let expected = git_root.join(".huskies").join("project.toml");
path == expected
}
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
let (action, prefix) = match stage {
"1_backlog" => ("create", format!("storkit: create {item_id}")),
"2_current" => ("start", format!("storkit: start {item_id}")),
"3_qa" => ("qa", format!("storkit: queue {item_id} for QA")),
"4_merge" => ("merge", format!("storkit: queue {item_id} for merge")),
"5_done" => ("done", format!("storkit: done {item_id}")),
"6_archived" => ("accept", format!("storkit: accept {item_id}")),
"1_backlog" => ("create", format!("huskies: create {item_id}")),
"2_current" => ("start", format!("huskies: start {item_id}")),
"3_qa" => ("qa", format!("huskies: queue {item_id} for QA")),
"4_merge" => ("merge", format!("huskies: queue {item_id} for merge")),
"5_done" => ("done", format!("huskies: done {item_id}")),
"6_archived" => ("accept", format!("huskies: accept {item_id}")),
_ => return None,
};
Some((action, prefix))
@@ -121,7 +121,7 @@ fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)>
/// Return the pipeline stage name for a path if it is a `.md` file living
/// directly inside one of the known work subdirectories, otherwise `None`.
///
/// Explicitly returns `None` for any path under `.storkit/worktrees/` so
/// Explicitly returns `None` for any path under `.huskies/worktrees/` so
/// that code changes made by agents in their isolated worktrees are never
/// auto-committed to master by the watcher.
fn stage_for_path(path: &Path) -> Option<String> {
@@ -146,11 +146,11 @@ fn stage_for_path(path: &Path) -> Option<String> {
/// Stage all changes in the work directory and commit with the given message.
///
/// Uses `git add -A .storkit/work/` to catch both additions and deletions in
/// Uses `git add -A .huskies/work/` to catch both additions and deletions in
/// a single commit. Returns `Ok(true)` if a commit was made, `Ok(false)` if
/// there was nothing to commit, and `Err` for unexpected failures.
fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, String> {
let work_rel = PathBuf::from(".storkit").join("work");
let work_rel = PathBuf::from(".huskies").join("work");
let add_out = std::process::Command::new("git")
.args(["add", "-A"])
@@ -199,7 +199,7 @@ fn should_commit_stage(stage: &str) -> bool {
///
/// Only files that still exist on disk are used to derive the commit message
/// (they represent the destination of a move or a new file). Deletions are
/// captured by `git add -A .storkit/work/` automatically.
/// captured by `git add -A .huskies/work/` automatically.
///
/// Only terminal stages (`1_backlog` and `6_archived`) trigger git commits.
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
@@ -240,7 +240,7 @@ fn flush_pending(
(
"remove",
item.to_string(),
format!("storkit: remove {item}"),
format!("huskies: remove {item}"),
)
};
@@ -392,9 +392,9 @@ fn sweep_done_to_archived(work_dir: &Path, git_root: &Path, done_retention: Dura
/// Start the filesystem watcher on a dedicated OS thread.
///
/// `work_dir` — absolute path to `.storkit/work/` (watched recursively).
/// `work_dir` — absolute path to `.huskies/work/` (watched recursively).
/// `git_root` — project root (passed to `git` commands as cwd, and used to
/// derive the config file path `.storkit/project.toml`).
/// derive the config file path `.huskies/project.toml`).
/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver.
/// `watcher_config` — initial sweep configuration loaded from `project.toml`.
pub fn start_watcher(
@@ -421,8 +421,8 @@ pub fn start_watcher(
return;
}
// Also watch .storkit/project.toml for hot-reload of agent config.
let config_file = git_root.join(".storkit").join("project.toml");
// Also watch .huskies/project.toml for hot-reload of agent config.
let config_file = git_root.join(".huskies").join("project.toml");
if config_file.exists()
&& let Err(e) = watcher.watch(&config_file, RecursiveMode::NonRecursive)
{
@@ -575,9 +575,9 @@ mod tests {
.expect("git initial commit");
}
/// Create the `.storkit/work/{stage}/` dir tree inside `root`.
/// Create the `.huskies/work/{stage}/` dir tree inside `root`.
fn make_stage_dir(root: &std::path::Path, stage: &str) -> PathBuf {
let dir = root.join(".storkit").join("work").join(stage);
let dir = root.join(".huskies").join("work").join(stage);
fs::create_dir_all(&dir).expect("create stage dir");
dir
}
@@ -591,7 +591,7 @@ mod tests {
let stage_dir = make_stage_dir(tmp.path(), "2_current");
fs::write(stage_dir.join("42_story_foo.md"), "---\nname: test\n---\n").unwrap();
let result = git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo");
let result = git_add_work_and_commit(tmp.path(), "huskies: start 42_story_foo");
assert_eq!(
result,
Ok(true),
@@ -607,10 +607,10 @@ mod tests {
fs::write(stage_dir.join("42_story_foo.md"), "---\nname: test\n---\n").unwrap();
// First commit — should succeed.
git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo").unwrap();
git_add_work_and_commit(tmp.path(), "huskies: start 42_story_foo").unwrap();
// Second call with no changes — should return Ok(false).
let result = git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo");
let result = git_add_work_and_commit(tmp.path(), "huskies: start 42_story_foo");
assert_eq!(
result,
Ok(false),
@@ -646,7 +646,7 @@ mod tests {
assert_eq!(stage, "1_backlog");
assert_eq!(item_id, "42_story_foo");
assert_eq!(action, "create");
assert_eq!(commit_msg, "storkit: create 42_story_foo");
assert_eq!(commit_msg, "huskies: create 42_story_foo");
}
other => panic!("unexpected event: {other:?}"),
}
@@ -659,7 +659,7 @@ mod tests {
.expect("git log");
let log_msg = String::from_utf8_lossy(&log.stdout);
assert!(
log_msg.contains("storkit: create 42_story_foo"),
log_msg.contains("huskies: create 42_story_foo"),
"terminal stage should produce a git commit"
);
}
@@ -691,7 +691,7 @@ mod tests {
assert_eq!(stage, "2_current");
assert_eq!(item_id, "42_story_foo");
assert_eq!(action, "start");
assert_eq!(commit_msg, "storkit: start 42_story_foo");
assert_eq!(commit_msg, "huskies: start 42_story_foo");
}
other => panic!("unexpected event: {other:?}"),
}
@@ -704,7 +704,7 @@ mod tests {
.expect("git log");
let log_msg = String::from_utf8_lossy(&log.stdout);
assert!(
!log_msg.contains("storkit:"),
!log_msg.contains("huskies:"),
"intermediate stage should NOT produce a git commit"
);
}
@@ -712,11 +712,11 @@ mod tests {
#[test]
fn flush_pending_broadcasts_for_all_pipeline_stages() {
let stages = [
("1_backlog", "create", "storkit: create 10_story_x"),
("3_qa", "qa", "storkit: queue 10_story_x for QA"),
("4_merge", "merge", "storkit: queue 10_story_x for merge"),
("5_done", "done", "storkit: done 10_story_x"),
("6_archived", "accept", "storkit: accept 10_story_x"),
("1_backlog", "create", "huskies: create 10_story_x"),
("3_qa", "qa", "huskies: queue 10_story_x for QA"),
("4_merge", "merge", "huskies: queue 10_story_x for merge"),
("5_done", "done", "huskies: done 10_story_x"),
("6_archived", "accept", "huskies: accept 10_story_x"),
];
for (stage, expected_action, expected_msg) in stages {
@@ -754,7 +754,7 @@ mod tests {
make_stage_dir(tmp.path(), "2_current");
let deleted_path = tmp
.path()
.join(".storkit")
.join(".huskies")
.join("work")
.join("2_current")
.join("42_story_foo.md");
@@ -785,7 +785,7 @@ mod tests {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
// File sits in an unrecognised directory.
let unknown_dir = tmp.path().join(".storkit").join("work").join("9_unknown");
let unknown_dir = tmp.path().join(".huskies").join("work").join("9_unknown");
fs::create_dir_all(&unknown_dir).unwrap();
let path = unknown_dir.join("42_story_foo.md");
fs::write(&path, "---\nname: test\n---\n").unwrap();
@@ -961,7 +961,7 @@ mod tests {
make_stage_dir(tmp.path(), "3_qa");
let qa_path = tmp
.path()
.join(".storkit")
.join(".huskies")
.join("work")
.join("3_qa")
.join("42_story_foo.md");
@@ -1015,7 +1015,7 @@ mod tests {
#[test]
fn stage_for_path_recognises_pipeline_dirs() {
let base = PathBuf::from("/proj/.storkit/work");
let base = PathBuf::from("/proj/.huskies/work");
assert_eq!(
stage_for_path(&base.join("2_current/42_story_foo.md")),
Some("2_current".to_string())
@@ -1037,7 +1037,7 @@ mod tests {
#[test]
fn stage_for_path_ignores_worktree_paths() {
let worktrees = PathBuf::from("/proj/.storkit/worktrees");
let worktrees = PathBuf::from("/proj/.huskies/worktrees");
// Code changes inside a worktree must be ignored.
assert_eq!(
@@ -1048,7 +1048,7 @@ mod tests {
// Even if a worktree happens to contain a path component that looks
// like a pipeline stage, it must still be ignored.
assert_eq!(
stage_for_path(&worktrees.join("42_story_foo/.storkit/work/2_current/42_story_foo.md")),
stage_for_path(&worktrees.join("42_story_foo/.huskies/work/2_current/42_story_foo.md")),
None,
);
@@ -1056,7 +1056,7 @@ mod tests {
// segment (not an exact component) must NOT be filtered out.
assert_eq!(
stage_for_path(&PathBuf::from(
"/proj/.storkit/work/2_current/not_worktrees_story.md"
"/proj/.huskies/work/2_current/not_worktrees_story.md"
)),
Some("2_current".to_string()),
);
@@ -1080,15 +1080,15 @@ mod tests {
fn stage_metadata_returns_correct_actions() {
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
assert_eq!(action, "start");
assert_eq!(msg, "storkit: start 42_story_foo");
assert_eq!(msg, "huskies: start 42_story_foo");
let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap();
assert_eq!(action, "done");
assert_eq!(msg, "storkit: done 42_story_foo");
assert_eq!(msg, "huskies: done 42_story_foo");
let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap();
assert_eq!(action, "accept");
assert_eq!(msg, "storkit: accept 42_story_foo");
assert_eq!(msg, "huskies: accept 42_story_foo");
assert!(stage_metadata("unknown", "id").is_none());
}
@@ -1096,7 +1096,7 @@ mod tests {
#[test]
fn is_config_file_identifies_root_project_toml() {
let git_root = PathBuf::from("/proj");
let config = git_root.join(".storkit").join("project.toml");
let config = git_root.join(".huskies").join("project.toml");
assert!(is_config_file(&config, &git_root));
}
@@ -1105,7 +1105,7 @@ mod tests {
let git_root = PathBuf::from("/proj");
// project.toml inside a worktree must NOT be treated as the root config.
let worktree_config =
PathBuf::from("/proj/.storkit/worktrees/42_story_foo/.storkit/project.toml");
PathBuf::from("/proj/.huskies/worktrees/42_story_foo/.huskies/project.toml");
assert!(!is_config_file(&worktree_config, &git_root));
}
@@ -1114,11 +1114,11 @@ mod tests {
let git_root = PathBuf::from("/proj");
// Random files must not match.
assert!(!is_config_file(
&PathBuf::from("/proj/.storkit/work/2_current/42_story_foo.md"),
&PathBuf::from("/proj/.huskies/work/2_current/42_story_foo.md"),
&git_root
));
assert!(!is_config_file(
&PathBuf::from("/proj/.storkit/README.md"),
&PathBuf::from("/proj/.huskies/README.md"),
&git_root
));
}
@@ -1126,7 +1126,7 @@ mod tests {
#[test]
fn is_config_file_rejects_wrong_root() {
let git_root = PathBuf::from("/proj");
let other_root_config = PathBuf::from("/other/.storkit/project.toml");
let other_root_config = PathBuf::from("/other/.huskies/project.toml");
assert!(!is_config_file(&other_root_config, &git_root));
}
@@ -1135,7 +1135,7 @@ mod tests {
#[test]
fn sweep_moves_old_items_to_archived() {
let tmp = TempDir::new().unwrap();
let work_dir = tmp.path().join(".storkit").join("work");
let work_dir = tmp.path().join(".huskies").join("work");
let done_dir = work_dir.join("5_done");
let archived_dir = work_dir.join("6_archived");
fs::create_dir_all(&done_dir).unwrap();
@@ -1165,7 +1165,7 @@ mod tests {
#[test]
fn sweep_keeps_recent_items_in_done() {
let tmp = TempDir::new().unwrap();
let work_dir = tmp.path().join(".storkit").join("work");
let work_dir = tmp.path().join(".huskies").join("work");
let done_dir = work_dir.join("5_done");
fs::create_dir_all(&done_dir).unwrap();
@@ -1182,7 +1182,7 @@ mod tests {
#[test]
fn sweep_respects_custom_retention() {
let tmp = TempDir::new().unwrap();
let work_dir = tmp.path().join(".storkit").join("work");
let work_dir = tmp.path().join(".huskies").join("work");
let done_dir = work_dir.join("5_done");
let archived_dir = work_dir.join("6_archived");
fs::create_dir_all(&done_dir).unwrap();
@@ -1211,7 +1211,7 @@ mod tests {
#[test]
fn sweep_custom_retention_keeps_younger_items() {
let tmp = TempDir::new().unwrap();
let work_dir = tmp.path().join(".storkit").join("work");
let work_dir = tmp.path().join(".huskies").join("work");
let done_dir = work_dir.join("5_done");
fs::create_dir_all(&done_dir).unwrap();
@@ -1255,7 +1255,7 @@ mod tests {
let git_root = tmp.path().to_path_buf();
init_git_repo(&git_root);
let work_dir = git_root.join(".storkit").join("work");
let work_dir = git_root.join(".huskies").join("work");
let done_dir = work_dir.join("5_done");
fs::create_dir_all(&done_dir).unwrap();
@@ -1298,7 +1298,7 @@ mod tests {
let git_root = tmp.path().to_path_buf();
init_git_repo(&git_root);
let work_dir = git_root.join(".storkit").join("work");
let work_dir = git_root.join(".huskies").join("work");
let archived_dir = work_dir.join("6_archived");
fs::create_dir_all(&archived_dir).unwrap();
@@ -1339,7 +1339,7 @@ mod tests {
let git_root = tmp.path().to_path_buf();
init_git_repo(&git_root);
let work_dir = git_root.join(".storkit").join("work");
let work_dir = git_root.join(".huskies").join("work");
let done_dir = work_dir.join("5_done");
fs::create_dir_all(&done_dir).unwrap();