| 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. No constitution (or an empty file) is no law: `Ok` with no |
| 97 | /// rules. A constitution that exists but cannot be read or parsed, or an |
| 98 | /// enforced invariant whose path glob does not compile, is `Err` naming the |
| 99 | /// problem: the caller holds every write instead of silently enforcing less |
| 100 | /// than the law says (misconfiguration fails loud). Parse warnings also reach |
| 101 | /// the user through the prompt-side load path, which reads the same file. |
| 102 | pub(crate) fn load_repo_law_rules(workspace: &Path) -> Result<Vec<RepoLawRule>, String> { |
| 103 | let Some((path, constitution)) = discover_repo_constitution(workspace)? else { |
| 104 | return Ok(Vec::new()); |
| 105 | }; |
| 106 | let mut rules = Vec::new(); |
| 107 | for invariant in constitution.protected_invariants.into_iter().flatten() { |
| 108 | let ProtectedInvariant::Enforced(enforced) = invariant else { |
| 109 | continue; |
| 110 | }; |
| 111 | let text = enforced.text.trim(); |
| 112 | if text.is_empty() { |
| 113 | continue; |
| 114 | } |
| 115 | let mut builder = globset::GlobSetBuilder::new(); |
| 116 | let mut patterns = Vec::new(); |
| 117 | for pattern in &enforced.paths { |
| 118 | let trimmed = pattern.trim(); |
| 119 | if trimmed.is_empty() { |
| 120 | continue; |
| 121 | } |
| 122 | let glob = globset::Glob::new(trimmed).map_err(|error| { |
| 123 | format!( |
| 124 | "{}: invariant \"{text}\" has an invalid path glob `{trimmed}`: {error}", |
| 125 | path.display() |
| 126 | ) |
| 127 | })?; |
| 128 | builder.add(glob); |
| 129 | patterns.push(trimmed.to_string()); |
| 130 | } |
| 131 | if patterns.is_empty() { |
| 132 | continue; |
| 133 | } |
| 134 | let globs = builder.build().map_err(|error| { |
| 135 | format!( |
| 136 | "{}: invariant \"{text}\" globs do not compile: {error}", |
| 137 | path.display() |
| 138 | ) |
| 139 | })?; |
| 140 | rules.push(RepoLawRule { |
| 141 | text: text.to_string(), |
| 142 | patterns, |
| 143 | globs, |
| 144 | action: enforced.action, |
| 145 | }); |
| 146 | } |
| 147 | Ok(rules) |
| 148 | } |
| 149 | |
| 150 | /// Walk from `workspace` toward the git root looking for the repo |
| 151 | /// constitution. `Ok(None)` when there is none (or it is empty); `Err` when |
| 152 | /// one exists but cannot be read or parsed. Used by the enforcement loader; |
| 153 | /// the prompt-side loader keeps its richer warning handling. |
| 154 | fn discover_repo_constitution( |
| 155 | workspace: &Path, |
| 156 | ) -> Result<Option<(PathBuf, RepoConstitution)>, String> { |
| 157 | let git_root = find_git_root(workspace); |
| 158 | let mut current = workspace.to_path_buf(); |
| 159 | loop { |
| 160 | let mut path = current.clone(); |
| 161 | for component in REPO_CONSTITUTION_RELATIVE_PATH { |
| 162 | path.push(component); |
| 163 | } |
| 164 | if context_candidate_exists(&path) { |
| 165 | let raw = match load_context_file(¤t, &path) { |
| 166 | Ok(raw) => raw, |
| 167 | Err(super::ProjectContextError::Empty { .. }) => return Ok(None), |
| 168 | Err(error) => return Err(error.to_string()), |
| 169 | }; |
| 170 | let constitution = serde_json::from_str::<RepoConstitution>(&raw) |
| 171 | .map_err(|error| format!("{} is not valid: {error}", path.display()))?; |
| 172 | return Ok(Some((path, constitution))); |
| 173 | } |
| 174 | if let Some(ref root) = git_root |
| 175 | && current == *root |
| 176 | { |
| 177 | break; |
| 178 | } |
| 179 | match current.parent() { |
| 180 | Some(parent) if parent != current => current = parent.to_path_buf(), |
| 181 | _ => break, |
| 182 | } |
| 183 | } |
| 184 | Ok(None) |
| 185 | } |
| 186 | |
| 187 | impl RepoConstitution { |
| 188 | /// True when the file carried no usable policy (so we can skip emitting an |
| 189 | /// empty block). |
| 190 | fn is_empty(&self) -> bool { |
| 191 | let list_empty = |l: &Option<Vec<String>>| l.as_ref().is_none_or(Vec::is_empty); |
| 192 | list_empty(&self.authority) |
| 193 | && self.protected_invariants.as_ref().is_none_or(Vec::is_empty) |
| 194 | && list_empty(&self.escalate_when) |
| 195 | && self |
| 196 | .branch_policy |
| 197 | .as_ref() |
| 198 | .is_none_or(|s| s.trim().is_empty()) |
| 199 | && self |
| 200 | .verification_policy |
| 201 | .as_ref() |
| 202 | .and_then(|p| p.before_claiming_done.as_ref()) |
| 203 | .is_none_or(Vec::is_empty) |
| 204 | } |
| 205 | |
| 206 | /// Render a model-facing authority block (concise prose, per the layered |
| 207 | /// model: base myth → global constitution → repo constitution = local law). |
| 208 | fn render_block(&self, source: &Path) -> String { |
| 209 | let mut body = String::new(); |
| 210 | if let Some(authority) = self.authority.as_ref().filter(|a| !a.is_empty()) { |
| 211 | body.push_str( |
| 212 | "When local sources conflict, trust them in this order (highest first):\n", |
| 213 | ); |
| 214 | for (idx, item) in authority.iter().enumerate() { |
| 215 | body.push_str(&format!("{}. {item}\n", idx + 1)); |
| 216 | } |
| 217 | } |
| 218 | if let Some(invariants) = self.protected_invariants.as_ref().filter(|i| !i.is_empty()) { |
| 219 | body.push_str("\nProtected invariants — do not break:\n"); |
| 220 | for item in invariants { |
| 221 | match item { |
| 222 | ProtectedInvariant::Advisory(text) => { |
| 223 | body.push_str(&format!("- {text}\n")); |
| 224 | } |
| 225 | ProtectedInvariant::Enforced(enforced) => { |
| 226 | let paths = enforced |
| 227 | .paths |
| 228 | .iter() |
| 229 | .map(String::as_str) |
| 230 | .collect::<Vec<_>>() |
| 231 | .join(", "); |
| 232 | if paths.is_empty() { |
| 233 | body.push_str(&format!("- {}\n", enforced.text)); |
| 234 | } else { |
| 235 | body.push_str(&format!( |
| 236 | "- {} (mechanically enforced for: {paths})\n", |
| 237 | enforced.text |
| 238 | )); |
| 239 | } |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | if let Some(policy) = self.branch_policy.as_ref().filter(|s| !s.trim().is_empty()) { |
| 245 | body.push_str(&format!("\nBranch / release policy: {}\n", policy.trim())); |
| 246 | } |
| 247 | if let Some(steps) = self |
| 248 | .verification_policy |
| 249 | .as_ref() |
| 250 | .and_then(|p| p.before_claiming_done.as_ref()) |
| 251 | .filter(|s| !s.is_empty()) |
| 252 | { |
| 253 | body.push_str("\nBefore claiming a task is done:\n"); |
| 254 | for step in steps { |
| 255 | body.push_str(&format!("- {step}\n")); |
| 256 | } |
| 257 | } |
| 258 | if let Some(conditions) = self.escalate_when.as_ref().filter(|c| !c.is_empty()) { |
| 259 | body.push_str("\nStop and escalate to the user when:\n"); |
| 260 | for item in conditions { |
| 261 | body.push_str(&format!("- {item}\n")); |
| 262 | } |
| 263 | } |
| 264 | format!( |
| 265 | "<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>", |
| 266 | // Same origin-label convention as `<project_instructions>`: file |
| 267 | // name only. The rendered `source` here is a runtime-canonicalized |
| 268 | // absolute path (workspace-relative traversal from the |
| 269 | // constitution's fixed relative path), but its final segment is a |
| 270 | // compile-time constant, so the base name is stable across |
| 271 | // directory moves and recasings. This keeps absolute paths out of |
| 272 | // provider-bound prompt labels. Operators still get the locator |
| 273 | // via `constitution_source_path` in the report and /constitution. |
| 274 | super::project_instructions_source_label(Some(source)), |
| 275 | body.trim_end() |
| 276 | ) |
| 277 | } |
| 278 | |
| 279 | fn policy_warnings(&self, source: &Path) -> Vec<String> { |
| 280 | let mut warnings = Vec::new(); |
| 281 | if let Some(policy) = self.branch_policy.as_deref() |
| 282 | && branch_policy_looks_stale(policy) |
| 283 | { |
| 284 | warnings.push(format!( |
| 285 | "{} branch_policy appears stale: hard-coded release branch guidance (`{}`). Use live branch/handoff truth and AGENTS.md instead of versioned integration-lane text.", |
| 286 | source.display(), |
| 287 | policy.trim() |
| 288 | )); |
| 289 | } |
| 290 | warnings |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | fn branch_policy_looks_stale(policy: &str) -> bool { |
| 295 | let lower = policy.to_ascii_lowercase(); |
| 296 | lower.contains("codex/v") |
| 297 | || ((lower.contains("integration branch") || lower.contains("not main")) |
| 298 | && contains_release_version_token(policy)) |
| 299 | } |
| 300 | |
| 301 | fn contains_release_version_token(value: &str) -> bool { |
| 302 | value |
| 303 | .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '.')) |
| 304 | .any(|token| { |
| 305 | let token = token.trim_start_matches(['v', 'V']); |
| 306 | let mut parts = token.split('.'); |
| 307 | matches!( |
| 308 | (parts.next(), parts.next(), parts.next(), parts.next()), |
| 309 | (Some(major), Some(minor), Some(patch), None) |
| 310 | if major.chars().all(|ch| ch.is_ascii_digit()) |
| 311 | && minor.chars().all(|ch| ch.is_ascii_digit()) |
| 312 | && patch.chars().all(|ch| ch.is_ascii_digit()) |
| 313 | ) |
| 314 | }) |
| 315 | } |
| 316 | |
| 317 | /// Discover and render `.codewhale/constitution.json` from `workspace` or, if |
| 318 | /// absent, its parent directories up to the git root. Returns the rendered |
| 319 | /// authority block plus any parse warnings. |
| 320 | pub(crate) fn load_repo_constitution_block( |
| 321 | workspace: &Path, |
| 322 | ) -> (Option<String>, Option<PathBuf>, Vec<String>) { |
| 323 | let mut warnings = Vec::new(); |
| 324 | let git_root = find_git_root(workspace); |
| 325 | let mut current = workspace.to_path_buf(); |
| 326 | loop { |
| 327 | let mut path = current.clone(); |
| 328 | for component in REPO_CONSTITUTION_RELATIVE_PATH { |
| 329 | path.push(component); |
| 330 | } |
| 331 | if context_candidate_exists(&path) { |
| 332 | match load_context_file(¤t, &path) { |
| 333 | Ok(raw) => match serde_json::from_str::<RepoConstitution>(&raw) { |
| 334 | Ok(constitution) if !constitution.is_empty() => { |
| 335 | if let Some(version) = constitution.schema_version |
| 336 | && version != SUPPORTED_CONSTITUTION_SCHEMA |
| 337 | { |
| 338 | warnings.push(format!( |
| 339 | "{} declares schema_version {version}; this build supports {SUPPORTED_CONSTITUTION_SCHEMA}. Reading it on a best-effort basis.", |
| 340 | path.display() |
| 341 | )); |
| 342 | } |
| 343 | warnings.extend(constitution.policy_warnings(&path)); |
| 344 | return (Some(constitution.render_block(&path)), Some(path), warnings); |
| 345 | } |
| 346 | Ok(_) => { |
| 347 | warnings.push(format!( |
| 348 | "{} has no authority/verification policy; ignoring.", |
| 349 | path.display() |
| 350 | )); |
| 351 | return (None, None, warnings); |
| 352 | } |
| 353 | Err(e) => { |
| 354 | warnings.push(format!("Failed to parse {}: {e}", path.display())); |
| 355 | return (None, None, warnings); |
| 356 | } |
| 357 | }, |
| 358 | Err(e) => { |
| 359 | warnings.push(format!("Failed to read {}: {e}", path.display())); |
| 360 | return (None, None, warnings); |
| 361 | } |
| 362 | } |
| 363 | } |
| 364 | if let Some(ref root) = git_root |
| 365 | && current == *root |
| 366 | { |
| 367 | break; |
| 368 | } |
| 369 | match current.parent() { |
| 370 | Some(parent) if parent != current => current = parent.to_path_buf(), |
| 371 | _ => break, |
| 372 | } |
| 373 | } |
| 374 | (None, None, warnings) |
| 375 | } |
| 376 | |
| 377 | pub(crate) fn repo_constitution_candidate_paths(workspace: &Path) -> Vec<PathBuf> { |
| 378 | let git_root = find_git_root(workspace); |
| 379 | let mut current = workspace.to_path_buf(); |
| 380 | let mut paths = Vec::new(); |
| 381 | loop { |
| 382 | paths.push(join_relative_components( |
| 383 | ¤t, |
| 384 | REPO_CONSTITUTION_RELATIVE_PATH, |
| 385 | )); |
| 386 | if let Some(ref root) = git_root |
| 387 | && current == *root |
| 388 | { |
| 389 | break; |
| 390 | } |
| 391 | match current.parent() { |
| 392 | Some(parent) if parent != current => current = parent.to_path_buf(), |
| 393 | _ => break, |
| 394 | } |
| 395 | } |
| 396 | paths |
| 397 | } |
| 398 | |
| 399 | #[cfg(test)] |
| 400 | mod tests { |
| 401 | use super::*; |
| 402 | use std::fs; |
| 403 | use tempfile::tempdir; |
| 404 | |
| 405 | #[test] |
| 406 | fn mixed_advisory_and_enforced_invariants_render_and_back_compat_holds() { |
| 407 | let tmp = tempdir().expect("tempdir"); |
| 408 | let dir = tmp.path().join(".codewhale"); |
| 409 | fs::create_dir_all(&dir).expect("law dir"); |
| 410 | fs::write( |
| 411 | dir.join("constitution.json"), |
| 412 | r#"{ |
| 413 | "protected_invariants": [ |
| 414 | "Plain advisory prose.", |
| 415 | { "text": "The wire format is frozen", "paths": ["crates/protocol/**"], "action": "block" } |
| 416 | ] |
| 417 | }"#, |
| 418 | ) |
| 419 | .expect("write law"); |
| 420 | |
| 421 | let (block, path, warnings) = load_repo_constitution_block(tmp.path()); |
| 422 | let block = block.expect("law renders"); |
| 423 | assert!(path.is_some()); |
| 424 | assert!(warnings.is_empty(), "{warnings:?}"); |
| 425 | assert!(block.contains("- Plain advisory prose."), "{block}"); |
| 426 | assert!( |
| 427 | block.contains( |
| 428 | "- The wire format is frozen (mechanically enforced for: crates/protocol/**)" |
| 429 | ), |
| 430 | "{block}" |
| 431 | ); |
| 432 | |
| 433 | // The enforcement loader compiles only the enforced entry. |
| 434 | let rules = load_repo_law_rules(tmp.path()).expect("valid law"); |
| 435 | assert_eq!(rules.len(), 1); |
| 436 | assert_eq!(rules[0].text, "The wire format is frozen"); |
| 437 | assert_eq!(rules[0].action, RepoLawAction::Block); |
| 438 | assert!(rules[0].globs.is_match("crates/protocol/wire.rs")); |
| 439 | } |
| 440 | |
| 441 | #[test] |
| 442 | fn legacy_string_only_invariants_render_unchanged_and_compile_nothing() { |
| 443 | let tmp = tempdir().expect("tempdir"); |
| 444 | let dir = tmp.path().join(".codewhale"); |
| 445 | fs::create_dir_all(&dir).expect("law dir"); |
| 446 | fs::write( |
| 447 | dir.join("constitution.json"), |
| 448 | r#"{"protected_invariants": ["Keep DeepSeek support first-class."]}"#, |
| 449 | ) |
| 450 | .expect("write law"); |
| 451 | |
| 452 | let (block, _, warnings) = load_repo_constitution_block(tmp.path()); |
| 453 | let block = block.expect("law renders"); |
| 454 | assert!(warnings.is_empty(), "{warnings:?}"); |
| 455 | assert!( |
| 456 | block.contains("- Keep DeepSeek support first-class."), |
| 457 | "{block}" |
| 458 | ); |
| 459 | assert!(!block.contains("mechanically enforced"), "{block}"); |
| 460 | assert!( |
| 461 | load_repo_law_rules(tmp.path()) |
| 462 | .expect("valid law") |
| 463 | .is_empty() |
| 464 | ); |
| 465 | } |
| 466 | |
| 467 | #[test] |
| 468 | fn repository_constitution_avoids_hard_coded_release_lane_policy() { |
| 469 | let repo_constitution = Path::new(env!("CARGO_MANIFEST_DIR")) |
| 470 | .join("../..") |
| 471 | .join(".codewhale") |
| 472 | .join("constitution.json"); |
| 473 | let raw = fs::read_to_string(&repo_constitution).expect("read repo constitution"); |
| 474 | let constitution: RepoConstitution = |
| 475 | serde_json::from_str(&raw).expect("parse repo constitution"); |
| 476 | let warnings = constitution.policy_warnings(&repo_constitution); |
| 477 | assert!( |
| 478 | warnings.is_empty(), |
| 479 | "repo constitution should not carry stale release-lane policy: {:?}", |
| 480 | warnings |
| 481 | ); |
| 482 | } |
| 483 | } |
| 484 |