feat(project): enrich repo constitution (invariants, branch policy, escalation)

Per the layered-authority clarification (base myth → global Constitution → repo
constitution = local law → task packet → runtime policy), extend
.codewhale/constitution.json beyond authority+verification with optional:

- protected_invariants — repo invariants the agent must not break
- branch_policy — branch/release policy in effect
- escalate_when — conditions to stop and escalate to the user

All optional; rendered as concise model-facing prose. The global Brother Whale
identity anchor and Constitution in prompts/base.md are unchanged (verified
untouched on this branch). Dogfood constitution.json filled with CodeWhale's
real invariants (prefix-cache byte-stability, transcript replay, stable Rust,
cli/tui parity), branch policy (codex/v0.8.53), and escalation rules. Docs note
the layered hierarchy.

cargo test -p codewhale-tui --bins → 3946 passed; clippy clean.
This commit is contained in:
Hunter Bown
2026-06-03 12:16:06 -07:00
parent 9d9616e898
commit fc8ad7b3a8
3 changed files with 74 additions and 8 deletions
+13 -1
View File
@@ -8,11 +8,23 @@
"memory", "memory",
"previous-session handoffs" "previous-session handoffs"
], ],
"protected_invariants": [
"Keep the active first-turn tool-catalog head byte-stable (DeepSeek KV prefix-cache invariant); changes to it must be one-time and deterministic.",
"Preserve old-session transcript replay: never remove a tool's registration just because it is deprecated/hidden.",
"Stable Rust only (edition 2024); no nightly features.",
"Keep the codewhale CLI dispatcher and the codewhale-tui binary in sync when crates/tui changes."
],
"branch_policy": "v0.8.53 work targets the codex/v0.8.53 integration branch, not main. One PR per logical workstream; do not mix unrelated fixes.",
"verification_policy": { "verification_policy": {
"before_claiming_done": [ "before_claiming_done": [
"run the focused tests for the changed crate (cargo test -p <crate>), then cargo check/clippy as appropriate", "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", "read changed files back to confirm the edit landed as intended",
"never claim verification you did not perform" "never claim verification you did not perform"
] ]
} },
"escalate_when": [
"an action is destructive or hard to reverse and was not explicitly authorized",
"changing provider/auth/config or anything that sends data to an external service",
"deleting or overwriting files you did not create, or that contradict how they were described"
]
} }
+42 -4
View File
@@ -194,6 +194,15 @@ struct RepoConstitution {
/// (highest authority first). /// (highest authority first).
#[serde(default)] #[serde(default)]
authority: Option<Vec<String>>, authority: Option<Vec<String>>,
/// Repo invariants the agent must not break.
#[serde(default)]
protected_invariants: Option<Vec<String>>,
/// Branch / release policy in effect (e.g. "PRs target codex/v0.8.53").
#[serde(default)]
branch_policy: Option<String>,
/// Conditions under which the agent should stop and escalate to the user.
#[serde(default)]
escalate_when: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
verification_policy: Option<VerificationPolicy>, verification_policy: Option<VerificationPolicy>,
} }
@@ -209,7 +218,14 @@ impl RepoConstitution {
/// True when the file carried no usable policy (so we can skip emitting an /// True when the file carried no usable policy (so we can skip emitting an
/// empty block). /// empty block).
fn is_empty(&self) -> bool { fn is_empty(&self) -> bool {
self.authority.as_ref().is_none_or(Vec::is_empty) let list_empty = |l: &Option<Vec<String>>| l.as_ref().is_none_or(Vec::is_empty);
list_empty(&self.authority)
&& list_empty(&self.protected_invariants)
&& list_empty(&self.escalate_when)
&& self
.branch_policy
.as_ref()
.is_none_or(|s| s.trim().is_empty())
&& self && self
.verification_policy .verification_policy
.as_ref() .as_ref()
@@ -217,7 +233,8 @@ impl RepoConstitution {
.is_none_or(Vec::is_empty) .is_none_or(Vec::is_empty)
} }
/// Render a model-facing authority block. /// Render a model-facing authority block (concise prose, per the layered
/// model: base myth → global constitution → repo constitution = local law).
fn render_block(&self, source: &Path) -> String { fn render_block(&self, source: &Path) -> String {
let mut body = String::new(); let mut body = String::new();
if let Some(authority) = self.authority.as_ref().filter(|a| !a.is_empty()) { if let Some(authority) = self.authority.as_ref().filter(|a| !a.is_empty()) {
@@ -228,6 +245,15 @@ impl RepoConstitution {
body.push_str(&format!("{}. {item}\n", idx + 1)); body.push_str(&format!("{}. {item}\n", idx + 1));
} }
} }
if let Some(invariants) = self.protected_invariants.as_ref().filter(|i| !i.is_empty()) {
body.push_str("\nProtected invariants — do not break:\n");
for item in invariants {
body.push_str(&format!("- {item}\n"));
}
}
if let Some(policy) = self.branch_policy.as_ref().filter(|s| !s.trim().is_empty()) {
body.push_str(&format!("\nBranch / release policy: {}\n", policy.trim()));
}
if let Some(steps) = self if let Some(steps) = self
.verification_policy .verification_policy
.as_ref() .as_ref()
@@ -239,8 +265,14 @@ impl RepoConstitution {
body.push_str(&format!("- {step}\n")); body.push_str(&format!("- {step}\n"));
} }
} }
if let Some(conditions) = self.escalate_when.as_ref().filter(|c| !c.is_empty()) {
body.push_str("\nStop and escalate to the user when:\n");
for item in conditions {
body.push_str(&format!("- {item}\n"));
}
}
format!( format!(
"<codewhale_repo_constitution source=\"{}\">\nCodeWhale-specific repo authority policy (takes precedence over a legacy WHALE.md).\n\n{}</codewhale_repo_constitution>", "<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; takes precedence over a legacy WHALE.md).\n\n{}</codewhale_repo_constitution>",
source.display(), source.display(),
body.trim_end() body.trim_end()
) )
@@ -1182,7 +1214,10 @@ mod tests {
r#"{ r#"{
"schema_version": 1, "schema_version": 1,
"authority": ["current user request", "live code and tests", "AGENTS.md"], "authority": ["current user request", "live code and tests", "AGENTS.md"],
"verification_policy": { "before_claiming_done": ["run focused tests"] } "protected_invariants": ["keep the tool-catalog head byte-stable"],
"branch_policy": "PRs target codex/v0.8.53, not main",
"verification_policy": { "before_claiming_done": ["run focused tests"] },
"escalate_when": ["a destructive action was not authorized"]
}"#, }"#,
) )
.expect("write constitution"); .expect("write constitution");
@@ -1195,6 +1230,9 @@ mod tests {
assert!(block.contains("<codewhale_repo_constitution")); assert!(block.contains("<codewhale_repo_constitution"));
assert!(block.contains("current user request")); assert!(block.contains("current user request"));
assert!(block.contains("run focused tests")); assert!(block.contains("run focused tests"));
assert!(block.contains("keep the tool-catalog head byte-stable"));
assert!(block.contains("PRs target codex/v0.8.53"));
assert!(block.contains("a destructive action was not authorized"));
assert!(block.contains("takes precedence over a legacy WHALE.md")); assert!(block.contains("takes precedence over a legacy WHALE.md"));
// It also surfaces through the system block. // It also surfaces through the system block.
assert!( assert!(
+19 -3
View File
@@ -29,14 +29,30 @@ Each repo can carry two distinct, complementary files:
"memory", "memory",
"old handoffs" "old handoffs"
], ],
"protected_invariants": [
"do not break old-session transcript replay"
],
"branch_policy": "PRs target the integration branch, not main",
"verification_policy": { "verification_policy": {
"before_claiming_done": ["run focused tests", "read changed files back"] "before_claiming_done": ["run focused tests", "read changed files back"]
} },
"escalate_when": [
"a destructive action was not explicitly authorized"
]
} }
``` ```
When present, it is rendered into the system prompt as a higher-authority All fields are optional. When present, the file is rendered into the system
block and takes precedence over a legacy `WHALE.md`. prompt as concise prose in a higher-authority block and takes precedence over
a legacy `WHALE.md`.
This is the **local-law** layer in CodeWhale's hierarchy: *base myth & global
Constitution* (the model prompt in `prompts/base.md`, including the Brother
Whale identity anchor) → *repo constitution* (`.codewhale/constitution.json`,
this file) → *task packet* (the current objective) → *runtime policy*
(permissions/sandbox/cost limits enforced in code). The repo constitution
gives decision rules; it does not replace the global Constitution or the
current user request.
> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`. > **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.
> CodeWhale still **reads** an existing `WHALE.md` (below `AGENTS.md`) so old > CodeWhale still **reads** an existing `WHALE.md` (below `AGENTS.md`) so old