diff --git a/crates/tui/src/context_report.rs b/crates/tui/src/context_report.rs
index ca408d8a5a..5b34d7a0fa 100644
--- a/crates/tui/src/context_report.rs
+++ b/crates/tui/src/context_report.rs
@@ -334,9 +334,14 @@ fn base_source_entries(model: &str, workspace: &Path, skills_dir: Option<&Path>)
.source_path
.as_ref()
.map_or_else(|| "project".to_string(), |p| p.display().to_string());
- let block = format!(
+ let mut block = format!(
"\n{content}\n"
);
+ // Include rules in the report when present
+ if let Some(rules) = &project_context.rules_block {
+ block.push('\n');
+ block.push_str(rules);
+ }
builder.push(SourceEntry::text(
SourceKind::ProjectContext,
"Project instructions",
@@ -349,6 +354,17 @@ fn base_source_entries(model: &str, workspace: &Path, skills_dir: Option<&Path>)
CountingConfidence::High,
Some(5),
));
+ } else if let Some(rules) = &project_context.rules_block {
+ // Rules exist without main instructions
+ builder.push(SourceEntry::text(
+ SourceKind::ProjectContext,
+ "Project rules",
+ None::,
+ ActivationReason::FilePresent,
+ rules,
+ CountingConfidence::High,
+ Some(5),
+ ));
}
if project_context.constitution_block.is_none() && project_context.instructions.is_none() {
diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs
index 912807d344..4eaf005fef 100644
--- a/crates/tui/src/project_context.rs
+++ b/crates/tui/src/project_context.rs
@@ -39,6 +39,13 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[
".deepseek/instructions.md",
];
+/// Rules directories auto-discovered at workspace level, in priority order.
+/// `.codewhale/rules/` is CodeWhale-native; `.claude/rules/` is Claude compatibility.
+/// All `.md` files in these directories are loaded as project rules in filename order.
+/// Security model: same trust class as AGENTS.md — workspace-contained content only,
+/// no absolute-path escape. Does not require #417 project-config relaxation.
+const RULES_DIRS: &[&str] = &[".codewhale/rules", ".claude/rules"];
+
/// File name of the deprecated CodeWhale-native instructions file.
const DEPRECATED_WHALE_FILENAME: &str = "WHALE.md";
@@ -72,6 +79,17 @@ const GLOBAL_INSTRUCTIONS_LEGACY_PATH: &[&str] = &[".deepseek", "instructions.md
/// Maximum size for project context files (to prevent loading huge files)
const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB
+
+/// Maximum number of rule files loaded per rules directory.
+/// Prevents a project from silently injecting hundreds of rule files.
+const MAX_RULES_FILES: usize = 50;
+
+/// Maximum total bytes across the assembled rules_block.
+/// 50 files × 100 KB per file could reach ~5 MB; this caps the
+/// cumulative injected content so a large rules directory can't
+/// dominate the context window. Exceeded bytes are truncated with
+/// an explicit marker.
+const MAX_RULES_BLOCK_BYTES: usize = 500 * 1024; // 500 KB
const PACK_README_MAX_CHARS: usize = 4_000;
const PACK_MAX_ENTRIES: usize = 220;
const PACK_MAX_SOURCE_FILES: usize = 60;
@@ -133,6 +151,10 @@ enum ProjectContextError {
pub struct ProjectContext {
/// The loaded instructions content
pub instructions: Option,
+ /// Auto-discovered rules from `.codewhale/rules/` / `.claude/rules/`.
+ /// Kept separate from `instructions` so rules alone don't block
+ /// parent-directory AGENTS.md discovery via `has_instructions()`.
+ pub rules_block: Option,
/// Path to the loaded file (for display)
pub source_path: Option,
/// Any warnings during loading
@@ -155,6 +177,7 @@ impl ProjectContext {
pub fn empty(project_root: PathBuf) -> Self {
Self {
instructions: None,
+ rules_block: None,
source_path: None,
warnings: Vec::new(),
constitution_block: None,
@@ -181,18 +204,36 @@ impl ProjectContext {
.as_ref()
.map_or_else(|| "project".to_string(), |p| p.display().to_string());
- format!(
+ let mut block = format!(
"\n{content}\n"
- )
+ );
+ // Append rules after instructions, inside the same logical block.
+ // Rules are kept separate from `instructions` so they don't block
+ // parent-directory AGENTS.md discovery via `has_instructions()`.
+ if let Some(rules) = &self.rules_block {
+ block.push('\n');
+ block.push_str(rules);
+ }
+ block
});
match (self.constitution_block.as_ref(), instructions_block) {
(Some(constitution), Some(instructions)) => {
Some(format!("{constitution}\n\n{instructions}"))
}
- (Some(constitution), None) => Some(constitution.clone()),
+ (Some(constitution), None) => {
+ // Constitution present but no main instructions — still emit rules if any
+ if let Some(rules) = &self.rules_block {
+ Some(format!("{constitution}\n\n{rules}"))
+ } else {
+ Some(constitution.clone())
+ }
+ }
(None, Some(instructions)) => Some(instructions),
- (None, None) => None,
+ (None, None) => {
+ // No main instructions, but rules may exist on their own
+ self.rules_block.clone()
+ }
}
}
}
@@ -841,6 +882,44 @@ pub fn load_project_context(workspace: &Path) -> ProjectContext {
ctx.warnings
.extend(ignored_project_whale_warnings(workspace));
+ // Load rules from auto-discovered directories (.codewhale/rules/, .claude/rules/)
+ // Each rule file is wrapped in a block and appended after
+ // the main instructions content. Security model: same as AGENTS.md —
+ // workspace-contained content only, no absolute-path escape.
+ let mut rules_content = String::new();
+ for rules_dir in RULES_DIRS {
+ let rules = load_rules_from_dir(workspace, rules_dir);
+ for (path, content) in rules {
+ if !rules_content.is_empty() {
+ rules_content.push('\n');
+ }
+ rules_content.push_str(&format!(
+ "\n{}\n",
+ path.display(),
+ content.trim()
+ ));
+ }
+ }
+
+ if !rules_content.is_empty() {
+ // Cap total rules bytes so a large rules dir can't dominate the context window
+ if rules_content.len() > MAX_RULES_BLOCK_BYTES {
+ let mut end = MAX_RULES_BLOCK_BYTES;
+ while !rules_content.is_char_boundary(end) {
+ end -= 1;
+ }
+ rules_content.truncate(end);
+ rules_content.push_str("\n\n[…rules block truncated at 500 KB…]");
+ tracing::warn!(
+ target: "project_context",
+ total_bytes = rules_content.len(),
+ cap = MAX_RULES_BLOCK_BYTES,
+ "Truncating rules block to total byte budget"
+ );
+ }
+ ctx.rules_block = Some(rules_content);
+ }
+
// Check for trust file
ctx.is_trusted = check_trust_status(workspace);
@@ -995,6 +1074,27 @@ pub(crate) fn project_context_cache_candidate_paths(
paths.push(workspace.join(".deepseek").join("trust.json"));
paths.extend(crate::config::workspace_trust_config_candidate_paths());
+ // Include auto-discovered rules directory files so cache invalidates
+ // when rules change (not just when AGENTS.md changes).
+ for rules_dir in RULES_DIRS {
+ let dir_path = workspace.join(rules_dir);
+ // Skip symlinked rules directories (same guard as load_rules_from_dir)
+ if fs::symlink_metadata(&dir_path)
+ .map(|m| m.file_type().is_symlink())
+ .unwrap_or(false)
+ {
+ continue;
+ }
+ if let Ok(entries) = std::fs::read_dir(&dir_path) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|ext| ext == "md") {
+ paths.push(path);
+ }
+ }
+ }
+ }
+
paths
}
@@ -1222,6 +1322,85 @@ fn context_candidate_exists(path: &Path) -> bool {
})
}
+/// Scan a rules directory for `.md` files and load them in filename order.
+/// Missing or unreadable directories return an empty vec (no error).
+/// Each file is verified through `load_context_file` (size check, symlink safety).
+fn load_rules_from_dir(workspace: &Path, rules_dir_name: &str) -> Vec<(PathBuf, String)> {
+ let rules_dir = workspace.join(rules_dir_name);
+ let mut entries: Vec<(PathBuf, String)> = Vec::new();
+
+ // Refuse a symlinked rules directory: the real .md files behind it
+ // would pass per-file is_symlink checks and be read from outside the
+ // workspace subtree — same escape class as #417.
+ if fs::symlink_metadata(&rules_dir)
+ .map(|m| m.file_type().is_symlink())
+ .unwrap_or(false)
+ {
+ tracing::warn!(
+ target: "project_context",
+ dir = %rules_dir.display(),
+ "Refusing symlinked rules directory"
+ );
+ return entries;
+ }
+
+ let dir_iter = match fs::read_dir(&rules_dir) {
+ Ok(iter) => iter,
+ Err(_) => return entries,
+ };
+
+ let mut file_paths: Vec = Vec::new();
+ for entry in dir_iter.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|ext| ext == "md") && context_candidate_exists(&path) {
+ file_paths.push(path);
+ }
+ }
+
+ // Sort by filename for deterministic order
+ file_paths.sort_by(|a, b| {
+ a.file_name()
+ .unwrap_or_default()
+ .cmp(b.file_name().unwrap_or_default())
+ });
+
+ // Enforce per-directory cap
+ let total = file_paths.len();
+ if total > MAX_RULES_FILES {
+ tracing::warn!(
+ target: "project_context",
+ dir = %rules_dir.display(),
+ total,
+ cap = MAX_RULES_FILES,
+ "Truncating rules directory to cap"
+ );
+ file_paths.truncate(MAX_RULES_FILES);
+ }
+
+ for path in file_paths {
+ match load_context_file(&path) {
+ Ok(content) => {
+ tracing::info!(
+ "Loaded project rule from {} ({} bytes)",
+ path.display(),
+ content.len()
+ );
+ entries.push((path, content));
+ }
+ Err(error) => {
+ tracing::warn!(
+ target: "project_context",
+ ?error,
+ ?path,
+ "Skipping unreadable rules file"
+ );
+ }
+ }
+ }
+
+ entries
+}
+
#[cfg(unix)]
fn open_context_file(path: &Path) -> Result {
use std::os::unix::fs::OpenOptionsExt;
@@ -2444,4 +2623,267 @@ mod tests {
.contains("Project Context (Auto-generated, ephemeral)")
);
}
+
+ // ── Rules directory auto-discovery tests ──
+
+ #[test]
+ fn rules_from_codewhale_dir_are_loaded_as_project_context() {
+ let tmp = tempdir().expect("tempdir");
+ let rules_dir = tmp.path().join(".codewhale/rules");
+ fs::create_dir_all(&rules_dir).expect("mkdir rules");
+ fs::write(
+ rules_dir.join("security.md"),
+ "# Security\nNo hardcoded secrets.",
+ )
+ .expect("write");
+
+ let ctx = load_project_context(tmp.path());
+
+ let rules = ctx.rules_block.as_ref().expect("rules_block should be set");
+ assert!(
+ rules.contains("Security"),
+ "expected rules content, got: {rules}"
+ );
+ assert!(
+ rules.contains(" wrapper, got: {rules}"
+ );
+ }
+
+ #[test]
+ fn rules_are_loaded_in_filename_order() {
+ let tmp = tempdir().expect("tempdir");
+ let rules_dir = tmp.path().join(".codewhale/rules");
+ fs::create_dir_all(&rules_dir).expect("mkdir rules");
+ fs::write(rules_dir.join("zzz.md"), "last").expect("write");
+ fs::write(rules_dir.join("aaa.md"), "first").expect("write");
+ fs::write(rules_dir.join("mmm.md"), "middle").expect("write");
+
+ let ctx = load_project_context(tmp.path());
+ let rules = ctx.rules_block.as_ref().unwrap();
+
+ let pos_aaa = rules.find("first").unwrap();
+ let pos_mmm = rules.find("middle").unwrap();
+ let pos_zzz = rules.find("last").unwrap();
+ assert!(pos_aaa < pos_mmm, "aaa should come before mmm");
+ assert!(pos_mmm < pos_zzz, "mmm should come before zzz");
+ }
+
+ #[test]
+ fn rules_from_claude_dir_are_compat_loaded() {
+ let tmp = tempdir().expect("tempdir");
+ let rules_dir = tmp.path().join(".claude/rules");
+ fs::create_dir_all(&rules_dir).expect("mkdir rules");
+ fs::write(rules_dir.join("style.md"), "Use tabs").expect("write");
+
+ let ctx = load_project_context(tmp.path());
+
+ let rules = ctx.rules_block.as_ref().expect("rules should be loaded");
+ assert!(
+ rules.contains("Use tabs"),
+ "expected .claude/rules/ compat loading"
+ );
+ }
+
+ #[test]
+ fn rules_directory_missing_does_not_crash() {
+ let tmp = tempdir().expect("tempdir");
+ // No .codewhale/rules/ or .claude/rules/ directories exist
+ let ctx = load_project_context(tmp.path());
+ // Rules block should be None when no rules directories exist
+ assert!(
+ ctx.rules_block.is_none(),
+ "rules_block should be None when no rules exist"
+ );
+ }
+
+ #[test]
+ fn rules_coexist_with_agents_md() {
+ let tmp = tempdir().expect("tempdir");
+ fs::write(tmp.path().join("AGENTS.md"), "Main project instructions").expect("write");
+ let rules_dir = tmp.path().join(".codewhale/rules");
+ fs::create_dir_all(&rules_dir).expect("mkdir rules");
+ fs::write(rules_dir.join("extra.md"), "Extra rule").expect("write");
+
+ let ctx = load_project_context(tmp.path());
+ let instructions = ctx.instructions.as_ref().unwrap();
+ let rules = ctx.rules_block.as_ref().unwrap();
+
+ assert!(
+ instructions.contains("Main project instructions"),
+ "AGENTS.md content missing"
+ );
+ assert!(rules.contains("Extra rule"), "rules content missing");
+ // AGENTS.md should come first in system block
+ let block = ctx.as_system_block().unwrap();
+ let pos_agents = block.find("Main project instructions").unwrap();
+ let pos_rule = block.find("Extra rule").unwrap();
+ assert!(pos_agents < pos_rule, "AGENTS.md should precede rules");
+ }
+
+ #[test]
+ fn non_md_files_in_rules_dir_are_ignored() {
+ let tmp = tempdir().expect("tempdir");
+ let rules_dir = tmp.path().join(".codewhale/rules");
+ fs::create_dir_all(&rules_dir).expect("mkdir rules");
+ fs::write(rules_dir.join("notes.txt"), "should be ignored").expect("write");
+ fs::write(rules_dir.join("valid.md"), "loaded").expect("write");
+
+ let ctx = load_project_context(tmp.path());
+ let rules = ctx.rules_block.as_ref().unwrap();
+
+ assert!(rules.contains("loaded"), "valid .md should be loaded");
+ assert!(
+ !rules.contains("should be ignored"),
+ ".txt should be ignored"
+ );
+ }
+
+ #[test]
+ fn rules_cap_truncates_excess_files() {
+ let tmp = tempdir().expect("tempdir");
+ let rules_dir = tmp.path().join(".codewhale/rules");
+ fs::create_dir_all(&rules_dir).expect("mkdir rules");
+
+ // Create more files than the cap
+ for i in 0..60 {
+ fs::write(
+ rules_dir.join(format!("rule_{i:04}.md")),
+ format!("content {i}"),
+ )
+ .expect("write");
+ }
+
+ let ctx = load_project_context(tmp.path());
+ let rules = ctx.rules_block.as_ref().unwrap();
+
+ // The last file (by sorted name) should NOT be present
+ assert!(
+ !rules.contains("content 59"),
+ "rule_0059 should be above cap"
+ );
+ // The first file should be present
+ assert!(
+ rules.contains("content 0"),
+ "rule_0000 should be within cap"
+ );
+ // Count blocks
+ let count = rules.matches(" {}",
+ rules.len(),
+ MAX_RULES_BLOCK_BYTES
+ );
+ assert!(
+ rules.contains("truncated at 500 KB"),
+ "truncation marker missing"
+ );
+ }
}
diff --git a/crates/tui/src/project_context_cache.rs b/crates/tui/src/project_context_cache.rs
index aefb3adf0b..fd1ed50639 100644
--- a/crates/tui/src/project_context_cache.rs
+++ b/crates/tui/src/project_context_cache.rs
@@ -217,4 +217,42 @@ mod tests {
assert_ne!(before, after);
}
+
+ #[test]
+ fn signature_changes_when_rules_file_changes() {
+ let workspace = tempdir().expect("workspace");
+ let home = tempdir().expect("home");
+ let rules_dir = workspace.path().join(".codewhale/rules");
+ fs::create_dir_all(&rules_dir).expect("mkdir rules");
+ fs::write(rules_dir.join("rule.md"), "alpha").expect("write alpha");
+
+ let before = compute_cache_key(workspace.path(), Some(home.path()));
+
+ fs::write(rules_dir.join("rule.md"), "bravo").expect("write bravo");
+ let after = compute_cache_key(workspace.path(), Some(home.path()));
+
+ assert_ne!(
+ before, after,
+ "cache key must change when rules file changes"
+ );
+ }
+
+ #[test]
+ fn signature_changes_when_rules_file_is_added_or_removed() {
+ let workspace = tempdir().expect("workspace");
+ let home = tempdir().expect("home");
+ let rules_dir = workspace.path().join(".codewhale/rules");
+ fs::create_dir_all(&rules_dir).expect("mkdir rules");
+
+ // No rules yet
+ let before = compute_cache_key(workspace.path(), Some(home.path()));
+
+ fs::write(rules_dir.join("new.md"), "content").expect("write new.md");
+ let after = compute_cache_key(workspace.path(), Some(home.path()));
+
+ assert_ne!(
+ before, after,
+ "cache key must change when rules file is added"
+ );
+ }
}