feat(project): deprecate WHALE.md; add .codewhale/constitution.json authority layer
Splits repo-level guidance into two clear artifacts and deprecates the confusing WHALE.md concept (overlapped with AGENTS.md): - AGENTS.md is the canonical cross-agent project-instructions file. - .codewhale/constitution.json is the CodeWhale-specific repo authority / prioritization policy (when local sources conflict, which to trust first; what to verify before claiming done). Rendered into the system prompt as a higher-authority <codewhale_repo_constitution> block; takes precedence over a legacy WHALE.md. WHALE.md migration (compat-preserving): - AGENTS.md now ranks above WHALE.md in both project and global discovery; with both present, AGENTS.md wins. - WHALE.md is still read as a legacy fallback, but now emits a deprecation warning and is never created or recommended (init.rs no longer suggests it). - Discovery/docs updated; the global CodeWhale Constitution in prompts/base.md is unaffected (different thing). constitution.json: - New RepoConstitution (serde, all fields optional, unknown fields ignored, schema_version checked). Discovered at .codewhale/constitution.json in the workspace or any parent up to the git root. Malformed JSON warns, never panics. - Loaded after the auto-generate fallback so it can't be clobbered. .gitignore: ignore .codewhale/ contents at any depth EXCEPT the committed constitution.json (a directory exclude can't be negated, so **/.codewhale/* + negation). init.rs writes the same pattern for new repos. Dogfood: this repo's .codewhale/constitution.json added. find_git_root made pub(crate) and reused (no duplicate loader). Tests: AGENTS-over-WHALE precedence, WHALE legacy-read-with-warning, constitution render + system-block surfacing, malformed-constitution warning, gitignore-keeps-constitution. cargo test -p codewhale-tui --bins → 3946 passed; clippy clean. Targets codex/v0.8.53.
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"authority": [
|
||||||
|
"current user request",
|
||||||
|
"live code and tests",
|
||||||
|
"GitHub issue/PR details",
|
||||||
|
"AGENTS.md and project CLAUDE.md",
|
||||||
|
"memory",
|
||||||
|
"previous-session handoffs"
|
||||||
|
],
|
||||||
|
"verification_policy": {
|
||||||
|
"before_claiming_done": [
|
||||||
|
"run the focused tests for the changed crate (cargo test -p <crate>), then cargo check/clippy as appropriate",
|
||||||
|
"read changed files back to confirm the edit landed as intended",
|
||||||
|
"never claim verification you did not perform"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -67,7 +67,10 @@ project_overhaul_prompt.md
|
|||||||
.wrangler/
|
.wrangler/
|
||||||
|
|
||||||
# Local runtime state
|
# Local runtime state
|
||||||
.codewhale/
|
# Ignore everything under any .codewhale/ (snapshots, auto-generated
|
||||||
|
# instructions.md, etc.) at any depth EXCEPT the committed repo authority policy.
|
||||||
|
**/.codewhale/*
|
||||||
|
!**/.codewhale/constitution.json
|
||||||
.deepseek/
|
.deepseek/
|
||||||
**/session_*.json
|
**/session_*.json
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ pub fn init(app: &mut App) -> CommandResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `workspace` is inside a git repository, ensure `.codewhale/` and
|
/// If `workspace` is inside a git repository, ensure workspace-local CodeWhale
|
||||||
/// `.deepseek/` are listed in the nearest `.gitignore` so that snapshots,
|
/// state is listed in the nearest `.gitignore` so snapshots, auto-generated
|
||||||
/// instructions, and other workspace-local state are not accidentally committed.
|
/// instructions, and other runtime state are not accidentally committed — while
|
||||||
|
/// keeping the authored `.codewhale/constitution.json` repo authority policy
|
||||||
|
/// committable (a directory exclude cannot be overridden, so `.codewhale/*` plus
|
||||||
|
/// a negation is required).
|
||||||
fn ensure_deepseek_gitignored(workspace: &Path) {
|
fn ensure_deepseek_gitignored(workspace: &Path) {
|
||||||
// Only act if this workspace is a git repo.
|
// Only act if this workspace is a git repo.
|
||||||
if !workspace.join(".git").exists() {
|
if !workspace.join(".git").exists() {
|
||||||
@@ -45,7 +48,11 @@ fn ensure_deepseek_gitignored(workspace: &Path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let gitignore = workspace.join(".gitignore");
|
let gitignore = workspace.join(".gitignore");
|
||||||
let entries = [".codewhale/", ".deepseek/"];
|
let entries = [
|
||||||
|
"**/.codewhale/*",
|
||||||
|
"!**/.codewhale/constitution.json",
|
||||||
|
".deepseek/",
|
||||||
|
];
|
||||||
|
|
||||||
// Read existing contents once.
|
// Read existing contents once.
|
||||||
let existing = std::fs::read_to_string(&gitignore).unwrap_or_default();
|
let existing = std::fs::read_to_string(&gitignore).unwrap_or_default();
|
||||||
@@ -109,7 +116,7 @@ fn generate_project_doc(workspace: &Path) -> String {
|
|||||||
doc.push_str("<!-- file patterns to avoid, and anything that helps a model navigate -->\n");
|
doc.push_str("<!-- file patterns to avoid, and anything that helps a model navigate -->\n");
|
||||||
doc.push_str("<!-- the codebase without reading every file. -->\n");
|
doc.push_str("<!-- the codebase without reading every file. -->\n");
|
||||||
doc.push('\n');
|
doc.push('\n');
|
||||||
doc.push_str("- **CodeWhale reads this file as:** <!-- WHALE.md (CodeWhale-native) or AGENTS.md (compatible with other agents) -->\n");
|
doc.push_str("- **CodeWhale reads this file as:** AGENTS.md (canonical cross-agent project instructions). <!-- WHALE.md is deprecated; put CodeWhale-specific authority policy in .codewhale/constitution.json -->\n");
|
||||||
doc.push_str(
|
doc.push_str(
|
||||||
"- **Read-only surface:** <!-- Which directories can the agent read but not write? -->\n",
|
"- **Read-only surface:** <!-- Which directories can the agent read but not write? -->\n",
|
||||||
);
|
);
|
||||||
@@ -394,6 +401,10 @@ version = "1.0.0"
|
|||||||
|
|
||||||
let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
|
let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
|
||||||
assert!(content.contains(".deepseek/"));
|
assert!(content.contains(".deepseek/"));
|
||||||
|
// .codewhale/ is ignored at any depth, but the committed
|
||||||
|
// constitution.json is kept.
|
||||||
|
assert!(content.contains("**/.codewhale/*"));
|
||||||
|
assert!(content.contains("!**/.codewhale/constitution.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3,36 +3,55 @@
|
|||||||
//! This module handles loading project-specific context files that provide
|
//! This module handles loading project-specific context files that provide
|
||||||
//! instructions and context to the AI agent. These include:
|
//! instructions and context to the AI agent. These include:
|
||||||
//!
|
//!
|
||||||
//! - `WHALE.md` - CodeWhale-native project instructions (highest priority)
|
//! - `AGENTS.md` - Cross-agent project instructions (canonical, highest priority)
|
||||||
//! - `AGENTS.md` - Generic agent instructions (compatible with other agents)
|
//! - `WHALE.md` - **Deprecated** legacy CodeWhale-native instructions (read-only fallback)
|
||||||
//! - `.claude/instructions.md` - Claude-style hidden instructions
|
//! - `.claude/instructions.md` - Claude-style hidden instructions (compat)
|
||||||
//! - `CLAUDE.md` - Claude-style instructions
|
//! - `CLAUDE.md` - Claude-style instructions (compat)
|
||||||
//! - `.codewhale/instructions.md` - Hidden instructions file (new)
|
//! - `.codewhale/instructions.md` - Hidden instructions file (compat)
|
||||||
//! - `.deepseek/instructions.md` - Hidden instructions file (legacy)
|
//! - `.deepseek/instructions.md` - Hidden instructions file (legacy)
|
||||||
//!
|
//!
|
||||||
//! The loaded content is injected into the system prompt to give the agent
|
//! CodeWhale-specific repo authority/prioritization policy lives separately in
|
||||||
//! context about the project's conventions, structure, and requirements.
|
//! `.codewhale/constitution.json` and is rendered as its own higher-authority
|
||||||
|
//! block. The loaded content is injected into the system prompt to give the
|
||||||
|
//! agent context about the project's conventions, structure, and requirements.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Names of project context files to look for, in priority order.
|
/// Names of project context files to look for, in priority order.
|
||||||
/// WHALE.md is the CodeWhale-native convention; AGENTS.md and CLAUDE.md
|
///
|
||||||
/// provide compatibility with other coding agents. `.codewhale/` is the
|
/// `AGENTS.md` is the canonical cross-agent project-instructions file.
|
||||||
/// new config directory; `.deepseek/` is the legacy fallback.
|
/// `WHALE.md` is **deprecated** (kept only as a read-only legacy fallback, now
|
||||||
|
/// below `AGENTS.md`) — CodeWhale-specific repo authority now lives in
|
||||||
|
/// `.codewhale/constitution.json`, not a bespoke markdown file. `CLAUDE.md` and
|
||||||
|
/// the `*/instructions.md` variants are read-only compatibility fallbacks;
|
||||||
|
/// CodeWhale never creates or recommends them.
|
||||||
const PROJECT_CONTEXT_FILES: &[&str] = &[
|
const PROJECT_CONTEXT_FILES: &[&str] = &[
|
||||||
"WHALE.md",
|
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
|
"WHALE.md", // deprecated: legacy CodeWhale-native, read-only fallback (#WHALE.md deprecation)
|
||||||
".claude/instructions.md",
|
".claude/instructions.md",
|
||||||
"CLAUDE.md",
|
"CLAUDE.md",
|
||||||
".codewhale/instructions.md",
|
".codewhale/instructions.md",
|
||||||
".deepseek/instructions.md",
|
".deepseek/instructions.md",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// File name of the deprecated CodeWhale-native instructions file.
|
||||||
|
const DEPRECATED_WHALE_FILENAME: &str = "WHALE.md";
|
||||||
|
|
||||||
|
/// Warning surfaced when a `WHALE.md` is still the active instruction source.
|
||||||
|
const WHALE_DEPRECATION_WARNING: &str = "WHALE.md is deprecated; move project instructions to AGENTS.md, or CodeWhale-specific authority policy to .codewhale/constitution.json. WHALE.md is still read for now but will be dropped from default discovery in a future release.";
|
||||||
|
|
||||||
|
/// Relative path (within a workspace or one of its parents) to the
|
||||||
|
/// CodeWhale-specific repo authority/prioritization policy.
|
||||||
|
const REPO_CONSTITUTION_RELATIVE_PATH: &[&str] = &[".codewhale", "constitution.json"];
|
||||||
|
|
||||||
|
/// `schema_version` understood by this build of the constitution loader.
|
||||||
|
const SUPPORTED_CONSTITUTION_SCHEMA: u32 = 1;
|
||||||
|
|
||||||
/// User-level project instructions loaded as a fallback when the workspace and
|
/// User-level project instructions loaded as a fallback when the workspace and
|
||||||
/// its parents do not define project context. `.codewhale/` takes priority
|
/// its parents do not define project context. `.codewhale/` takes priority
|
||||||
/// over vendor-neutral `.agents/`, which takes priority over legacy
|
/// over vendor-neutral `.agents/`, which takes priority over legacy
|
||||||
@@ -107,6 +126,10 @@ pub struct ProjectContext {
|
|||||||
pub source_path: Option<PathBuf>,
|
pub source_path: Option<PathBuf>,
|
||||||
/// Any warnings during loading
|
/// Any warnings during loading
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
|
/// Rendered `.codewhale/constitution.json` authority block, if present.
|
||||||
|
/// CodeWhale-specific repo authority/prioritization policy — distinct from
|
||||||
|
/// the cross-agent prose in `instructions`.
|
||||||
|
pub constitution_block: Option<String>,
|
||||||
/// Project root directory
|
/// Project root directory
|
||||||
#[allow(dead_code)] // Part of ProjectContext public interface
|
#[allow(dead_code)] // Part of ProjectContext public interface
|
||||||
pub project_root: PathBuf,
|
pub project_root: PathBuf,
|
||||||
@@ -121,6 +144,7 @@ impl ProjectContext {
|
|||||||
instructions: None,
|
instructions: None,
|
||||||
source_path: None,
|
source_path: None,
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
constitution_block: None,
|
||||||
project_root,
|
project_root,
|
||||||
is_trusted: false,
|
is_trusted: false,
|
||||||
}
|
}
|
||||||
@@ -131,9 +155,13 @@ impl ProjectContext {
|
|||||||
self.instructions.is_some()
|
self.instructions.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the instructions as a formatted block for system prompt
|
/// Get the instructions as a formatted block for system prompt.
|
||||||
|
///
|
||||||
|
/// The CodeWhale repo constitution (`.codewhale/constitution.json`), when
|
||||||
|
/// present, is emitted first as a higher-authority block, followed by the
|
||||||
|
/// cross-agent `<project_instructions>` prose. Either may be absent.
|
||||||
pub fn as_system_block(&self) -> Option<String> {
|
pub fn as_system_block(&self) -> Option<String> {
|
||||||
self.instructions.as_ref().map(|content| {
|
let instructions_block = self.instructions.as_ref().map(|content| {
|
||||||
let source = self
|
let source = self
|
||||||
.source_path
|
.source_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -142,10 +170,140 @@ impl ProjectContext {
|
|||||||
format!(
|
format!(
|
||||||
"<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
|
"<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
|
||||||
)
|
)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
match (self.constitution_block.as_ref(), instructions_block) {
|
||||||
|
(Some(constitution), Some(instructions)) => {
|
||||||
|
Some(format!("{constitution}\n\n{instructions}"))
|
||||||
|
}
|
||||||
|
(Some(constitution), None) => Some(constitution.clone()),
|
||||||
|
(None, Some(instructions)) => Some(instructions),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CodeWhale-specific repo authority/prioritization policy, loaded from
|
||||||
|
/// `.codewhale/constitution.json`. All fields are optional so a minimal file
|
||||||
|
/// (or a future schema) still parses; unknown fields are ignored.
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
struct RepoConstitution {
|
||||||
|
#[serde(default)]
|
||||||
|
schema_version: Option<u32>,
|
||||||
|
/// Ordered list of sources to trust when local sources conflict
|
||||||
|
/// (highest authority first).
|
||||||
|
#[serde(default)]
|
||||||
|
authority: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
verification_policy: Option<VerificationPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
struct VerificationPolicy {
|
||||||
|
/// Steps to perform before claiming a task is done.
|
||||||
|
#[serde(default)]
|
||||||
|
before_claiming_done: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoConstitution {
|
||||||
|
/// True when the file carried no usable policy (so we can skip emitting an
|
||||||
|
/// empty block).
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.authority.as_ref().is_none_or(Vec::is_empty)
|
||||||
|
&& self
|
||||||
|
.verification_policy
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.before_claiming_done.as_ref())
|
||||||
|
.is_none_or(Vec::is_empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a model-facing authority block.
|
||||||
|
fn render_block(&self, source: &Path) -> String {
|
||||||
|
let mut body = String::new();
|
||||||
|
if let Some(authority) = self.authority.as_ref().filter(|a| !a.is_empty()) {
|
||||||
|
body.push_str(
|
||||||
|
"When local sources conflict, trust them in this order (highest first):\n",
|
||||||
|
);
|
||||||
|
for (idx, item) in authority.iter().enumerate() {
|
||||||
|
body.push_str(&format!("{}. {item}\n", idx + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(steps) = self
|
||||||
|
.verification_policy
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.before_claiming_done.as_ref())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
body.push_str("\nBefore claiming a task is done:\n");
|
||||||
|
for step in steps {
|
||||||
|
body.push_str(&format!("- {step}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"<codewhale_repo_constitution source=\"{}\">\nCodeWhale-specific repo authority policy (takes precedence over a legacy WHALE.md).\n\n{}</codewhale_repo_constitution>",
|
||||||
|
source.display(),
|
||||||
|
body.trim_end()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover and render `.codewhale/constitution.json` from `workspace` or, if
|
||||||
|
/// absent, its parent directories up to the git root. Returns the rendered
|
||||||
|
/// authority block plus any parse warnings.
|
||||||
|
fn load_repo_constitution_block(workspace: &Path) -> (Option<String>, Vec<String>) {
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let git_root = crate::project_doc::find_git_root(workspace);
|
||||||
|
let mut current = workspace.to_path_buf();
|
||||||
|
loop {
|
||||||
|
let mut path = current.clone();
|
||||||
|
for component in REPO_CONSTITUTION_RELATIVE_PATH {
|
||||||
|
path.push(component);
|
||||||
|
}
|
||||||
|
if path.is_file() {
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => match serde_json::from_str::<RepoConstitution>(&raw) {
|
||||||
|
Ok(constitution) if !constitution.is_empty() => {
|
||||||
|
if let Some(version) = constitution.schema_version
|
||||||
|
&& version != SUPPORTED_CONSTITUTION_SCHEMA
|
||||||
|
{
|
||||||
|
warnings.push(format!(
|
||||||
|
"{} declares schema_version {version}; this build supports {SUPPORTED_CONSTITUTION_SCHEMA}. Reading it on a best-effort basis.",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return (Some(constitution.render_block(&path)), warnings);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
warnings.push(format!(
|
||||||
|
"{} has no authority/verification policy; ignoring.",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
return (None, warnings);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warnings.push(format!("Failed to parse {}: {e}", path.display()));
|
||||||
|
return (None, warnings);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warnings.push(format!("Failed to read {}: {e}", path.display()));
|
||||||
|
return (None, warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref root) = git_root
|
||||||
|
&& current == *root
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match current.parent() {
|
||||||
|
Some(parent) if parent != current => current = parent.to_path_buf(),
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ProjectContextPack {
|
struct ProjectContextPack {
|
||||||
project_name: String,
|
project_name: String,
|
||||||
@@ -433,6 +591,10 @@ pub fn load_project_context(workspace: &Path) -> ProjectContext {
|
|||||||
file_path.display(),
|
file_path.display(),
|
||||||
content.len()
|
content.len()
|
||||||
);
|
);
|
||||||
|
if *filename == DEPRECATED_WHALE_FILENAME {
|
||||||
|
tracing::warn!("{WHALE_DEPRECATION_WARNING}");
|
||||||
|
ctx.warnings.push(WHALE_DEPRECATION_WARNING.to_string());
|
||||||
|
}
|
||||||
ctx.instructions = Some(content);
|
ctx.instructions = Some(content);
|
||||||
ctx.source_path = Some(file_path);
|
ctx.source_path = Some(file_path);
|
||||||
break;
|
break;
|
||||||
@@ -527,6 +689,16 @@ fn load_project_context_with_parents_and_home(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the CodeWhale-specific repo authority policy
|
||||||
|
// (.codewhale/constitution.json) independently of the prose instructions —
|
||||||
|
// it is a distinct, higher-authority artifact and may exist with or without
|
||||||
|
// an AGENTS.md. When present it takes precedence over a legacy WHALE.md.
|
||||||
|
// Loaded last so the auto-generate fallback above (which rebuilds `ctx`)
|
||||||
|
// cannot clobber it.
|
||||||
|
let (constitution_block, constitution_warnings) = load_repo_constitution_block(workspace);
|
||||||
|
ctx.warnings.extend(constitution_warnings);
|
||||||
|
ctx.constitution_block = constitution_block;
|
||||||
|
|
||||||
ctx
|
ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,20 +725,20 @@ fn merge_global_and_project_instructions(
|
|||||||
fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Option<ProjectContext> {
|
fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Option<ProjectContext> {
|
||||||
let home = home_dir?;
|
let home = home_dir?;
|
||||||
|
|
||||||
// Priority order:
|
// Priority order (AGENTS.md preferred over the now-deprecated WHALE.md):
|
||||||
// 1. ~/.codewhale/WHALE.md (CodeWhale-native)
|
// 1. ~/.codewhale/AGENTS.md (canonical)
|
||||||
// 2. ~/.codewhale/AGENTS.md (new config directory)
|
// 2. ~/.codewhale/WHALE.md (deprecated, legacy fallback)
|
||||||
// 3. ~/.agents/WHALE.md (vendor-neutral fallback)
|
// 3. ~/.agents/AGENTS.md (vendor-neutral fallback)
|
||||||
// 4. ~/.agents/AGENTS.md (vendor-neutral fallback)
|
// 4. ~/.agents/WHALE.md (deprecated, vendor-neutral legacy)
|
||||||
// 5. ~/.deepseek/WHALE.md (legacy fallback)
|
// 5. ~/.deepseek/AGENTS.md (legacy fallback)
|
||||||
// 6. ~/.deepseek/AGENTS.md (legacy fallback)
|
// 6. ~/.deepseek/WHALE.md (deprecated, legacy)
|
||||||
let candidates: &[&[&str]] = &[
|
let candidates: &[&[&str]] = &[
|
||||||
GLOBAL_WHALE_RELATIVE_PATH,
|
|
||||||
GLOBAL_AGENTS_RELATIVE_PATH,
|
GLOBAL_AGENTS_RELATIVE_PATH,
|
||||||
GLOBAL_WHALE_VENDOR_NEUTRAL_PATH,
|
GLOBAL_WHALE_RELATIVE_PATH,
|
||||||
GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH,
|
GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH,
|
||||||
GLOBAL_WHALE_LEGACY_PATH,
|
GLOBAL_WHALE_VENDOR_NEUTRAL_PATH,
|
||||||
GLOBAL_AGENTS_LEGACY_PATH,
|
GLOBAL_AGENTS_LEGACY_PATH,
|
||||||
|
GLOBAL_WHALE_LEGACY_PATH,
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
@@ -580,6 +752,10 @@ fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Opti
|
|||||||
if path.exists() && path.is_file() {
|
if path.exists() && path.is_file() {
|
||||||
match load_context_file(&path) {
|
match load_context_file(&path) {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
|
if path.file_name().and_then(|n| n.to_str()) == Some(DEPRECATED_WHALE_FILENAME) {
|
||||||
|
tracing::warn!("{WHALE_DEPRECATION_WARNING}");
|
||||||
|
warnings.push(WHALE_DEPRECATION_WARNING.to_string());
|
||||||
|
}
|
||||||
let mut ctx = ProjectContext::empty(workspace.to_path_buf());
|
let mut ctx = ProjectContext::empty(workspace.to_path_buf());
|
||||||
ctx.instructions = Some(content);
|
ctx.instructions = Some(content);
|
||||||
ctx.source_path = Some(path);
|
ctx.source_path = Some(path);
|
||||||
@@ -961,6 +1137,93 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agents_md_preferred_over_deprecated_whale_md() {
|
||||||
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
fs::write(tmp.path().join("AGENTS.md"), "AGENTS canonical").expect("write agents");
|
||||||
|
fs::write(tmp.path().join("WHALE.md"), "WHALE legacy").expect("write whale");
|
||||||
|
|
||||||
|
let ctx = load_project_context(tmp.path());
|
||||||
|
let instructions = ctx.instructions.expect("instructions loaded");
|
||||||
|
assert!(instructions.contains("AGENTS canonical"), "{instructions}");
|
||||||
|
assert!(!instructions.contains("WHALE legacy"), "{instructions}");
|
||||||
|
// No deprecation warning since AGENTS.md won.
|
||||||
|
assert!(
|
||||||
|
!ctx.warnings.iter().any(|w| w.contains("WHALE.md is deprecated")),
|
||||||
|
"{:?}",
|
||||||
|
ctx.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whale_md_alone_is_still_read_with_deprecation_warning() {
|
||||||
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
fs::write(tmp.path().join("WHALE.md"), "WHALE legacy body").expect("write whale");
|
||||||
|
|
||||||
|
let ctx = load_project_context(tmp.path());
|
||||||
|
assert!(
|
||||||
|
ctx.instructions.as_deref() == Some("WHALE legacy body"),
|
||||||
|
"legacy WHALE.md must still be read"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ctx.warnings.iter().any(|w| w.contains("WHALE.md is deprecated")),
|
||||||
|
"expected deprecation warning, got {:?}",
|
||||||
|
ctx.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constitution_json_renders_authority_block() {
|
||||||
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
|
||||||
|
fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
|
||||||
|
fs::write(
|
||||||
|
tmp.path().join(".codewhale").join("constitution.json"),
|
||||||
|
r#"{
|
||||||
|
"schema_version": 1,
|
||||||
|
"authority": ["current user request", "live code and tests", "AGENTS.md"],
|
||||||
|
"verification_policy": { "before_claiming_done": ["run focused tests"] }
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write constitution");
|
||||||
|
|
||||||
|
let ctx = load_project_context_with_parents(tmp.path());
|
||||||
|
let block = ctx
|
||||||
|
.constitution_block
|
||||||
|
.as_deref()
|
||||||
|
.expect("constitution block rendered");
|
||||||
|
assert!(block.contains("<codewhale_repo_constitution"));
|
||||||
|
assert!(block.contains("current user request"));
|
||||||
|
assert!(block.contains("run focused tests"));
|
||||||
|
assert!(block.contains("takes precedence over a legacy WHALE.md"));
|
||||||
|
// It also surfaces through the system block.
|
||||||
|
assert!(
|
||||||
|
ctx.as_system_block()
|
||||||
|
.expect("system block")
|
||||||
|
.contains("codewhale_repo_constitution")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_constitution_warns_without_crashing() {
|
||||||
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
|
||||||
|
fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
|
||||||
|
fs::write(
|
||||||
|
tmp.path().join(".codewhale").join("constitution.json"),
|
||||||
|
"{ not valid json",
|
||||||
|
)
|
||||||
|
.expect("write bad constitution");
|
||||||
|
|
||||||
|
let ctx = load_project_context_with_parents(tmp.path());
|
||||||
|
assert!(ctx.constitution_block.is_none(), "no block for invalid JSON");
|
||||||
|
assert!(
|
||||||
|
ctx.warnings.iter().any(|w| w.contains("Failed to parse")),
|
||||||
|
"expected parse warning, got {:?}",
|
||||||
|
ctx.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_context_pack_is_stable_and_sorted() {
|
fn project_context_pack_is_stable_and_sorted() {
|
||||||
let tmp = tempdir().expect("tempdir");
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
//! Project document discovery and loading
|
//! Project document discovery and loading
|
||||||
//!
|
//!
|
||||||
//! Supports auto-discovery of project instructions like Claude Code.
|
//! Supports auto-discovery of project instructions like Claude Code.
|
||||||
//! Priority: WHALE.md > AGENTS.md > .claude/instructions.md > CLAUDE.md > .codewhale/instructions.md > .deepseek/instructions.md
|
//! Priority: AGENTS.md > WHALE.md (deprecated) > .claude/instructions.md > CLAUDE.md > .codewhale/instructions.md > .deepseek/instructions.md
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Document filenames to search for (in priority order)
|
/// Document filenames to search for (in priority order).
|
||||||
/// WHALE.md is the CodeWhale-native convention; AGENTS.md and CLAUDE.md
|
/// `AGENTS.md` is canonical. `WHALE.md` is **deprecated** (read-only legacy
|
||||||
/// provide compatibility; `.codewhale/` is the new config directory.
|
/// fallback, now below `AGENTS.md`); CodeWhale-specific authority policy lives
|
||||||
|
/// in `.codewhale/constitution.json`. `CLAUDE.md` and the `*/instructions.md`
|
||||||
|
/// variants are read-only compatibility fallbacks.
|
||||||
pub const DOC_FILENAMES: &[&str] = &[
|
pub const DOC_FILENAMES: &[&str] = &[
|
||||||
"WHALE.md",
|
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
|
"WHALE.md", // deprecated: legacy CodeWhale-native, read-only fallback
|
||||||
".claude/instructions.md",
|
".claude/instructions.md",
|
||||||
"CLAUDE.md",
|
"CLAUDE.md",
|
||||||
".codewhale/instructions.md",
|
".codewhale/instructions.md",
|
||||||
@@ -64,7 +66,7 @@ pub fn discover_paths(cwd: &Path) -> Vec<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the git root directory from cwd
|
/// Find the git root directory from cwd
|
||||||
fn find_git_root(cwd: &Path) -> Option<PathBuf> {
|
pub(crate) fn find_git_root(cwd: &Path) -> Option<PathBuf> {
|
||||||
let mut current = cwd.to_path_buf();
|
let mut current = cwd.to_path_buf();
|
||||||
loop {
|
loop {
|
||||||
if current.join(".git").exists() {
|
if current.join(".git").exists() {
|
||||||
|
|||||||
@@ -5,6 +5,48 @@ At process startup it also loads a workspace-local `.env` file when present.
|
|||||||
Use the tracked `.env.example` as the template; copy it to `.env`, then edit
|
Use the tracked `.env.example` as the template; copy it to `.env`, then edit
|
||||||
only the provider and safety knobs you need.
|
only the provider and safety knobs you need.
|
||||||
|
|
||||||
|
## Project instructions & repo authority
|
||||||
|
|
||||||
|
Each repo can carry two distinct, complementary files:
|
||||||
|
|
||||||
|
- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the
|
||||||
|
canonical file for "how should an agent work in this repo." Run `/init` to
|
||||||
|
scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as
|
||||||
|
compatibility fallbacks.
|
||||||
|
- **`.codewhale/constitution.json`** — CodeWhale-specific **repo authority /
|
||||||
|
prioritization policy**: when local sources conflict, which should CodeWhale
|
||||||
|
trust first, and what to verify before claiming a task is done. `.codewhale/`
|
||||||
|
lives inside the repo (like `.github/`). Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"authority": [
|
||||||
|
"current user request",
|
||||||
|
"live code and tests",
|
||||||
|
"GitHub issue/PR details",
|
||||||
|
"AGENTS.md",
|
||||||
|
"memory",
|
||||||
|
"old handoffs"
|
||||||
|
],
|
||||||
|
"verification_policy": {
|
||||||
|
"before_claiming_done": ["run focused tests", "read changed files back"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When present, it is rendered into the system prompt as a higher-authority
|
||||||
|
block and takes precedence over a legacy `WHALE.md`.
|
||||||
|
|
||||||
|
> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.
|
||||||
|
> CodeWhale still **reads** an existing `WHALE.md` (below `AGENTS.md`) so old
|
||||||
|
> repos keep working, and emits a deprecation notice, but it is no longer
|
||||||
|
> created or recommended and will be dropped from default discovery after a
|
||||||
|
> deprecation window. Move ordinary instructions to `AGENTS.md` and
|
||||||
|
> CodeWhale-specific authority policy to `.codewhale/constitution.json`. (The
|
||||||
|
> global CodeWhale Constitution shipped in the model prompt is a separate thing
|
||||||
|
> and is unaffected.)
|
||||||
|
|
||||||
## Where It Looks
|
## Where It Looks
|
||||||
|
|
||||||
Default config path:
|
Default config path:
|
||||||
|
|||||||
Reference in New Issue
Block a user