返回 CodeWhale
constitution.rs
根目录 / crates / tui / src / project_context / constitution.rs
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(&current, &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(&current, &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 &current,
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
484 lines RUST