| 1 | //! `.codewhale/constitution.json` — the Codewhale-specific repo authority and |
| 2 | //! prioritization policy. This module owns discovery (workspace upward to the |
| 3 | //! git root), parsing, the rendered `<codewhale_repo_constitution>` authority |
| 4 | //! block, and the mechanically enforceable write holds compiled for |
| 5 | //! `crate::repo_law`. |
| 6 | |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | |
| 9 | use serde::Deserialize; |
| 10 | |
| 11 | use super::{context_candidate_exists, find_git_root, join_relative_components, load_context_file}; |
| 12 | |
| 13 | /// Relative path (within a workspace or one of its parents) to the |
| 14 | /// Codewhale-specific repo authority/prioritization policy. |
| 15 | const REPO_CONSTITUTION_RELATIVE_PATH: &[&str] = &[".codewhale", "constitution.json"]; |
| 16 | |
| 17 | /// `schema_version` understood by this build of the constitution loader. |
| 18 | const SUPPORTED_CONSTITUTION_SCHEMA: u32 = 1; |
| 19 | |
| 20 | /// Codewhale-specific repo authority/prioritization policy, loaded from |
| 21 | /// `.codewhale/constitution.json`. All fields are optional so a minimal file |
| 22 | /// (or a future schema) still parses; unknown fields are ignored. |
| 23 | #[derive(Debug, Clone, Default, Deserialize)] |
| 24 | struct RepoConstitution { |
| 25 | #[serde(default)] |
| 26 | schema_version: Option<u32>, |
| 27 | /// Ordered list of sources to trust when local sources conflict |
| 28 | /// (highest authority first). |
| 29 | #[serde(default)] |
| 30 | authority: Option<Vec<String>>, |
| 31 | /// Repo invariants the agent must not break. Plain strings are advisory |
| 32 | /// prose (rendered into the prompt only); object entries with `paths` |
| 33 | /// are additionally compiled into mechanical write holds (see |
| 34 | /// `crate::repo_law`). Law can only tighten — there is no allow shape. |
| 35 | #[serde(default)] |
| 36 | protected_invariants: Option<Vec<ProtectedInvariant>>, |
| 37 | /// Branch / release policy in effect (e.g. "PRs target codex/v0.8.53"). |
| 38 | #[serde(default)] |
| 39 | branch_policy: Option<String>, |
| 40 | /// Conditions under which the agent should stop and escalate to the user. |
| 41 | #[serde(default)] |
| 42 | escalate_when: Option<Vec<String>>, |
| 43 | #[serde(default)] |
| 44 | verification_policy: Option<VerificationPolicy>, |
| 45 | } |
| 46 | |
| 47 | #[derive(Debug, Clone, Default, Deserialize)] |
| 48 | struct VerificationPolicy { |
| 49 | /// Steps to perform before claiming a task is done. |
| 50 | #[serde(default)] |
| 51 | before_claiming_done: Option<Vec<String>>, |
| 52 | } |
| 53 | |
| 54 | /// One protected invariant: either advisory prose (the historical shape) or |
| 55 | /// an enforced entry carrying path globs. Untagged so existing files keep |
| 56 | /// parsing unchanged. |
| 57 | #[derive(Debug, Clone, Deserialize)] |
| 58 | #[serde(untagged)] |
| 59 | enum ProtectedInvariant { |
| 60 | Advisory(String), |
| 61 | Enforced(EnforcedInvariant), |
| 62 | } |
| 63 | |
| 64 | #[derive(Debug, Clone, Deserialize)] |
| 65 | struct EnforcedInvariant { |
| 66 | text: String, |
| 67 | /// Workspace-relative path globs this invariant protects (e.g. |
| 68 | /// `crates/protocol/**`). Empty means advisory-only despite the shape. |
| 69 | #[serde(default)] |
| 70 | paths: Vec<String>, |
| 71 | /// What the harness does when a write targets a protected path. |
| 72 | #[serde(default)] |
| 73 | action: RepoLawAction, |
| 74 | } |
| 75 | |
| 76 | /// Enforcement level for a protected path. `Ask` force-prompts in |
| 77 | /// approval-gated postures and fails closed without a modal in Full Access; |
| 78 | /// `Block` denies outright in every posture. |
| 79 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] |
| 80 | #[serde(rename_all = "snake_case")] |
| 81 | pub(crate) enum RepoLawAction { |
| 82 | #[default] |
| 83 | Ask, |
| 84 | Block, |
| 85 | } |
| 86 | |
| 87 | /// A compiled, mechanically-enforceable repo-law rule. |
| 88 | pub(crate) struct RepoLawRule { |
| 89 | pub(crate) text: String, |
| 90 | pub(crate) patterns: Vec<String>, |
| 91 | pub(crate) globs: globset::GlobSet, |
| 92 | pub(crate) action: RepoLawAction, |
| 93 | } |
| 94 | |
| 95 | /// Load and compile the enforceable rules from the workspace's repo |
| 96 | /// constitution. Any failure — missing file, parse error, invalid glob — |
| 97 | /// degrades to fewer (or zero) rules: enforcement can silently do less, |
| 98 | /// never more, and never poisons the tool gate. Parse warnings still reach |
| 99 | /// the user through the prompt-side load path, which reads the same file. |
| 100 | pub(crate) fn load_repo_law_rules(workspace: &Path) -> Vec<RepoLawRule> { |
| 101 | let Some((_, constitution)) = discover_repo_constitution(workspace) else { |
| 102 | return Vec::new(); |
| 103 | }; |
| 104 | let mut rules = Vec::new(); |
| 105 | for invariant in constitution.protected_invariants.into_iter().flatten() { |
| 106 | let ProtectedInvariant::Enforced(enforced) = invariant else { |
| 107 | continue; |
| 108 | }; |
| 109 | if enforced.text.trim().is_empty() { |
| 110 | continue; |
| 111 | } |
| 112 | let mut builder = globset::GlobSetBuilder::new(); |
| 113 | let mut patterns = Vec::new(); |
| 114 | for pattern in &enforced.paths { |
| 115 | let trimmed = pattern.trim(); |
| 116 | if trimmed.is_empty() { |
| 117 | continue; |
| 118 | } |
| 119 | if let Ok(glob) = globset::Glob::new(trimmed) { |
| 120 | builder.add(glob); |
| 121 | patterns.push(trimmed.to_string()); |
| 122 | } |
| 123 | } |
| 124 | if patterns.is_empty() { |
| 125 | continue; |
| 126 | } |
| 127 | let Ok(globs) = builder.build() else { |
| 128 | continue; |
| 129 | }; |
| 130 | rules.push(RepoLawRule { |
| 131 | text: enforced.text.trim().to_string(), |
| 132 | patterns, |
| 133 | globs, |
| 134 | action: enforced.action, |
| 135 | }); |
| 136 | } |
| 137 | rules |
| 138 | } |
| 139 | |
| 140 | /// Walk from `workspace` toward the git root looking for the repo |
| 141 | /// constitution; parse best-effort. Shared by the enforcement loader; the |
| 142 | /// prompt-side loader keeps its richer warning handling. |
| 143 | fn discover_repo_constitution(workspace: &Path) -> Option<(PathBuf, RepoConstitution)> { |
| 144 | let git_root = find_git_root(workspace); |
| 145 | let mut current = workspace.to_path_buf(); |
| 146 | loop { |
| 147 | let mut path = current.clone(); |
| 148 | for component in REPO_CONSTITUTION_RELATIVE_PATH { |
| 149 | path.push(component); |
| 150 | } |
| 151 | if context_candidate_exists(&path) { |
| 152 | let constitution = load_context_file(&path) |
| 153 | .ok() |
| 154 | .and_then(|raw| serde_json::from_str::<RepoConstitution>(&raw).ok())?; |
| 155 | return Some((path, constitution)); |
| 156 | } |
| 157 | if let Some(ref root) = git_root |
| 158 | && current == *root |
| 159 | { |
| 160 | break; |
| 161 | } |
| 162 | match current.parent() { |
| 163 | Some(parent) if parent != current => current = parent.to_path_buf(), |
| 164 | _ => break, |
| 165 | } |
| 166 | } |
| 167 | None |
| 168 | } |
| 169 | |
| 170 | impl RepoConstitution { |
| 171 | /// True when the file carried no usable policy (so we can skip emitting an |
| 172 | /// empty block). |
| 173 | fn is_empty(&self) -> bool { |
| 174 | let list_empty = |l: &Option<Vec<String>>| l.as_ref().is_none_or(Vec::is_empty); |
| 175 | list_empty(&self.authority) |
| 176 | && self.protected_invariants.as_ref().is_none_or(Vec::is_empty) |
| 177 | && list_empty(&self.escalate_when) |
| 178 | && self |
| 179 | .branch_policy |
| 180 | .as_ref() |
| 181 | .is_none_or(|s| s.trim().is_empty()) |
| 182 | && self |
| 183 | .verification_policy |
| 184 | .as_ref() |
| 185 | .and_then(|p| p.before_claiming_done.as_ref()) |
| 186 | .is_none_or(Vec::is_empty) |
| 187 | } |
| 188 | |
| 189 | /// Render a model-facing authority block (concise prose, per the layered |
| 190 | /// model: base myth → global constitution → repo constitution = local law). |
| 191 | fn render_block(&self, source: &Path) -> String { |
| 192 | let mut body = String::new(); |
| 193 | if let Some(authority) = self.authority.as_ref().filter(|a| !a.is_empty()) { |
| 194 | body.push_str( |
| 195 | "When local sources conflict, trust them in this order (highest first):\n", |
| 196 | ); |
| 197 | for (idx, item) in authority.iter().enumerate() { |
| 198 | body.push_str(&format!("{}. {item}\n", idx + 1)); |
| 199 | } |
| 200 | } |
| 201 | if let Some(invariants) = self.protected_invariants.as_ref().filter(|i| !i.is_empty()) { |
| 202 | body.push_str("\nProtected invariants — do not break:\n"); |
| 203 | for item in invariants { |
| 204 | match item { |
| 205 | ProtectedInvariant::Advisory(text) => { |
| 206 | body.push_str(&format!("- {text}\n")); |
| 207 | } |
| 208 | ProtectedInvariant::Enforced(enforced) => { |
| 209 | let paths = enforced |
| 210 | .paths |
| 211 | .iter() |
| 212 | .map(String::as_str) |
| 213 | .collect::<Vec<_>>() |
| 214 | .join(", "); |
| 215 | if paths.is_empty() { |
| 216 | body.push_str(&format!("- {}\n", enforced.text)); |
| 217 | } else { |
| 218 | body.push_str(&format!( |
| 219 | "- {} (mechanically enforced for: {paths})\n", |
| 220 | enforced.text |
| 221 | )); |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | if let Some(policy) = self.branch_policy.as_ref().filter(|s| !s.trim().is_empty()) { |
| 228 | body.push_str(&format!("\nBranch / release policy: {}\n", policy.trim())); |
| 229 | } |
| 230 | if let Some(steps) = self |
| 231 | .verification_policy |
| 232 | .as_ref() |
| 233 | .and_then(|p| p.before_claiming_done.as_ref()) |
| 234 | .filter(|s| !s.is_empty()) |
| 235 | { |
| 236 | body.push_str("\nBefore claiming a task is done:\n"); |
| 237 | for step in steps { |
| 238 | body.push_str(&format!("- {step}\n")); |
| 239 | } |
| 240 | } |
| 241 | if let Some(conditions) = self.escalate_when.as_ref().filter(|c| !c.is_empty()) { |
| 242 | body.push_str("\nStop and escalate to the user when:\n"); |
| 243 | for item in conditions { |
| 244 | body.push_str(&format!("- {item}\n")); |
| 245 | } |
| 246 | } |
| 247 | format!( |
| 248 | "<codewhale_repo_constitution source=\"{}\">\nCodewhale-specific repo authority policy (local law: subordinate to the global Constitution and the current user request, but above memory and old handoffs; WHALE.md is ignored and should be migrated, not treated as law).\n\n{}</codewhale_repo_constitution>", |
| 249 | source.display(), |
| 250 | body.trim_end() |
| 251 | ) |
| 252 | } |
| 253 | |
| 254 | fn policy_warnings(&self, source: &Path) -> Vec<String> { |
| 255 | let mut warnings = Vec::new(); |
| 256 | if let Some(policy) = self.branch_policy.as_deref() |
| 257 | && branch_policy_looks_stale(policy) |
| 258 | { |
| 259 | warnings.push(format!( |
| 260 | "{} branch_policy appears stale: hard-coded release branch guidance (`{}`). Use live branch/handoff truth and AGENTS.md instead of versioned integration-lane text.", |
| 261 | source.display(), |
| 262 | policy.trim() |
| 263 | )); |
| 264 | } |
| 265 | warnings |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | fn branch_policy_looks_stale(policy: &str) -> bool { |
| 270 | let lower = policy.to_ascii_lowercase(); |
| 271 | lower.contains("codex/v") |
| 272 | || ((lower.contains("integration branch") || lower.contains("not main")) |
| 273 | && contains_release_version_token(policy)) |
| 274 | } |
| 275 | |
| 276 | fn contains_release_version_token(value: &str) -> bool { |
| 277 | value |
| 278 | .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '.')) |
| 279 | .any(|token| { |
| 280 | let token = token.trim_start_matches(['v', 'V']); |
| 281 | let mut parts = token.split('.'); |
| 282 | matches!( |
| 283 | (parts.next(), parts.next(), parts.next(), parts.next()), |
| 284 | (Some(major), Some(minor), Some(patch), None) |
| 285 | if major.chars().all(|ch| ch.is_ascii_digit()) |
| 286 | && minor.chars().all(|ch| ch.is_ascii_digit()) |
| 287 | && patch.chars().all(|ch| ch.is_ascii_digit()) |
| 288 | ) |
| 289 | }) |
| 290 | } |
| 291 | |
| 292 | /// Discover and render `.codewhale/constitution.json` from `workspace` or, if |
| 293 | /// absent, its parent directories up to the git root. Returns the rendered |
| 294 | /// authority block plus any parse warnings. |
| 295 | pub(crate) fn load_repo_constitution_block( |
| 296 | workspace: &Path, |
| 297 | ) -> (Option<String>, Option<PathBuf>, Vec<String>) { |
| 298 | let mut warnings = Vec::new(); |
| 299 | let git_root = find_git_root(workspace); |
| 300 | let mut current = workspace.to_path_buf(); |
| 301 | loop { |
| 302 | let mut path = current.clone(); |
| 303 | for component in REPO_CONSTITUTION_RELATIVE_PATH { |
| 304 | path.push(component); |
| 305 | } |
| 306 | if context_candidate_exists(&path) { |
| 307 | match load_context_file(&path) { |
| 308 | Ok(raw) => match serde_json::from_str::<RepoConstitution>(&raw) { |
| 309 | Ok(constitution) if !constitution.is_empty() => { |
| 310 | if let Some(version) = constitution.schema_version |
| 311 | && version != SUPPORTED_CONSTITUTION_SCHEMA |
| 312 | { |
| 313 | warnings.push(format!( |
| 314 | "{} declares schema_version {version}; this build supports {SUPPORTED_CONSTITUTION_SCHEMA}. Reading it on a best-effort basis.", |
| 315 | path.display() |
| 316 | )); |
| 317 | } |
| 318 | warnings.extend(constitution.policy_warnings(&path)); |
| 319 | return (Some(constitution.render_block(&path)), Some(path), warnings); |
| 320 | } |
| 321 | Ok(_) => { |
| 322 | warnings.push(format!( |
| 323 | "{} has no authority/verification policy; ignoring.", |
| 324 | path.display() |
| 325 | )); |
| 326 | return (None, None, warnings); |
| 327 | } |
| 328 | Err(e) => { |
| 329 | warnings.push(format!("Failed to parse {}: {e}", path.display())); |
| 330 | return (None, None, warnings); |
| 331 | } |
| 332 | }, |
| 333 | Err(e) => { |
| 334 | warnings.push(format!("Failed to read {}: {e}", path.display())); |
| 335 | return (None, None, warnings); |
| 336 | } |
| 337 | } |
| 338 | } |
| 339 | if let Some(ref root) = git_root |
| 340 | && current == *root |
| 341 | { |
| 342 | break; |
| 343 | } |
| 344 | match current.parent() { |
| 345 | Some(parent) if parent != current => current = parent.to_path_buf(), |
| 346 | _ => break, |
| 347 | } |
| 348 | } |
| 349 | (None, None, warnings) |
| 350 | } |
| 351 | |
| 352 | pub(crate) fn repo_constitution_candidate_paths(workspace: &Path) -> Vec<PathBuf> { |
| 353 | let git_root = find_git_root(workspace); |
| 354 | let mut current = workspace.to_path_buf(); |
| 355 | let mut paths = Vec::new(); |
| 356 | loop { |
| 357 | paths.push(join_relative_components( |
| 358 | ¤t, |
| 359 | REPO_CONSTITUTION_RELATIVE_PATH, |
| 360 | )); |
| 361 | if let Some(ref root) = git_root |
| 362 | && current == *root |
| 363 | { |
| 364 | break; |
| 365 | } |
| 366 | match current.parent() { |
| 367 | Some(parent) if parent != current => current = parent.to_path_buf(), |
| 368 | _ => break, |
| 369 | } |
| 370 | } |
| 371 | paths |
| 372 | } |
| 373 | |
| 374 | #[cfg(test)] |
| 375 | mod tests { |
| 376 | use super::*; |
| 377 | use std::fs; |
| 378 | use tempfile::tempdir; |
| 379 | |
| 380 | #[test] |
| 381 | fn mixed_advisory_and_enforced_invariants_render_and_back_compat_holds() { |
| 382 | let tmp = tempdir().expect("tempdir"); |
| 383 | let dir = tmp.path().join(".codewhale"); |
| 384 | fs::create_dir_all(&dir).expect("law dir"); |
| 385 | fs::write( |
| 386 | dir.join("constitution.json"), |
| 387 | r#"{ |
| 388 | "protected_invariants": [ |
| 389 | "Plain advisory prose.", |
| 390 | { "text": "The wire format is frozen", "paths": ["crates/protocol/**"], "action": "block" } |
| 391 | ] |
| 392 | }"#, |
| 393 | ) |
| 394 | .expect("write law"); |
| 395 | |
| 396 | let (block, path, warnings) = load_repo_constitution_block(tmp.path()); |
| 397 | let block = block.expect("law renders"); |
| 398 | assert!(path.is_some()); |
| 399 | assert!(warnings.is_empty(), "{warnings:?}"); |
| 400 | assert!(block.contains("- Plain advisory prose."), "{block}"); |
| 401 | assert!( |
| 402 | block.contains( |
| 403 | "- The wire format is frozen (mechanically enforced for: crates/protocol/**)" |
| 404 | ), |
| 405 | "{block}" |
| 406 | ); |
| 407 | |
| 408 | // The enforcement loader compiles only the enforced entry. |
| 409 | let rules = load_repo_law_rules(tmp.path()); |
| 410 | assert_eq!(rules.len(), 1); |
| 411 | assert_eq!(rules[0].text, "The wire format is frozen"); |
| 412 | assert_eq!(rules[0].action, RepoLawAction::Block); |
| 413 | assert!(rules[0].globs.is_match("crates/protocol/wire.rs")); |
| 414 | } |
| 415 | |
| 416 | #[test] |
| 417 | fn legacy_string_only_invariants_render_unchanged_and_compile_nothing() { |
| 418 | let tmp = tempdir().expect("tempdir"); |
| 419 | let dir = tmp.path().join(".codewhale"); |
| 420 | fs::create_dir_all(&dir).expect("law dir"); |
| 421 | fs::write( |
| 422 | dir.join("constitution.json"), |
| 423 | r#"{"protected_invariants": ["Keep DeepSeek support first-class."]}"#, |
| 424 | ) |
| 425 | .expect("write law"); |
| 426 | |
| 427 | let (block, _, warnings) = load_repo_constitution_block(tmp.path()); |
| 428 | let block = block.expect("law renders"); |
| 429 | assert!(warnings.is_empty(), "{warnings:?}"); |
| 430 | assert!( |
| 431 | block.contains("- Keep DeepSeek support first-class."), |
| 432 | "{block}" |
| 433 | ); |
| 434 | assert!(!block.contains("mechanically enforced"), "{block}"); |
| 435 | assert!(load_repo_law_rules(tmp.path()).is_empty()); |
| 436 | } |
| 437 | |
| 438 | #[test] |
| 439 | fn repository_constitution_avoids_hard_coded_release_lane_policy() { |
| 440 | let repo_constitution = Path::new(env!("CARGO_MANIFEST_DIR")) |
| 441 | .join("../..") |
| 442 | .join(".codewhale") |
| 443 | .join("constitution.json"); |
| 444 | let raw = fs::read_to_string(&repo_constitution).expect("read repo constitution"); |
| 445 | let constitution: RepoConstitution = |
| 446 | serde_json::from_str(&raw).expect("parse repo constitution"); |
| 447 | let warnings = constitution.policy_warnings(&repo_constitution); |
| 448 | assert!( |
| 449 | warnings.is_empty(), |
| 450 | "repo constitution should not carry stale release-lane policy: {:?}", |
| 451 | warnings |
| 452 | ); |
| 453 | } |
| 454 | } |
| 455 |