返回 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. 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 &current,
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
455 lines RUST