refactor: split top-5 largest files into mod.rs + tests.rs
Five files in server/src/ exceeded 1500 lines, with 50–75% of the line
count being inline `#[cfg(test)] mod tests { ... }` blocks. Agents
working on these files have to navigate huge buffers via Read calls,
costing turn budget that could go toward actual work.
Pattern: convert `foo.rs` to `foo/mod.rs` + `foo/tests.rs`.
Rust resolves `mod foo;` to either form, so no parent-module changes
needed.
Before / after (production-code lines, what an agent has to navigate
when editing the module):
crdt_sync.rs: 3672 → 1003 (mod.rs) + 2667 (tests.rs)
crdt_state.rs: 2122 → 1263 (mod.rs) + 854 (tests.rs)
io/fs/scaffold.rs: 2045 → 702 (mod.rs) + 1342 (tests.rs)
http/mcp/mod.rs: 1882 → 1410 (mod.rs) + 472 (tests.rs)
http/mcp/story_tools.rs: 1864 → 725 (mod.rs) + 1137 (tests.rs)
Side change: scaffold/mod.rs's include_str! paths got an extra `../`
because the file moved one directory deeper.
Tests: full `cargo test` suite passes (2635 passed, 0 failed).
Formatting: cargo fmt --check clean.
Motivation: today's agent thrashing on 644 / 650 / 652 was partly due to
cumulative-counting (now fixed by 650) but also genuinely due to file
size — sonnet's 50-turn budget barely covers reading these files plus
making the change. Smaller production-code files mean more turn budget
left for the actual work.
Committed straight to master because this is an enabling refactor for
agent autonomy work; running it through the normal pipeline would
require an agent that has to navigate the very files it's about to
split, defeating the purpose.
This commit is contained in:
@@ -0,0 +1,702 @@
|
||||
//! Project scaffolding — creates the `.huskies/` directory structure and default files.
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const STORY_KIT_README: &str = include_str!("../../../../../.huskies/README.md");
|
||||
|
||||
const BOT_TOML_MATRIX_EXAMPLE: &str =
|
||||
include_str!("../../../../../.huskies/bot.toml.matrix.example");
|
||||
const BOT_TOML_WHATSAPP_META_EXAMPLE: &str =
|
||||
include_str!("../../../../../.huskies/bot.toml.whatsapp-meta.example");
|
||||
const BOT_TOML_WHATSAPP_TWILIO_EXAMPLE: &str =
|
||||
include_str!("../../../../../.huskies/bot.toml.whatsapp-twilio.example");
|
||||
const BOT_TOML_SLACK_EXAMPLE: &str = include_str!("../../../../../.huskies/bot.toml.slack.example");
|
||||
|
||||
const STORY_KIT_CONTEXT: &str = "<!-- huskies:scaffold-template -->\n\
|
||||
# Project Context\n\
|
||||
\n\
|
||||
## High-Level Goal\n\
|
||||
\n\
|
||||
TODO: Describe the high-level goal of this project.\n\
|
||||
\n\
|
||||
## Core Features\n\
|
||||
\n\
|
||||
TODO: List the core features of this project.\n\
|
||||
\n\
|
||||
## Domain Definition\n\
|
||||
\n\
|
||||
TODO: Define the key domain concepts and entities.\n\
|
||||
\n\
|
||||
## Glossary\n\
|
||||
\n\
|
||||
TODO: Define abbreviations and technical terms.\n";
|
||||
|
||||
const STORY_KIT_STACK: &str = "<!-- huskies:scaffold-template -->\n\
|
||||
# Tech Stack & Constraints\n\
|
||||
\n\
|
||||
## Core Stack\n\
|
||||
\n\
|
||||
TODO: Describe the language, frameworks, and runtimes.\n\
|
||||
\n\
|
||||
## Coding Standards\n\
|
||||
\n\
|
||||
TODO: Describe code style, linting rules, and error handling conventions.\n\
|
||||
\n\
|
||||
## Quality Gates\n\
|
||||
\n\
|
||||
TODO: List the commands that must pass before merging (e.g., cargo test, npm run build).\n\
|
||||
\n\
|
||||
## Libraries\n\
|
||||
\n\
|
||||
TODO: List approved libraries and their purpose.\n";
|
||||
|
||||
const STORY_KIT_SCRIPT_TEST: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's test commands here.\n# Story Kit agents invoke this script as the canonical test runner.\n# Exit 0 on success, non-zero on failure.\necho \"No tests configured\"\n";
|
||||
|
||||
const STORY_KIT_CLAUDE_MD: &str = "<!-- huskies:scaffold-template -->\n\
|
||||
Never chain shell commands with `&&`, `||`, or `;` in a single Bash call. \
|
||||
The permission system validates the entire command string, and chained commands \
|
||||
won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \
|
||||
parallel calls work fine.\n\
|
||||
\n\
|
||||
Read .huskies/README.md to see our dev process.\n\
|
||||
\n\
|
||||
IMPORTANT: On your first conversation, call `wizard_status` to check if \
|
||||
project setup is complete. If not, read .huskies/README.md for the full \
|
||||
setup wizard instructions and guide the user through it conversationally.\n";
|
||||
|
||||
const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cargo build:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(git *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(mkdir *)",
|
||||
"Bash(mv *)",
|
||||
"Bash(rm *)",
|
||||
"Bash(touch *)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(pwd *)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find *)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(cat *)",
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"mcp__huskies__*"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"huskies"
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
const DEFAULT_PROJECT_SETTINGS_TOML: &str = r#"# Project-wide default QA mode: "server", "agent", or "human".
|
||||
# Per-story `qa` front matter overrides this setting.
|
||||
default_qa = "server"
|
||||
|
||||
# Maximum number of retries per story per pipeline stage before marking as blocked.
|
||||
# Set to 0 to disable retry limits.
|
||||
max_retries = 2
|
||||
|
||||
# Default model for coder-stage agents (e.g. "sonnet", "opus").
|
||||
# When set, only coder agents whose model matches this value are considered for
|
||||
# auto-assignment, so opus agents are only used when explicitly requested via
|
||||
# story front matter `agent:` field.
|
||||
# default_coder_model = "sonnet"
|
||||
|
||||
# Maximum number of concurrent coder-stage agents.
|
||||
# Stories wait in 2_current/ until a slot frees up.
|
||||
# max_coders = 3
|
||||
|
||||
# Override the base branch for worktree creation and merge operations.
|
||||
# When not set, the system auto-detects the base branch from the current HEAD.
|
||||
# base_branch = "main"
|
||||
|
||||
# Suppress soft rate-limit warning notifications in chat.
|
||||
# Hard blocks and story-blocked notifications are always sent.
|
||||
# rate_limit_notifications = true
|
||||
|
||||
# IANA timezone for timer scheduling (e.g. "Europe/London", "America/New_York").
|
||||
# Timer HH:MM inputs are interpreted in this timezone.
|
||||
# timezone = "America/New_York"
|
||||
"#;
|
||||
|
||||
const DEFAULT_AGENTS_TOML: &str = r#"[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md to understand the dev process. Follow the workflow through implementation and verification. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits.\n\nIf `script/test` still contains the generic 'No tests configured' stub, update it to run the project's actual test suite before starting implementation."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Commit all your work before finishing. Do not accept stories, move them to archived, or merge to master."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work: runs quality gates, generates testing plans, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = "You are the QA agent for story {{story_id}}. Review the coder's work and produce a structured QA report. Run quality gates (linting, tests), attempt a build, and generate a manual testing plan. Do NOT modify any code."
|
||||
system_prompt = "You are a QA agent. Your job is read-only: review code quality, run tests, and produce a structured QA report. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "mergemaster"
|
||||
stage = "mergemaster"
|
||||
role = "Merges completed work into master, runs quality gates, and archives stories."
|
||||
model = "sonnet"
|
||||
max_turns = 30
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are the mergemaster agent for story {{story_id}}. Call merge_agent_work(story_id='{{story_id}}') to start the merge pipeline. Then poll get_merge_status(story_id='{{story_id}}') every 15 seconds until the status is 'completed' or 'failed'. Report the final result. If the merge fails, call report_merge_failure."
|
||||
system_prompt = "You are the mergemaster agent. Call merge_agent_work to start the merge, then poll get_merge_status every 15 seconds until done. Never manually move story files. Call report_merge_failure when merges fail."
|
||||
"#;
|
||||
|
||||
/// Detect the tech stack from the project root and return TOML `[[component]]` entries.
|
||||
///
|
||||
/// Inspects well-known marker files at the project root to identify which
|
||||
/// tech stacks are present, then emits one `[[component]]` entry per detected
|
||||
/// stack with sensible default `setup` commands. If no markers are found, a
|
||||
/// single fallback `app` component with an empty `setup` list is returned so
|
||||
/// that the pipeline never breaks on an unknown stack.
|
||||
pub fn detect_components_toml(root: &Path) -> String {
|
||||
let mut sections = Vec::new();
|
||||
|
||||
if root.join("Cargo.toml").exists() {
|
||||
sections.push(
|
||||
"[[component]]\nname = \"server\"\npath = \".\"\nsetup = [\"cargo check\"]\n"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if root.join("package.json").exists() {
|
||||
let setup_cmd = if root.join("pnpm-lock.yaml").exists() {
|
||||
"pnpm install"
|
||||
} else {
|
||||
"npm install"
|
||||
};
|
||||
sections.push(format!(
|
||||
"[[component]]\nname = \"frontend\"\npath = \".\"\nsetup = [\"{setup_cmd}\"]\n"
|
||||
));
|
||||
}
|
||||
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
sections.push(
|
||||
"[[component]]\nname = \"python\"\npath = \".\"\nsetup = [\"pip install -r requirements.txt\"]\n"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if root.join("go.mod").exists() {
|
||||
sections.push(
|
||||
"[[component]]\nname = \"go\"\npath = \".\"\nsetup = [\"go build ./...\"]\n"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if root.join("Gemfile").exists() {
|
||||
sections.push(
|
||||
"[[component]]\nname = \"ruby\"\npath = \".\"\nsetup = [\"bundle install\"]\n"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if sections.is_empty() {
|
||||
// No tech stack markers detected — emit a single generic component
|
||||
// with an empty setup list. The ONBOARDING_PROMPT instructs the chat
|
||||
// agent to inspect the project and replace this with real definitions.
|
||||
sections.push("[[component]]\nname = \"app\"\npath = \".\"\nsetup = []\n".to_string());
|
||||
}
|
||||
|
||||
sections.join("\n")
|
||||
}
|
||||
|
||||
/// Detect the appropriate Node.js test command for a directory containing `package.json`.
|
||||
///
|
||||
/// Reads the `package.json` content to identify known test runners (vitest, jest).
|
||||
/// Falls back to `npm test` or `pnpm test` based on which lock file is present.
|
||||
fn detect_node_test_cmd(pkg_dir: &Path) -> String {
|
||||
let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists();
|
||||
let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default();
|
||||
|
||||
if content.contains("\"vitest\"") {
|
||||
let pm = if has_pnpm { "pnpm" } else { "npx" };
|
||||
return format!("{} vitest run", pm);
|
||||
}
|
||||
if content.contains("\"jest\"") {
|
||||
let pm = if has_pnpm { "pnpm" } else { "npx" };
|
||||
return format!("{} jest", pm);
|
||||
}
|
||||
|
||||
if has_pnpm {
|
||||
"pnpm test".to_string()
|
||||
} else {
|
||||
"npm test".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the appropriate Node.js build command for a directory containing `package.json`.
|
||||
fn detect_node_build_cmd(pkg_dir: &Path) -> String {
|
||||
if pkg_dir.join("pnpm-lock.yaml").exists() {
|
||||
"pnpm run build".to_string()
|
||||
} else {
|
||||
"npm run build".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the appropriate Node.js lint command for a directory containing `package.json`.
|
||||
///
|
||||
/// Reads the `package.json` content to identify eslint. Falls back to
|
||||
/// `npm run lint` or `pnpm run lint` based on which lock file is present.
|
||||
fn detect_node_lint_cmd(pkg_dir: &Path) -> String {
|
||||
let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists();
|
||||
let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default();
|
||||
if content.contains("\"eslint\"") {
|
||||
let pm = if has_pnpm { "pnpm" } else { "npx" };
|
||||
return format!("{pm} eslint .");
|
||||
}
|
||||
if has_pnpm {
|
||||
"pnpm run lint".to_string()
|
||||
} else {
|
||||
"npm run lint".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate `script/build` content for a new project at `root`.
|
||||
///
|
||||
/// Inspects well-known marker files to identify which tech stacks are present
|
||||
/// and emits the appropriate build commands. Multi-stack projects get combined
|
||||
/// commands run sequentially. Falls back to a generic stub when no markers
|
||||
/// are found so the scaffold is always valid.
|
||||
///
|
||||
/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`),
|
||||
/// the build command is detected from the presence of `pnpm-lock.yaml`.
|
||||
pub fn detect_script_build(root: &Path) -> String {
|
||||
let mut commands: Vec<String> = Vec::new();
|
||||
|
||||
if root.join("Cargo.toml").exists() {
|
||||
commands.push("cargo build --release".to_string());
|
||||
}
|
||||
|
||||
if root.join("package.json").exists() {
|
||||
commands.push(detect_node_build_cmd(root));
|
||||
}
|
||||
|
||||
// Detect frontend in known subdirectories (e.g. frontend/, client/)
|
||||
for subdir in &["frontend", "client"] {
|
||||
let sub_path = root.join(subdir);
|
||||
if sub_path.join("package.json").exists() {
|
||||
let cmd = detect_node_build_cmd(&sub_path);
|
||||
commands.push(format!("(cd {} && {})", subdir, cmd));
|
||||
}
|
||||
}
|
||||
|
||||
if root.join("pyproject.toml").exists() {
|
||||
commands.push("python -m build".to_string());
|
||||
}
|
||||
|
||||
if root.join("go.mod").exists() {
|
||||
commands.push("go build ./...".to_string());
|
||||
}
|
||||
|
||||
if commands.is_empty() {
|
||||
return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's build commands here.\necho \"No build configured\"\n".to_string();
|
||||
}
|
||||
|
||||
let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string();
|
||||
for cmd in commands {
|
||||
script.push_str(&cmd);
|
||||
script.push('\n');
|
||||
}
|
||||
script
|
||||
}
|
||||
|
||||
/// Generate `script/lint` content for a new project at `root`.
|
||||
///
|
||||
/// Inspects well-known marker files to identify which linters are present
|
||||
/// and emits the appropriate lint commands. Multi-stack projects get combined
|
||||
/// commands run sequentially. Falls back to a generic stub when no markers
|
||||
/// are found so the scaffold is always valid.
|
||||
///
|
||||
/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`),
|
||||
/// the lint command is detected from the `package.json` (eslint, npm, pnpm).
|
||||
pub fn detect_script_lint(root: &Path) -> String {
|
||||
let mut commands: Vec<String> = Vec::new();
|
||||
|
||||
if root.join("Cargo.toml").exists() {
|
||||
commands.push("cargo fmt --all --check".to_string());
|
||||
commands.push("cargo clippy -- -D warnings".to_string());
|
||||
}
|
||||
|
||||
if root.join("package.json").exists() {
|
||||
commands.push(detect_node_lint_cmd(root));
|
||||
}
|
||||
|
||||
// Detect frontend in known subdirectories (e.g. frontend/, client/)
|
||||
for subdir in &["frontend", "client"] {
|
||||
let sub_path = root.join(subdir);
|
||||
if sub_path.join("package.json").exists() {
|
||||
let cmd = detect_node_lint_cmd(&sub_path);
|
||||
commands.push(format!("(cd {} && {})", subdir, cmd));
|
||||
}
|
||||
}
|
||||
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
let mut content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap_or_default();
|
||||
content
|
||||
.push_str(&std::fs::read_to_string(root.join("requirements.txt")).unwrap_or_default());
|
||||
if content.contains("ruff") {
|
||||
commands.push("ruff check .".to_string());
|
||||
} else {
|
||||
commands.push("flake8 .".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if root.join("go.mod").exists() {
|
||||
commands.push("go vet ./...".to_string());
|
||||
}
|
||||
|
||||
if commands.is_empty() {
|
||||
return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's lint commands here.\necho \"No linters configured\"\n".to_string();
|
||||
}
|
||||
|
||||
let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string();
|
||||
for cmd in commands {
|
||||
script.push_str(&cmd);
|
||||
script.push('\n');
|
||||
}
|
||||
script
|
||||
}
|
||||
|
||||
/// Generate `script/test` content for a new project at `root`.
|
||||
///
|
||||
/// Inspects well-known marker files to identify which tech stacks are present
|
||||
/// and emits the appropriate test commands. Multi-stack projects get combined
|
||||
/// commands run sequentially. Falls back to the generic stub when no markers
|
||||
/// are found so the scaffold is always valid.
|
||||
///
|
||||
/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`),
|
||||
/// the test runner is detected from the `package.json` (vitest, jest, npm, pnpm).
|
||||
pub fn detect_script_test(root: &Path) -> String {
|
||||
let mut commands: Vec<String> = Vec::new();
|
||||
|
||||
if root.join("Cargo.toml").exists() {
|
||||
commands.push("cargo test".to_string());
|
||||
}
|
||||
|
||||
if root.join("package.json").exists() {
|
||||
if root.join("pnpm-lock.yaml").exists() {
|
||||
commands.push("pnpm test".to_string());
|
||||
} else {
|
||||
commands.push("npm test".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Detect frontend in known subdirectories (e.g. frontend/, client/)
|
||||
for subdir in &["frontend", "client"] {
|
||||
let sub_path = root.join(subdir);
|
||||
if sub_path.join("package.json").exists() {
|
||||
let cmd = detect_node_test_cmd(&sub_path);
|
||||
commands.push(format!("(cd {} && {})", subdir, cmd));
|
||||
}
|
||||
}
|
||||
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
commands.push("pytest".to_string());
|
||||
}
|
||||
|
||||
if root.join("go.mod").exists() {
|
||||
commands.push("go test ./...".to_string());
|
||||
}
|
||||
|
||||
if commands.is_empty() {
|
||||
return STORY_KIT_SCRIPT_TEST.to_string();
|
||||
}
|
||||
|
||||
let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string();
|
||||
for cmd in commands {
|
||||
script.push_str(&cmd);
|
||||
script.push('\n');
|
||||
}
|
||||
script
|
||||
}
|
||||
|
||||
/// Generate a `project.toml` for a new project at `root`.
|
||||
///
|
||||
/// Detects the tech stack via [`detect_components_toml`] and combines the
|
||||
/// resulting `[[component]]` entries with the default project settings.
|
||||
/// Agent definitions are written to `agents.toml` separately.
|
||||
fn generate_project_toml(root: &Path) -> String {
|
||||
let components = detect_components_toml(root);
|
||||
format!("{components}\n{DEFAULT_PROJECT_SETTINGS_TOML}")
|
||||
}
|
||||
|
||||
fn write_file_if_missing(path: &Path, content: &str) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `content` to `path` if missing, then ensure the file is executable.
|
||||
fn write_script_if_missing(path: &Path, content: &str) -> Result<(), String> {
|
||||
write_file_if_missing(path, content)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(path)
|
||||
.map_err(|e| format!("Failed to read permissions for {}: {}", path.display(), e))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(path, perms)
|
||||
.map_err(|e| format!("Failed to set permissions on {}: {}", path.display(), e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write (or idempotently update) `.huskies/.gitignore` with Story Kit–specific
|
||||
/// ignore patterns for files that live inside the `.huskies/` directory.
|
||||
/// Patterns are relative to `.huskies/` as git resolves `.gitignore` files
|
||||
/// relative to the directory that contains them.
|
||||
fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
||||
// Entries that belong inside .huskies/.gitignore (relative to .huskies/).
|
||||
let entries = [
|
||||
"bot.toml",
|
||||
"matrix_store/",
|
||||
"matrix_device_id",
|
||||
"matrix_history.json",
|
||||
"timers.json",
|
||||
"worktrees/",
|
||||
"merge_workspace/",
|
||||
"coverage/",
|
||||
"work/2_current/",
|
||||
"work/3_qa/",
|
||||
"work/4_merge/",
|
||||
"logs/",
|
||||
"token_usage.jsonl",
|
||||
"wizard_state.json",
|
||||
"store.json",
|
||||
"pipeline.db",
|
||||
"*.db",
|
||||
];
|
||||
|
||||
let gitignore_path = root.join(".huskies").join(".gitignore");
|
||||
let existing = if gitignore_path.exists() {
|
||||
fs::read_to_string(&gitignore_path)
|
||||
.map_err(|e| format!("Failed to read .huskies/.gitignore: {}", e))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let missing: Vec<&str> = entries
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|e| !existing.lines().any(|l| l.trim() == *e))
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut new_content = existing;
|
||||
if !new_content.is_empty() && !new_content.ends_with('\n') {
|
||||
new_content.push('\n');
|
||||
}
|
||||
for entry in missing {
|
||||
new_content.push_str(entry);
|
||||
new_content.push('\n');
|
||||
}
|
||||
|
||||
fs::write(&gitignore_path, new_content)
|
||||
.map_err(|e| format!("Failed to write .huskies/.gitignore: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append root-level Story Kit entries to the project `.gitignore`.
|
||||
/// Only `.huskies_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 `.huskies/.gitignore`.
|
||||
/// `store.json` is excluded via `.huskies/.gitignore` since it now lives
|
||||
/// inside the `.huskies/` directory.
|
||||
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
|
||||
let entries = [".huskies_port", ".mcp.json"];
|
||||
|
||||
let gitignore_path = root.join(".gitignore");
|
||||
let existing = if gitignore_path.exists() {
|
||||
fs::read_to_string(&gitignore_path)
|
||||
.map_err(|e| format!("Failed to read .gitignore: {}", e))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let missing: Vec<&str> = entries
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|e| !existing.lines().any(|l| l.trim() == *e))
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut new_content = existing;
|
||||
if !new_content.is_empty() && !new_content.ends_with('\n') {
|
||||
new_content.push('\n');
|
||||
}
|
||||
for entry in missing {
|
||||
new_content.push_str(entry);
|
||||
new_content.push('\n');
|
||||
}
|
||||
|
||||
fs::write(&gitignore_path, new_content)
|
||||
.map_err(|e| format!("Failed to write .gitignore: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
|
||||
let story_kit_root = root.join(".huskies");
|
||||
let specs_root = story_kit_root.join("specs");
|
||||
let tech_root = specs_root.join("tech");
|
||||
let functional_root = specs_root.join("functional");
|
||||
let script_root = root.join("script");
|
||||
|
||||
// Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone
|
||||
let work_stages = [
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
for stage in &work_stages {
|
||||
let dir = story_kit_root.join("work").join(stage);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create work/{}: {}", stage, e))?;
|
||||
write_file_if_missing(&dir.join(".gitkeep"), "")?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&tech_root).map_err(|e| format!("Failed to create specs/tech: {}", e))?;
|
||||
fs::create_dir_all(&functional_root)
|
||||
.map_err(|e| format!("Failed to create specs/functional: {}", e))?;
|
||||
fs::create_dir_all(&script_root)
|
||||
.map_err(|e| format!("Failed to create script/ directory: {}", e))?;
|
||||
|
||||
write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?;
|
||||
let project_toml_content = generate_project_toml(root);
|
||||
write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?;
|
||||
write_file_if_missing(&story_kit_root.join("agents.toml"), DEFAULT_AGENTS_TOML)?;
|
||||
write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?;
|
||||
write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?;
|
||||
let script_test_content = detect_script_test(root);
|
||||
write_script_if_missing(&script_root.join("test"), &script_test_content)?;
|
||||
let script_build_content = detect_script_build(root);
|
||||
write_script_if_missing(&script_root.join("build"), &script_build_content)?;
|
||||
let script_lint_content = detect_script_lint(root);
|
||||
write_script_if_missing(&script_root.join("lint"), &script_lint_content)?;
|
||||
write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?;
|
||||
|
||||
// Write per-transport bot.toml example files so users can see all options.
|
||||
write_file_if_missing(
|
||||
&story_kit_root.join("bot.toml.matrix.example"),
|
||||
BOT_TOML_MATRIX_EXAMPLE,
|
||||
)?;
|
||||
write_file_if_missing(
|
||||
&story_kit_root.join("bot.toml.whatsapp-meta.example"),
|
||||
BOT_TOML_WHATSAPP_META_EXAMPLE,
|
||||
)?;
|
||||
write_file_if_missing(
|
||||
&story_kit_root.join("bot.toml.whatsapp-twilio.example"),
|
||||
BOT_TOML_WHATSAPP_TWILIO_EXAMPLE,
|
||||
)?;
|
||||
write_file_if_missing(
|
||||
&story_kit_root.join("bot.toml.slack.example"),
|
||||
BOT_TOML_SLACK_EXAMPLE,
|
||||
)?;
|
||||
|
||||
// Write .mcp.json at the project root so agents can find the MCP server.
|
||||
// Only written when missing — never overwrites an existing file, because
|
||||
// the port is environment-specific and must not clobber a running instance.
|
||||
let mcp_content = format!(
|
||||
"{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n"
|
||||
);
|
||||
write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?;
|
||||
|
||||
// Create .claude/settings.json with sensible permission defaults so that
|
||||
// Claude Code (both agents and web UI chat) can operate without constant
|
||||
// permission prompts.
|
||||
let claude_dir = root.join(".claude");
|
||||
fs::create_dir_all(&claude_dir)
|
||||
.map_err(|e| format!("Failed to create .claude/ directory: {}", e))?;
|
||||
write_file_if_missing(&claude_dir.join("settings.json"), STORY_KIT_CLAUDE_SETTINGS)?;
|
||||
|
||||
write_story_kit_gitignore(root)?;
|
||||
append_root_gitignore_entries(root)?;
|
||||
|
||||
// Run `git init` if the directory is not already a git repo, then make an initial commit
|
||||
if !root.join(".git").exists() {
|
||||
let init_status = std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to run git init: {}", e))?;
|
||||
if !init_status.success() {
|
||||
return Err("git init failed".to_string());
|
||||
}
|
||||
|
||||
let add_output = std::process::Command::new("git")
|
||||
.args([
|
||||
"add",
|
||||
".huskies",
|
||||
"script",
|
||||
".gitignore",
|
||||
"CLAUDE.md",
|
||||
".claude",
|
||||
])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git add: {}", e))?;
|
||||
if !add_output.status.success() {
|
||||
return Err(format!(
|
||||
"git add failed: {}",
|
||||
String::from_utf8_lossy(&add_output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let commit_output = std::process::Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.email=huskies@localhost",
|
||||
"-c",
|
||||
"user.name=Story Kit",
|
||||
"commit",
|
||||
"-m",
|
||||
"Initial Story Kit scaffold",
|
||||
])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git commit: {}", e))?;
|
||||
if !commit_output.status.success() {
|
||||
return Err(format!(
|
||||
"git commit failed: {}",
|
||||
String::from_utf8_lossy(&commit_output.stderr)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user