| 1 | //! `/constitution` command surface (#3806). |
| 2 | |
| 3 | use std::fmt::Write as _; |
| 4 | use std::path::PathBuf; |
| 5 | |
| 6 | use codewhale_config::{ |
| 7 | ConstitutionChoice, ConstitutionSource, ConstitutionValidity, RuntimePostureSource, SetupState, |
| 8 | SetupStep, UserConstitution, UserConstitutionLoad, |
| 9 | }; |
| 10 | |
| 11 | use crate::commands::traits::{CommandInfo, RegisterCommand}; |
| 12 | use crate::localization::{Locale, MessageId}; |
| 13 | use crate::tui::app::{App, AppAction}; |
| 14 | use crate::tui::pager::PagerView; |
| 15 | |
| 16 | use super::CommandResult; |
| 17 | |
| 18 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 19 | name: "constitution", |
| 20 | aliases: &["law"], |
| 21 | usage: "/constitution [status|preview|base|suggestions|ratify <id>|migrate|rollback|bundled|edit|review|repair|repo|explain|posture|help]", |
| 22 | description_id: MessageId::CmdConstitutionDescription, |
| 23 | }; |
| 24 | |
| 25 | pub(in crate::commands) struct ConstitutionCmd; |
| 26 | |
| 27 | impl RegisterCommand for ConstitutionCmd { |
| 28 | fn info() -> &'static CommandInfo { |
| 29 | &COMMAND_INFO |
| 30 | } |
| 31 | |
| 32 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 33 | match arg.map(str::trim).filter(|arg| !arg.is_empty()) { |
| 34 | None | Some("status" | "home" | "manager") => { |
| 35 | open_status(app); |
| 36 | CommandResult::ok() |
| 37 | } |
| 38 | Some("preview") => { |
| 39 | open_preview(app); |
| 40 | CommandResult::ok() |
| 41 | } |
| 42 | Some("review" | "existing") => { |
| 43 | open_review(app); |
| 44 | CommandResult::ok() |
| 45 | } |
| 46 | Some("repo" | "repo-local" | "law") => { |
| 47 | open_repo_law(app); |
| 48 | CommandResult::ok() |
| 49 | } |
| 50 | Some("explain" | "agents") => { |
| 51 | open_explanation(app); |
| 52 | CommandResult::ok() |
| 53 | } |
| 54 | Some("edit" | "guided" | "custom") => { |
| 55 | CommandResult::action(AppAction::OpenSetupWizardAt { |
| 56 | step: SetupStep::Constitution, |
| 57 | }) |
| 58 | } |
| 59 | Some("repair" | "fix") => CommandResult::with_message_and_action( |
| 60 | repair_text(app.ui_locale), |
| 61 | AppAction::OpenSetupWizardAt { |
| 62 | step: SetupStep::Constitution, |
| 63 | }, |
| 64 | ), |
| 65 | Some("posture" | "runtime-posture") => { |
| 66 | CommandResult::action(AppAction::OpenSetupWizardAt { |
| 67 | step: SetupStep::TrustSandbox, |
| 68 | }) |
| 69 | } |
| 70 | Some("bundled" | "default" | "use-bundled" | "use-default") => { |
| 71 | CommandResult::action(AppAction::UseBundledConstitution) |
| 72 | } |
| 73 | // The exact effective base prompt for the next turn (#3928). Routed |
| 74 | // as an action because the assembly needs the session config, so the |
| 75 | // preview is built by the same function the dispatch path uses |
| 76 | // rather than a lookalike reconstruction. |
| 77 | Some("base" | "prompt" | "base-prompt" | "effective") => { |
| 78 | CommandResult::action(AppAction::PreviewEffectiveBasePrompt) |
| 79 | } |
| 80 | Some("suggestions" | "proposed") => { |
| 81 | open_suggestions(app); |
| 82 | CommandResult::ok() |
| 83 | } |
| 84 | Some("migrate") => CommandResult::message(migrate_text(app.ui_locale)), |
| 85 | Some("rollback" | "undo-migrate") => { |
| 86 | CommandResult::message(rollback_text(app.ui_locale)) |
| 87 | } |
| 88 | Some(rest) if rest.starts_with("ratify") => { |
| 89 | let ids: Vec<String> = rest |
| 90 | .trim_start_matches("ratify") |
| 91 | .split_whitespace() |
| 92 | .map(str::to_string) |
| 93 | .collect(); |
| 94 | CommandResult::message(ratify_text(app.ui_locale, &ids)) |
| 95 | } |
| 96 | Some("help") => CommandResult::message(help_text(app.ui_locale)), |
| 97 | Some(other) => CommandResult::error(format!( |
| 98 | "Unknown /constitution target '{other}'. Try `/constitution` for the manager." |
| 99 | )), |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | fn open_status(app: &mut App) { |
| 105 | let locale = app.ui_locale; |
| 106 | let text = format_status(app, locale); |
| 107 | open_pager(app, manager_title(locale), &text); |
| 108 | } |
| 109 | |
| 110 | fn open_review(app: &mut App) { |
| 111 | let locale = app.ui_locale; |
| 112 | let mut text = format_status(app, locale); |
| 113 | let _ = write!(text, "\n\n{}", preview_text(locale)); |
| 114 | open_pager(app, review_title(locale), &text); |
| 115 | } |
| 116 | |
| 117 | fn open_preview(app: &mut App) { |
| 118 | let locale = app.ui_locale; |
| 119 | let text = preview_text(locale); |
| 120 | open_pager(app, rendered_title(locale), &text); |
| 121 | } |
| 122 | |
| 123 | fn open_repo_law(app: &mut App) { |
| 124 | let locale = app.ui_locale; |
| 125 | let context = crate::project_context::load_project_context_with_parents(&app.workspace); |
| 126 | let text = match context.constitution_block { |
| 127 | Some(block) => block, |
| 128 | None => no_repo_law_text(locale).to_string(), |
| 129 | }; |
| 130 | open_pager(app, repo_title(locale), &text); |
| 131 | } |
| 132 | |
| 133 | fn open_explanation(app: &mut App) { |
| 134 | let locale = app.ui_locale; |
| 135 | open_pager(app, explanation_title(locale), agents_explanation(locale)); |
| 136 | } |
| 137 | |
| 138 | /// Suggestions review surface (#3930). |
| 139 | /// |
| 140 | /// Suggested clauses are shown here and *only* here — they are absent from |
| 141 | /// `/constitution preview`, from the rendered block, and from the cache digest, |
| 142 | /// because unratified model advice is not law. |
| 143 | fn open_suggestions(app: &mut App) { |
| 144 | let locale = app.ui_locale; |
| 145 | let text = suggestions_text(locale); |
| 146 | open_pager(app, suggestions_title(locale), &text); |
| 147 | } |
| 148 | |
| 149 | fn suggestions_text(locale: Locale) -> String { |
| 150 | let UserConstitutionStatus::Loaded { constitution, .. } = load_user_constitution() else { |
| 151 | return match locale { |
| 152 | Locale::ZhHans => "没有可供审阅的结构化用户全局协作准则。".to_string(), |
| 153 | _ => "No structured user-global constitution is available to review.".to_string(), |
| 154 | }; |
| 155 | }; |
| 156 | let digest = constitution.cache_projection().digest; |
| 157 | let suggested: Vec<_> = constitution.suggested_clauses().collect(); |
| 158 | let accepted = constitution.accepted_clauses().count(); |
| 159 | |
| 160 | let mut out = String::new(); |
| 161 | match locale { |
| 162 | Locale::ZhHans => { |
| 163 | let _ = writeln!(out, "已生效条款:{accepted}"); |
| 164 | let _ = writeln!(out, "当前基线摘要:{digest}"); |
| 165 | out.push('\n'); |
| 166 | if suggested.is_empty() { |
| 167 | out.push_str("没有待批准的建议条款。模型建议在你明确批准之前不会生效。\n"); |
| 168 | } else { |
| 169 | out.push_str("待批准的建议条款(未注入模型,未计入缓存):\n"); |
| 170 | for clause in &suggested { |
| 171 | let _ = writeln!(out, "- [{}] {}", clause.id, clause.text); |
| 172 | } |
| 173 | out.push_str("\n用 /constitution ratify <id> 明确批准。基线变化后需要重新审阅。\n"); |
| 174 | } |
| 175 | } |
| 176 | _ => { |
| 177 | let _ = writeln!(out, "Ratified clauses in force: {accepted}"); |
| 178 | let _ = writeln!(out, "Current base digest: {digest}"); |
| 179 | out.push('\n'); |
| 180 | if suggested.is_empty() { |
| 181 | out.push_str( |
| 182 | "No suggested clauses are awaiting review. Model advice never applies itself.\n", |
| 183 | ); |
| 184 | } else { |
| 185 | out.push_str( |
| 186 | "Suggested clauses awaiting ratification (not injected, not in the cache digest):\n", |
| 187 | ); |
| 188 | for clause in &suggested { |
| 189 | let _ = writeln!(out, "- [{}] {}", clause.id, clause.text); |
| 190 | } |
| 191 | out.push_str( |
| 192 | "\nRatify explicitly with /constitution ratify <id>. If the base changes first, ratification is refused and you review again.\n", |
| 193 | ); |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | out |
| 198 | } |
| 199 | |
| 200 | /// Explicit ratification (#3930). Fails closed: it re-reads the live file, |
| 201 | /// hands the live digest back to [`UserConstitution::ratify`], and persists only |
| 202 | /// when the clause set is exactly what the user named. |
| 203 | fn ratify_text(locale: Locale, ids: &[String]) -> String { |
| 204 | if ids.is_empty() { |
| 205 | return match locale { |
| 206 | Locale::ZhHans => { |
| 207 | "用法:/constitution ratify <id> [<id>...]。先用 /constitution suggestions 查看待批准条款。" |
| 208 | .to_string() |
| 209 | } |
| 210 | _ => "Usage: /constitution ratify <id> [<id>...]. Run /constitution suggestions first to see what is pending.".to_string(), |
| 211 | }; |
| 212 | } |
| 213 | |
| 214 | let (path, constitution) = match load_user_constitution() { |
| 215 | UserConstitutionStatus::Loaded { path, constitution } => (path, constitution), |
| 216 | _ => { |
| 217 | return match locale { |
| 218 | Locale::ZhHans => "没有可批准的结构化用户全局协作准则。".to_string(), |
| 219 | _ => "There is no structured user-global constitution to ratify.".to_string(), |
| 220 | }; |
| 221 | } |
| 222 | }; |
| 223 | |
| 224 | let digest = constitution.cache_projection().digest; |
| 225 | match constitution.ratify(&digest, ids, None) { |
| 226 | Err(err) => match locale { |
| 227 | Locale::ZhHans => format!("未批准任何条款:{err}"), |
| 228 | _ => format!("Nothing was ratified: {err}"), |
| 229 | }, |
| 230 | Ok(ratification) => match ratification.constitution.save_to(&path) { |
| 231 | Err(err) => match locale { |
| 232 | Locale::ZhHans => format!("批准已计算但保存失败,文件未更改:{err:#}"), |
| 233 | _ => format!( |
| 234 | "Ratification was computed but could not be saved; the file is unchanged: {err:#}" |
| 235 | ), |
| 236 | }, |
| 237 | Ok(()) => match locale { |
| 238 | Locale::ZhHans => format!( |
| 239 | "已批准 {} 条:{}\n摘要 {} → {}\n这些条款现在会注入模型;提示词缓存前缀已随之变化。", |
| 240 | ratification.accepted_clause_ids.len(), |
| 241 | ratification.accepted_clause_ids.join(", "), |
| 242 | ratification.before_digest, |
| 243 | ratification.after_digest, |
| 244 | ), |
| 245 | _ => format!( |
| 246 | "Ratified {} clause(s): {}\nDigest {} → {}\nThey are injected from the next turn; the prompt-cache prefix moved with them.", |
| 247 | ratification.accepted_clause_ids.len(), |
| 248 | ratification.accepted_clause_ids.join(", "), |
| 249 | ratification.before_digest, |
| 250 | ratification.after_digest, |
| 251 | ), |
| 252 | }, |
| 253 | }, |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | /// Explicit schema migration with a receipt (#4782). A rejection writes nothing. |
| 258 | fn migrate_text(locale: Locale) -> String { |
| 259 | let path = match codewhale_config::UserConstitution::path() { |
| 260 | Ok(path) => path, |
| 261 | Err(err) => return path_error_preview_text(locale, &err.to_string()), |
| 262 | }; |
| 263 | match codewhale_config::UserConstitution::migrate_file(&path) { |
| 264 | Err(err) => match locale { |
| 265 | Locale::ZhHans => format!("迁移失败,文件未更改:{err:#}"), |
| 266 | _ => format!("Migration failed; the file is unchanged: {err:#}"), |
| 267 | }, |
| 268 | Ok(codewhale_config::MigrationOutcome::AlreadyCurrent { |
| 269 | preserved_unknown_keys, |
| 270 | .. |
| 271 | }) => { |
| 272 | let preserved = format_preserved(&preserved_unknown_keys, locale); |
| 273 | match locale { |
| 274 | Locale::ZhHans => { |
| 275 | format!("已是当前架构版本,未重写文件。\n保留的未知字段:{preserved}") |
| 276 | } |
| 277 | _ => format!( |
| 278 | "Already at the current schema; nothing was rewritten.\nPreserved unknown fields: {preserved}" |
| 279 | ), |
| 280 | } |
| 281 | } |
| 282 | Ok(codewhale_config::MigrationOutcome::Migrated { receipt, .. }) => { |
| 283 | let preserved = format_preserved(&receipt.preserved_unknown_keys, locale); |
| 284 | let backup = receipt |
| 285 | .backup_path |
| 286 | .as_ref() |
| 287 | .map(|p| p.display().to_string()) |
| 288 | .unwrap_or_default(); |
| 289 | match locale { |
| 290 | Locale::ZhHans => format!( |
| 291 | "已从架构 v{} 迁移到 v{}。\n备份:{backup}\n保留的未知字段:{preserved}\n模型可见字节摘要:{} → {}({})\n用 /constitution rollback 撤销。", |
| 292 | receipt.from_version, |
| 293 | receipt.to_version, |
| 294 | receipt.before_digest, |
| 295 | receipt.after_digest, |
| 296 | if receipt.is_cache_stable() { |
| 297 | "未改变,提示词缓存保持有效" |
| 298 | } else { |
| 299 | "已改变,提示词缓存前缀随之变化" |
| 300 | }, |
| 301 | ), |
| 302 | _ => format!( |
| 303 | "Migrated schema v{} → v{}.\nBackup: {backup}\nPreserved unknown fields: {preserved}\nModel-facing digest: {} → {} ({}).\nUndo with /constitution rollback.", |
| 304 | receipt.from_version, |
| 305 | receipt.to_version, |
| 306 | receipt.before_digest, |
| 307 | receipt.after_digest, |
| 308 | if receipt.is_cache_stable() { |
| 309 | "unchanged, so the prompt cache survives" |
| 310 | } else { |
| 311 | "changed, so the prompt-cache prefix moved" |
| 312 | }, |
| 313 | ), |
| 314 | } |
| 315 | } |
| 316 | Ok(codewhale_config::MigrationOutcome::Rejected(rejection)) => match locale { |
| 317 | Locale::ZhHans => format!( |
| 318 | "未迁移,文件保持原样。\n{}\n请手动修正后重试,或用 /constitution bundled 改用内置准则。", |
| 319 | rejection.receipt() |
| 320 | ), |
| 321 | _ => format!( |
| 322 | "Not migrated; the file is left exactly as it was.\n{}\nFix it by hand and retry, or run /constitution bundled to fall back to bundled law.", |
| 323 | rejection.receipt() |
| 324 | ), |
| 325 | }, |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | fn rollback_text(locale: Locale) -> String { |
| 330 | let path = match codewhale_config::UserConstitution::path() { |
| 331 | Ok(path) => path, |
| 332 | Err(err) => return path_error_preview_text(locale, &err.to_string()), |
| 333 | }; |
| 334 | match codewhale_config::UserConstitution::rollback_file(&path) { |
| 335 | Ok(backup) => match locale { |
| 336 | Locale::ZhHans => format!("已从 {} 恢复迁移前的内容。备份已消耗。", backup.display()), |
| 337 | _ => format!( |
| 338 | "Restored the pre-migration constitution from {}. The backup is consumed.", |
| 339 | backup.display() |
| 340 | ), |
| 341 | }, |
| 342 | Err(err) => match locale { |
| 343 | Locale::ZhHans => format!("未回滚:{err:#}"), |
| 344 | _ => format!("Nothing was rolled back: {err:#}"), |
| 345 | }, |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | fn format_preserved(keys: &[String], locale: Locale) -> String { |
| 350 | if keys.is_empty() { |
| 351 | match locale { |
| 352 | Locale::ZhHans => "无".to_string(), |
| 353 | _ => "none".to_string(), |
| 354 | } |
| 355 | } else { |
| 356 | keys.join(", ") |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | fn suggestions_title(locale: Locale) -> &'static str { |
| 361 | match locale { |
| 362 | Locale::ZhHans => "待批准的建议条款", |
| 363 | _ => "Suggested Clauses", |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | fn open_pager(app: &mut App, title: &str, text: &str) { |
| 368 | let width = app |
| 369 | .viewport |
| 370 | .last_transcript_area |
| 371 | .map(|area| area.width) |
| 372 | .unwrap_or(80); |
| 373 | app.view_stack |
| 374 | .push(PagerView::from_text(title, text, width.saturating_sub(2))); |
| 375 | } |
| 376 | |
| 377 | fn format_status(app: &App, locale: Locale) -> String { |
| 378 | let state = load_setup_state(); |
| 379 | let load = load_user_constitution(); |
| 380 | let context = crate::project_context::load_project_context_with_parents(&app.workspace); |
| 381 | let mut out = String::new(); |
| 382 | |
| 383 | let copy = ConstitutionManagerCopy::for_locale(locale); |
| 384 | |
| 385 | let _ = writeln!(out, "{}", copy.manager_header); |
| 386 | out.push('\n'); |
| 387 | let _ = writeln!(out, "{}", copy.active_stack_header); |
| 388 | let _ = writeln!(out, "- {}", copy.bundled_active); |
| 389 | let _ = writeln!( |
| 390 | out, |
| 391 | "- {}: {}", |
| 392 | copy.user_global_label, |
| 393 | user_constitution_stack_status(state.as_ref(), &load, locale) |
| 394 | ); |
| 395 | if let Some(path) = context.constitution_source_path.as_ref() { |
| 396 | let _ = writeln!( |
| 397 | out, |
| 398 | "- {}: {} ({})", |
| 399 | copy.repo_local_label, |
| 400 | copy.present, |
| 401 | path.display() |
| 402 | ); |
| 403 | } else { |
| 404 | let _ = writeln!(out, "- {}: {}", copy.repo_local_label, copy.not_present); |
| 405 | } |
| 406 | if let Some(path) = context.source_path.as_ref() { |
| 407 | let _ = writeln!( |
| 408 | out, |
| 409 | "- {}: {} ({})", |
| 410 | copy.agents_label, |
| 411 | copy.present, |
| 412 | path.display() |
| 413 | ); |
| 414 | } else if context.instructions.is_some() { |
| 415 | let _ = writeln!(out, "- {}: {}", copy.agents_label, copy.generated_fallback); |
| 416 | } else { |
| 417 | let _ = writeln!(out, "- {}: {}", copy.agents_label, copy.not_present); |
| 418 | } |
| 419 | let whale_warnings = ignored_whale_warnings(&context.warnings); |
| 420 | if whale_warnings.is_empty() { |
| 421 | let _ = writeln!(out, "- {}: {}", copy.legacy_whale_label, copy.not_present); |
| 422 | } else { |
| 423 | let _ = writeln!( |
| 424 | out, |
| 425 | "- {}: {} ({} {})", |
| 426 | copy.legacy_whale_label, |
| 427 | copy.whale_ignored, |
| 428 | whale_warnings.len(), |
| 429 | copy.location_count_unit(whale_warnings.len()) |
| 430 | ); |
| 431 | for warning in whale_warnings { |
| 432 | let _ = writeln!(out, " - {warning}"); |
| 433 | } |
| 434 | } |
| 435 | let handoff_path = app.workspace.join(crate::prompts::HANDOFF_RELATIVE_PATH); |
| 436 | let _ = writeln!( |
| 437 | out, |
| 438 | "- {}: {} {}, {} {}", |
| 439 | copy.memory_handoff_label, |
| 440 | copy.memory_label, |
| 441 | if app.use_memory { |
| 442 | copy.enabled |
| 443 | } else { |
| 444 | copy.disabled |
| 445 | }, |
| 446 | copy.handoff_label, |
| 447 | if handoff_path.exists() { |
| 448 | copy.present |
| 449 | } else { |
| 450 | copy.not_present |
| 451 | } |
| 452 | ); |
| 453 | |
| 454 | out.push('\n'); |
| 455 | let _ = writeln!(out, "{}", copy.user_global_header); |
| 456 | let _ = writeln!( |
| 457 | out, |
| 458 | "- {}: {}", |
| 459 | copy.choice_label, |
| 460 | state.as_ref().map_or(copy.not_recorded, |s| choice_label( |
| 461 | s.constitution_choice, |
| 462 | locale |
| 463 | )) |
| 464 | ); |
| 465 | let _ = writeln!( |
| 466 | out, |
| 467 | "- {}: {}", |
| 468 | copy.source_label, |
| 469 | state.as_ref().map_or(copy.not_recorded, |s| source_label( |
| 470 | s.constitution_source, |
| 471 | locale |
| 472 | )) |
| 473 | ); |
| 474 | let _ = writeln!( |
| 475 | out, |
| 476 | "- {}: {}", |
| 477 | copy.file_label, |
| 478 | user_constitution_file_label(&load, locale) |
| 479 | ); |
| 480 | let _ = writeln!( |
| 481 | out, |
| 482 | "- {}: {}", |
| 483 | copy.validity_label, |
| 484 | manager_validity_label(&load, state.as_ref(), locale) |
| 485 | ); |
| 486 | let _ = writeln!( |
| 487 | out, |
| 488 | "- {}: {}", |
| 489 | copy.language_label, |
| 490 | constitution_language(state.as_ref(), &load, locale) |
| 491 | ); |
| 492 | let _ = writeln!( |
| 493 | out, |
| 494 | "- {}: {}", |
| 495 | copy.last_preview_label, |
| 496 | preview_record_label(state.as_ref(), locale) |
| 497 | ); |
| 498 | let _ = writeln!( |
| 499 | out, |
| 500 | "- {}: {}", |
| 501 | copy.runtime_posture_label, |
| 502 | state.as_ref().map_or(copy.not_reviewed, |s| posture_label( |
| 503 | s.runtime_posture_source, |
| 504 | locale |
| 505 | )) |
| 506 | ); |
| 507 | let _ = writeln!( |
| 508 | out, |
| 509 | "- {}: {}", |
| 510 | copy.checkpoint_label, |
| 511 | state.as_ref().map_or(copy.not_completed.to_string(), |s| { |
| 512 | s.constitution_checkpoint_completed_for |
| 513 | .as_ref() |
| 514 | .map_or_else(|| copy.not_completed.to_string(), |v| copy.completed_for(v)) |
| 515 | }) |
| 516 | ); |
| 517 | |
| 518 | out.push('\n'); |
| 519 | let _ = writeln!(out, "{}", copy.preview_header); |
| 520 | let _ = writeln!(out, "- {}", copy.preview_action); |
| 521 | let _ = writeln!(out, "- {}", copy.repo_action); |
| 522 | |
| 523 | out.push('\n'); |
| 524 | let _ = writeln!(out, "{}", copy.maintenance_header); |
| 525 | for action in copy.maintenance_actions { |
| 526 | let _ = writeln!(out, "- {action}"); |
| 527 | } |
| 528 | out |
| 529 | } |
| 530 | |
| 531 | fn preview_text(locale: Locale) -> String { |
| 532 | let state = load_setup_state(); |
| 533 | let load = load_user_constitution(); |
| 534 | match load { |
| 535 | UserConstitutionStatus::Loaded { path, constitution } => { |
| 536 | let active = user_constitution_is_active(state.as_ref()); |
| 537 | let mut text = String::new(); |
| 538 | if !active { |
| 539 | text.push_str(inactive_preview_text(locale)); |
| 540 | text.push_str("\n\n"); |
| 541 | } |
| 542 | text.push_str( |
| 543 | &constitution |
| 544 | .render_block(Some(&path)) |
| 545 | .unwrap_or_else(|| structured_empty_text(locale).to_string()), |
| 546 | ); |
| 547 | text |
| 548 | } |
| 549 | UserConstitutionStatus::Missing { path } => missing_preview_text(locale, &path), |
| 550 | UserConstitutionStatus::Empty { path } => empty_preview_text(locale, &path), |
| 551 | UserConstitutionStatus::Invalid { path, error } => { |
| 552 | invalid_preview_text(locale, &path, &error) |
| 553 | } |
| 554 | UserConstitutionStatus::Unreadable { path, error } => { |
| 555 | unreadable_preview_text(locale, &path, &error) |
| 556 | } |
| 557 | UserConstitutionStatus::PathError { error } => path_error_preview_text(locale, &error), |
| 558 | } |
| 559 | } |
| 560 | |
| 561 | fn load_setup_state() -> Option<SetupState> { |
| 562 | SetupState::load().ok().flatten() |
| 563 | } |
| 564 | |
| 565 | #[derive(Debug)] |
| 566 | enum UserConstitutionStatus { |
| 567 | Missing { |
| 568 | path: PathBuf, |
| 569 | }, |
| 570 | Empty { |
| 571 | path: PathBuf, |
| 572 | }, |
| 573 | Invalid { |
| 574 | path: PathBuf, |
| 575 | error: String, |
| 576 | }, |
| 577 | Unreadable { |
| 578 | path: PathBuf, |
| 579 | error: String, |
| 580 | }, |
| 581 | Loaded { |
| 582 | path: PathBuf, |
| 583 | constitution: Box<codewhale_config::UserConstitution>, |
| 584 | }, |
| 585 | PathError { |
| 586 | error: String, |
| 587 | }, |
| 588 | } |
| 589 | |
| 590 | impl UserConstitutionStatus { |
| 591 | fn validity(&self) -> ConstitutionValidity { |
| 592 | match self { |
| 593 | Self::Missing { .. } | Self::PathError { .. } => ConstitutionValidity::Unknown, |
| 594 | Self::Empty { .. } => ConstitutionValidity::Empty, |
| 595 | Self::Invalid { .. } => ConstitutionValidity::Invalid, |
| 596 | Self::Unreadable { .. } => ConstitutionValidity::Unreadable, |
| 597 | Self::Loaded { constitution, .. } => constitution.validity(), |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | fn validity_for_display(&self, state: Option<&SetupState>) -> ConstitutionValidity { |
| 602 | match self { |
| 603 | Self::Missing { .. } | Self::PathError { .. } => { |
| 604 | state.map_or(ConstitutionValidity::Unknown, |s| s.constitution_validity) |
| 605 | } |
| 606 | _ => self.validity(), |
| 607 | } |
| 608 | } |
| 609 | } |
| 610 | |
| 611 | fn load_user_constitution() -> UserConstitutionStatus { |
| 612 | let path = match UserConstitution::path() { |
| 613 | Ok(path) => path, |
| 614 | Err(error) => { |
| 615 | return UserConstitutionStatus::PathError { |
| 616 | error: error.to_string(), |
| 617 | }; |
| 618 | } |
| 619 | }; |
| 620 | |
| 621 | match UserConstitution::load_from(&path) { |
| 622 | UserConstitutionLoad::Missing => UserConstitutionStatus::Missing { path }, |
| 623 | UserConstitutionLoad::Empty => UserConstitutionStatus::Empty { path }, |
| 624 | UserConstitutionLoad::Invalid(error) => UserConstitutionStatus::Invalid { path, error }, |
| 625 | UserConstitutionLoad::Unreadable(error) => { |
| 626 | UserConstitutionStatus::Unreadable { path, error } |
| 627 | } |
| 628 | UserConstitutionLoad::Loaded(constitution) => { |
| 629 | UserConstitutionStatus::Loaded { path, constitution } |
| 630 | } |
| 631 | } |
| 632 | } |
| 633 | |
| 634 | fn user_constitution_stack_status( |
| 635 | state: Option<&SetupState>, |
| 636 | load: &UserConstitutionStatus, |
| 637 | locale: Locale, |
| 638 | ) -> String { |
| 639 | let text = match load { |
| 640 | UserConstitutionStatus::Loaded { .. } if user_constitution_is_active(state) => match locale |
| 641 | { |
| 642 | Locale::ZhHans => "结构化用户全局准则已生效", |
| 643 | _ => "active structured user-global law", |
| 644 | }, |
| 645 | UserConstitutionStatus::Loaded { .. } => match locale { |
| 646 | Locale::ZhHans => "有效但未生效(已选择内置/默认或专家覆盖)", |
| 647 | _ => "valid but inactive (bundled/default or expert override selected)", |
| 648 | }, |
| 649 | UserConstitutionStatus::Missing { .. } => match locale { |
| 650 | Locale::ZhHans => "未配置;使用内置/默认准则", |
| 651 | _ => "not configured; bundled/default applies", |
| 652 | }, |
| 653 | UserConstitutionStatus::Empty { .. } => match locale { |
| 654 | Locale::ZhHans => "为空;建议修复", |
| 655 | _ => "empty; repair recommended", |
| 656 | }, |
| 657 | UserConstitutionStatus::Invalid { .. } => match locale { |
| 658 | Locale::ZhHans => "无效;建议修复", |
| 659 | _ => "invalid; repair recommended", |
| 660 | }, |
| 661 | UserConstitutionStatus::Unreadable { .. } => match locale { |
| 662 | Locale::ZhHans => "无法读取;建议修复", |
| 663 | _ => "unreadable; repair recommended", |
| 664 | }, |
| 665 | UserConstitutionStatus::PathError { .. } => match locale { |
| 666 | Locale::ZhHans => "不可用;CODEWHALE_HOME 错误", |
| 667 | _ => "unavailable; CODEWHALE_HOME error", |
| 668 | }, |
| 669 | }; |
| 670 | text.to_string() |
| 671 | } |
| 672 | |
| 673 | fn user_constitution_is_active(state: Option<&SetupState>) -> bool { |
| 674 | !matches!( |
| 675 | state.map(|s| s.constitution_choice), |
| 676 | Some( |
| 677 | ConstitutionChoice::Bundled |
| 678 | | ConstitutionChoice::Deferred |
| 679 | | ConstitutionChoice::ExpertOverride |
| 680 | ) |
| 681 | ) |
| 682 | } |
| 683 | |
| 684 | fn user_constitution_file_label(load: &UserConstitutionStatus, locale: Locale) -> String { |
| 685 | match load { |
| 686 | UserConstitutionStatus::Missing { path } |
| 687 | | UserConstitutionStatus::Empty { path } |
| 688 | | UserConstitutionStatus::Invalid { path, .. } |
| 689 | | UserConstitutionStatus::Unreadable { path, .. } |
| 690 | | UserConstitutionStatus::Loaded { path, .. } => path.display().to_string(), |
| 691 | UserConstitutionStatus::PathError { .. } => match locale { |
| 692 | Locale::ZhHans => "无法解析", |
| 693 | _ => "unresolved", |
| 694 | } |
| 695 | .to_string(), |
| 696 | } |
| 697 | } |
| 698 | |
| 699 | fn constitution_language( |
| 700 | state: Option<&SetupState>, |
| 701 | load: &UserConstitutionStatus, |
| 702 | locale: Locale, |
| 703 | ) -> String { |
| 704 | if let UserConstitutionStatus::Loaded { constitution, .. } = load |
| 705 | && let Some(language) = constitution.language.as_deref() |
| 706 | { |
| 707 | return language.to_string(); |
| 708 | } |
| 709 | state |
| 710 | .and_then(|s| s.constitution_language.as_deref()) |
| 711 | .unwrap_or(match locale { |
| 712 | Locale::ZhHans => "未记录", |
| 713 | _ => "not recorded", |
| 714 | }) |
| 715 | .to_string() |
| 716 | } |
| 717 | |
| 718 | fn preview_record_label(state: Option<&SetupState>, locale: Locale) -> String { |
| 719 | let Some(state) = state else { |
| 720 | return match locale { |
| 721 | Locale::ZhHans => "未记录", |
| 722 | _ => "not recorded", |
| 723 | } |
| 724 | .to_string(); |
| 725 | }; |
| 726 | match state.constitution_preview_hash.as_deref() { |
| 727 | Some(hash) => format!("v{} ({hash})", state.constitution_preview_version), |
| 728 | None => match locale { |
| 729 | Locale::ZhHans => "未记录", |
| 730 | _ => "not recorded", |
| 731 | } |
| 732 | .to_string(), |
| 733 | } |
| 734 | } |
| 735 | |
| 736 | fn choice_label(choice: ConstitutionChoice, locale: Locale) -> &'static str { |
| 737 | match (locale, choice) { |
| 738 | (Locale::ZhHans, ConstitutionChoice::Unset) => "未设置", |
| 739 | (Locale::ZhHans, ConstitutionChoice::Bundled) => "内置/默认", |
| 740 | (Locale::ZhHans, ConstitutionChoice::GuidedCustom) => "引导式自定义", |
| 741 | (Locale::ZhHans, ConstitutionChoice::ExpertOverride) => "专家覆盖", |
| 742 | (Locale::ZhHans, ConstitutionChoice::Deferred) => "已暂缓;使用内置", |
| 743 | (_, ConstitutionChoice::Unset) => "not set", |
| 744 | (_, ConstitutionChoice::Bundled) => "bundled/default", |
| 745 | (_, ConstitutionChoice::GuidedCustom) => "guided custom", |
| 746 | (_, ConstitutionChoice::ExpertOverride) => "expert override", |
| 747 | (_, ConstitutionChoice::Deferred) => "deferred; bundled applies", |
| 748 | } |
| 749 | } |
| 750 | |
| 751 | fn source_label(source: ConstitutionSource, locale: Locale) -> &'static str { |
| 752 | match (locale, source) { |
| 753 | (Locale::ZhHans, ConstitutionSource::Bundled) => "内置", |
| 754 | (Locale::ZhHans, ConstitutionSource::UserGlobal) => "用户全局 constitution.json", |
| 755 | (Locale::ZhHans, ConstitutionSource::ExpertOverride) => "专家提示词覆盖", |
| 756 | (_, ConstitutionSource::Bundled) => "bundled", |
| 757 | (_, ConstitutionSource::UserGlobal) => "user-global constitution.json", |
| 758 | (_, ConstitutionSource::ExpertOverride) => "expert prompt override", |
| 759 | } |
| 760 | } |
| 761 | |
| 762 | fn validity_label(validity: ConstitutionValidity, locale: Locale) -> &'static str { |
| 763 | match (locale, validity) { |
| 764 | (Locale::ZhHans, ConstitutionValidity::Unknown) => "未知或非自定义", |
| 765 | (Locale::ZhHans, ConstitutionValidity::Valid) => "有效", |
| 766 | (Locale::ZhHans, ConstitutionValidity::Invalid) => "无效", |
| 767 | (Locale::ZhHans, ConstitutionValidity::Empty) => "为空", |
| 768 | (Locale::ZhHans, ConstitutionValidity::Unreadable) => "无法读取", |
| 769 | (_, ConstitutionValidity::Unknown) => "unknown or not custom", |
| 770 | (_, ConstitutionValidity::Valid) => "valid", |
| 771 | (_, ConstitutionValidity::Invalid) => "invalid", |
| 772 | (_, ConstitutionValidity::Empty) => "empty", |
| 773 | (_, ConstitutionValidity::Unreadable) => "unreadable", |
| 774 | } |
| 775 | } |
| 776 | |
| 777 | fn manager_validity_label( |
| 778 | load: &UserConstitutionStatus, |
| 779 | state: Option<&SetupState>, |
| 780 | locale: Locale, |
| 781 | ) -> String { |
| 782 | if matches!(load, UserConstitutionStatus::Missing { .. }) |
| 783 | && state.is_some_and(|state| state.constitution_choice == ConstitutionChoice::Bundled) |
| 784 | { |
| 785 | return match locale { |
| 786 | Locale::ZhHans => "不适用(已选择内置/默认;没有自定义文件)".to_string(), |
| 787 | _ => "not applicable (bundled/default selected; no custom file)".to_string(), |
| 788 | }; |
| 789 | } |
| 790 | validity_label(load.validity_for_display(state), locale).to_string() |
| 791 | } |
| 792 | |
| 793 | fn posture_label(source: RuntimePostureSource, locale: Locale) -> &'static str { |
| 794 | match (locale, source) { |
| 795 | (Locale::ZhHans, RuntimePostureSource::Unset) => "未查看", |
| 796 | (Locale::ZhHans, RuntimePostureSource::Inherited) => "继承自现有配置", |
| 797 | (Locale::ZhHans, RuntimePostureSource::Confirmed) => "已在设置中确认", |
| 798 | (_, RuntimePostureSource::Unset) => "not reviewed", |
| 799 | (_, RuntimePostureSource::Inherited) => "inherited from existing config", |
| 800 | (_, RuntimePostureSource::Confirmed) => "confirmed in setup", |
| 801 | } |
| 802 | } |
| 803 | |
| 804 | fn ignored_whale_warnings(warnings: &[String]) -> Vec<&str> { |
| 805 | warnings |
| 806 | .iter() |
| 807 | .map(String::as_str) |
| 808 | .filter(|warning| warning.contains("WHALE.md is ignored")) |
| 809 | .collect() |
| 810 | } |
| 811 | |
| 812 | fn help_text(locale: Locale) -> String { |
| 813 | match locale { |
| 814 | Locale::ZhHans => "\ |
| 815 | 用法:/constitution [status|preview|bundled|edit|review|repair|repo|explain|posture|help] |
| 816 | |
| 817 | 常用命令: |
| 818 | - /constitution:打开协作准则管理器和当前层级。 |
| 819 | - /constitution preview:显示会注入模型的确切用户全局协作准则块;缺失、空、无效或不可读时显示修复说明。 |
| 820 | - /constitution edit:打开 /setup 的引导式协作准则步骤;用 1-6 调整,按 G 预览,再按 G 保存。 |
| 821 | - /constitution repair:说明当前文件状态,然后打开同一个引导式修复步骤。 |
| 822 | - /constitution bundled:记录使用内置/默认准则,不创建自定义文件。 |
| 823 | - /constitution repo:查看 .codewhale/constitution.json 仓库本地准则。 |
| 824 | - /constitution explain:解释内置基础准则、用户全局协作准则、仓库协作准则、AGENTS.md、记忆和交接的区别。 |
| 825 | - /constitution base:显示下一轮实际组装的确切基础提示词,附来源、摘要和字节/词元度量;只读,不发送请求,也不展开工具目录。 |
| 826 | - /constitution suggestions:查看待批准的建议条款;它们不会注入模型,也不影响提示词缓存。 |
| 827 | - /constitution ratify <id>:明确批准某条建议条款;若基线在审阅后发生变化则拒绝批准。 |
| 828 | - /constitution migrate:把协作准则文件迁移到当前架构版本并给出回执;被拒绝时文件保持原样。 |
| 829 | - /constitution rollback:恢复迁移前的备份。 |
| 830 | - /constitution posture:打开运行时姿态;协作准则只提供模型指导,不会更改批准、沙盒、Shell、网络、信任或 MCP 权限。", |
| 831 | _ => "\ |
| 832 | Usage: /constitution [status|preview|bundled|edit|review|repair|repo|explain|posture|help] |
| 833 | |
| 834 | Common commands: |
| 835 | - /constitution: open the constitution manager and active stack. |
| 836 | - /constitution preview: show the exact user-global constitution block that would be injected; missing, empty, invalid, or unreadable files show repair guidance instead. |
| 837 | - /constitution edit: open the guided /setup Constitution step; tune 1-6, press G to preview, then G again to save. |
| 838 | - /constitution repair: explain the current file state, then open the same guided repair step. |
| 839 | - /constitution bundled: record bundled/default law without creating a custom file. |
| 840 | - /constitution repo: inspect repo-local .codewhale/constitution.json law. |
| 841 | - /constitution explain: compare Constitution, user-global law, repo law, AGENTS.md, memory, and handoff. |
| 842 | - /constitution base: show the exact effective base prompt assembled for the next turn, with per-block provenance, digests, and byte/token measures. Read-only: no provider request, no tool-catalog expansion. |
| 843 | - /constitution suggestions: review suggested clauses awaiting ratification; they are not injected and do not move the prompt-cache digest. |
| 844 | - /constitution ratify <id>: explicitly ratify a suggested clause. Refused if the base changed after you reviewed it. |
| 845 | - /constitution migrate: migrate the constitution file to the current schema with a receipt; a rejected file is left exactly as it was. |
| 846 | - /constitution rollback: restore the pre-migration backup. |
| 847 | - /constitution posture: open runtime posture; constitution text is model guidance only and does not change approvals, sandbox, shell, network, trust, or MCP permissions.", |
| 848 | } |
| 849 | .to_string() |
| 850 | } |
| 851 | |
| 852 | fn repair_text(locale: Locale) -> String { |
| 853 | let state = load_setup_state(); |
| 854 | let load = load_user_constitution(); |
| 855 | let file = user_constitution_file_label(&load, locale); |
| 856 | let choice = state.as_ref().map_or( |
| 857 | match locale { |
| 858 | Locale::ZhHans => "未记录", |
| 859 | _ => "not recorded", |
| 860 | }, |
| 861 | |s| choice_label(s.constitution_choice, locale), |
| 862 | ); |
| 863 | let validity = validity_label(load.validity_for_display(state.as_ref()), locale); |
| 864 | let status = user_constitution_stack_status(state.as_ref(), &load, locale); |
| 865 | |
| 866 | match locale { |
| 867 | Locale::ZhHans => format!( |
| 868 | "\ |
| 869 | 用户全局协作准则修复 |
| 870 | |
| 871 | 当前文件:{file} |
| 872 | 当前状态:{status} |
| 873 | 记录选择:{choice} |
| 874 | 有效性:{validity} |
| 875 | |
| 876 | 接下来将打开 /setup 的协作准则步骤。安全修复路径: |
| 877 | - 用 1-6 调整引导式草稿,按 G 预览,再按 G 保存新的结构化 constitution.json。 |
| 878 | - 按 U 或运行 /constitution bundled 记录使用内置/默认准则;现有无效/空/不可读文件不会被注入。 |
| 879 | - 用 /constitution preview 查看当前错误或渲染结果。 |
| 880 | |
| 881 | 这只处理用户全局 constitution.json。运行时批准、沙盒、Shell、网络、信任、默认模式和 MCP 权限仍由运行时姿态/配置控制。" |
| 882 | ), |
| 883 | _ => format!( |
| 884 | "\ |
| 885 | User-global constitution repair |
| 886 | |
| 887 | Current file: {file} |
| 888 | Current state: {status} |
| 889 | Recorded choice: {choice} |
| 890 | Validity: {validity} |
| 891 | |
| 892 | Opening the /setup Constitution step next. Safe repair paths: |
| 893 | - Tune the guided draft with 1-6, press G to preview, then G again to save a fresh structured constitution.json. |
| 894 | - Press U or run /constitution bundled to record bundled/default law; the existing invalid/empty/unreadable file will not be injected. |
| 895 | - Use /constitution preview to inspect the current error or rendered result. |
| 896 | |
| 897 | This only repairs the user-global constitution.json. Runtime approval, sandbox, shell, network, trust, default mode, and MCP authority still belong to runtime posture/config." |
| 898 | ), |
| 899 | } |
| 900 | } |
| 901 | |
| 902 | fn manager_title(locale: Locale) -> &'static str { |
| 903 | match locale { |
| 904 | Locale::ZhHans => "协作准则", |
| 905 | _ => "Constitution", |
| 906 | } |
| 907 | } |
| 908 | |
| 909 | fn review_title(locale: Locale) -> &'static str { |
| 910 | match locale { |
| 911 | Locale::ZhHans => "协作准则检查", |
| 912 | _ => "Constitution Review", |
| 913 | } |
| 914 | } |
| 915 | |
| 916 | fn rendered_title(locale: Locale) -> &'static str { |
| 917 | match locale { |
| 918 | Locale::ZhHans => "渲染后的用户协作准则", |
| 919 | _ => "Rendered User Constitution", |
| 920 | } |
| 921 | } |
| 922 | |
| 923 | fn repo_title(locale: Locale) -> &'static str { |
| 924 | match locale { |
| 925 | Locale::ZhHans => "仓库本地协作准则", |
| 926 | _ => "Repo-Local Constitution", |
| 927 | } |
| 928 | } |
| 929 | |
| 930 | fn explanation_title(locale: Locale) -> &'static str { |
| 931 | match locale { |
| 932 | Locale::ZhHans => "AGENTS.md 与协作准则", |
| 933 | _ => "AGENTS.md vs Constitution", |
| 934 | } |
| 935 | } |
| 936 | |
| 937 | fn no_repo_law_text(locale: Locale) -> &'static str { |
| 938 | match locale { |
| 939 | Locale::ZhHans => "此工作区未找到仓库本地协作准则 .codewhale/constitution.json。", |
| 940 | _ => "No repo-local constitution found at .codewhale/constitution.json for this workspace.", |
| 941 | } |
| 942 | } |
| 943 | |
| 944 | fn inactive_preview_text(locale: Locale) -> &'static str { |
| 945 | match locale { |
| 946 | Locale::ZhHans => "非活动预览:当前选择了内置/默认或专家覆盖。", |
| 947 | _ => "Inactive preview: bundled/default or expert override is selected.", |
| 948 | } |
| 949 | } |
| 950 | |
| 951 | fn structured_empty_text(locale: Locale) -> &'static str { |
| 952 | match locale { |
| 953 | Locale::ZhHans => "结构化协作准则为空。", |
| 954 | _ => "The structured constitution is empty.", |
| 955 | } |
| 956 | } |
| 957 | |
| 958 | fn missing_preview_text(locale: Locale, path: &std::path::Path) -> String { |
| 959 | match locale { |
| 960 | Locale::ZhHans => format!( |
| 961 | "未在 {} 找到结构化用户全局协作准则。\n\n当前使用内置准则。使用 /constitution edit 创建引导式长期偏好,或使用 /constitution bundled 明确记录内置/默认。", |
| 962 | path.display() |
| 963 | ), |
| 964 | _ => format!( |
| 965 | "No structured user-global constitution found at {}.\n\nBundled law applies. Use /constitution edit to create guided standing preferences, or /constitution bundled to record bundled/default explicitly.", |
| 966 | path.display() |
| 967 | ), |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | fn empty_preview_text(locale: Locale, path: &std::path::Path) -> String { |
| 972 | match locale { |
| 973 | Locale::ZhHans => format!( |
| 974 | "{} 的结构化用户全局协作准则为空。使用 /constitution repair 返回引导式协作准则步骤。", |
| 975 | path.display() |
| 976 | ), |
| 977 | _ => format!( |
| 978 | "The structured user-global constitution at {} is empty. Use /constitution repair to return to the guided constitution step.", |
| 979 | path.display() |
| 980 | ), |
| 981 | } |
| 982 | } |
| 983 | |
| 984 | fn invalid_preview_text(locale: Locale, path: &std::path::Path, error: &str) -> String { |
| 985 | match locale { |
| 986 | Locale::ZhHans => format!( |
| 987 | "{} 的结构化用户全局协作准则无效,且不会注入。\n\n{error}\n\n使用 /constitution repair 返回引导式协作准则步骤。", |
| 988 | path.display() |
| 989 | ), |
| 990 | _ => format!( |
| 991 | "The structured user-global constitution at {} is invalid and is not injected.\n\n{error}\n\nUse /constitution repair to return to the guided constitution step.", |
| 992 | path.display() |
| 993 | ), |
| 994 | } |
| 995 | } |
| 996 | |
| 997 | fn unreadable_preview_text(locale: Locale, path: &std::path::Path, error: &str) -> String { |
| 998 | match locale { |
| 999 | Locale::ZhHans => format!( |
| 1000 | "无法读取 {} 的结构化用户全局协作准则,且不会注入。\n\n{error}\n\n使用 /constitution repair 返回引导式协作准则步骤。", |
| 1001 | path.display() |
| 1002 | ), |
| 1003 | _ => format!( |
| 1004 | "The structured user-global constitution at {} could not be read and is not injected.\n\n{error}\n\nUse /constitution repair to return to the guided constitution step.", |
| 1005 | path.display() |
| 1006 | ), |
| 1007 | } |
| 1008 | } |
| 1009 | |
| 1010 | fn path_error_preview_text(locale: Locale, error: &str) -> String { |
| 1011 | match locale { |
| 1012 | Locale::ZhHans => format!("无法为用户全局协作准则解析 CODEWHALE_HOME:\n\n{error}"), |
| 1013 | _ => { |
| 1014 | format!("Could not resolve CODEWHALE_HOME for the user-global constitution:\n\n{error}") |
| 1015 | } |
| 1016 | } |
| 1017 | } |
| 1018 | |
| 1019 | fn agents_explanation(locale: Locale) -> &'static str { |
| 1020 | match locale { |
| 1021 | Locale::ZhHans => { |
| 1022 | "\ |
| 1023 | AGENTS.md 与协作准则 |
| 1024 | |
| 1025 | 内置基础准则是精简的全局判断约定:身份、事实、验证、克制和优先级顺序。 |
| 1026 | |
| 1027 | 用户全局协作准则是个人长期偏好。它是结构化数据,确定性渲染,并低于当前用户请求和内置基础准则。 |
| 1028 | |
| 1029 | .codewhale/constitution.json 是仓库本地协作准则。它属于某个工作区,并作为独立的仓库协作准则块渲染。 |
| 1030 | |
| 1031 | AGENTS.md 和项目说明是项目规则/实现指导。它们可以描述构建命令、仓库规范和本地流程;按优先级顺序,它们低于当前用户请求和内置基础准则,高于用户全局长期偏好、记忆和交接。 |
| 1032 | |
| 1033 | WHALE.md 已忽略。将普通项目说明迁移到 AGENTS.md,将 Codewhale 专属权限策略迁移到 .codewhale/constitution.json。 |
| 1034 | |
| 1035 | 运行时姿态是独立设置。协作准则可以建议主动性,但不会改变批准策略、沙盒、Shell、网络、信任、MCP 权限或默认模式。使用 /constitution posture 查看这些控制。" |
| 1036 | } |
| 1037 | _ => { |
| 1038 | "\ |
| 1039 | AGENTS.md vs constitution |
| 1040 | |
| 1041 | The bundled Constitution is the compact global judgment contract: identity, ground truth, verification, restraint, and precedence. |
| 1042 | |
| 1043 | The user-global constitution is personal standing preference law. It is structured, rendered deterministically, and subordinate to the current user request and the bundled Constitution. |
| 1044 | |
| 1045 | .codewhale/constitution.json is repo-local law. It belongs to a workspace and is rendered as a separate repo constitution block. |
| 1046 | |
| 1047 | AGENTS.md and project instructions are project law / implementation guidance. They can describe build commands, repository norms, and local workflows. Under “Whose word wins,” they sit below the current user request and bundled Constitution, and above user-global standing preferences, memory, and handoff. |
| 1048 | |
| 1049 | WHALE.md is ignored. Move ordinary project instructions to AGENTS.md and Codewhale-specific authority policy to .codewhale/constitution.json. |
| 1050 | |
| 1051 | Runtime posture is separate. A constitution can recommend autonomy, but it does not change approval policy, sandbox, shell, network, trust, MCP permissions, or default mode. Use /constitution posture to review those controls." |
| 1052 | } |
| 1053 | } |
| 1054 | } |
| 1055 | |
| 1056 | struct ConstitutionManagerCopy { |
| 1057 | manager_header: &'static str, |
| 1058 | active_stack_header: &'static str, |
| 1059 | bundled_active: &'static str, |
| 1060 | user_global_label: &'static str, |
| 1061 | repo_local_label: &'static str, |
| 1062 | agents_label: &'static str, |
| 1063 | legacy_whale_label: &'static str, |
| 1064 | memory_handoff_label: &'static str, |
| 1065 | memory_label: &'static str, |
| 1066 | handoff_label: &'static str, |
| 1067 | user_global_header: &'static str, |
| 1068 | choice_label: &'static str, |
| 1069 | source_label: &'static str, |
| 1070 | file_label: &'static str, |
| 1071 | validity_label: &'static str, |
| 1072 | language_label: &'static str, |
| 1073 | last_preview_label: &'static str, |
| 1074 | runtime_posture_label: &'static str, |
| 1075 | checkpoint_label: &'static str, |
| 1076 | preview_header: &'static str, |
| 1077 | preview_action: &'static str, |
| 1078 | repo_action: &'static str, |
| 1079 | maintenance_header: &'static str, |
| 1080 | maintenance_actions: &'static [&'static str], |
| 1081 | present: &'static str, |
| 1082 | not_present: &'static str, |
| 1083 | generated_fallback: &'static str, |
| 1084 | whale_ignored: &'static str, |
| 1085 | enabled: &'static str, |
| 1086 | disabled: &'static str, |
| 1087 | not_recorded: &'static str, |
| 1088 | not_reviewed: &'static str, |
| 1089 | not_completed: &'static str, |
| 1090 | } |
| 1091 | |
| 1092 | impl ConstitutionManagerCopy { |
| 1093 | fn for_locale(locale: Locale) -> Self { |
| 1094 | match locale { |
| 1095 | Locale::ZhHans => Self { |
| 1096 | manager_header: "协作准则管理器", |
| 1097 | active_stack_header: "生效层级", |
| 1098 | bundled_active: "内置基础准则:始终生效", |
| 1099 | user_global_label: "用户全局协作准则", |
| 1100 | repo_local_label: "仓库本地协作准则", |
| 1101 | agents_label: "AGENTS/项目说明", |
| 1102 | legacy_whale_label: "旧版 WHALE.md", |
| 1103 | memory_handoff_label: "记忆/交接", |
| 1104 | memory_label: "记忆", |
| 1105 | handoff_label: "交接", |
| 1106 | user_global_header: "用户全局协作准则", |
| 1107 | choice_label: "选择", |
| 1108 | source_label: "来源", |
| 1109 | file_label: "文件", |
| 1110 | validity_label: "有效性", |
| 1111 | language_label: "语言", |
| 1112 | last_preview_label: "上次接受的预览", |
| 1113 | runtime_posture_label: "运行时姿态", |
| 1114 | checkpoint_label: "准则检查点代次", |
| 1115 | preview_header: "预览", |
| 1116 | preview_action: "/constitution preview 会在存在时打开精确渲染的用户全局块。", |
| 1117 | repo_action: "/constitution repo 会在存在时显示 .codewhale/constitution.json 本地准则。", |
| 1118 | maintenance_header: "维护", |
| 1119 | maintenance_actions: &[ |
| 1120 | "编辑引导式协作准则:/constitution edit", |
| 1121 | "预览渲染后的协作准则:/constitution preview", |
| 1122 | "查看下一轮的确切基础提示词:/constitution base", |
| 1123 | "审阅待批准的建议条款:/constitution suggestions", |
| 1124 | "批准某条建议条款:/constitution ratify <id>", |
| 1125 | "迁移到当前架构版本:/constitution migrate", |
| 1126 | "使用内置/默认:/constitution bundled", |
| 1127 | "查看现有内容:/constitution review", |
| 1128 | "修复无效/空/不可读文件:/constitution repair", |
| 1129 | "显示仓库本地准则:/constitution repo", |
| 1130 | "解释 AGENTS.md 与协作准则:/constitution explain", |
| 1131 | "打开运行时姿态:/constitution posture", |
| 1132 | ], |
| 1133 | present: "存在", |
| 1134 | not_present: "不存在", |
| 1135 | generated_fallback: "生成的后备内容", |
| 1136 | whale_ignored: "已忽略;需要迁移", |
| 1137 | enabled: "启用", |
| 1138 | disabled: "停用", |
| 1139 | not_recorded: "未记录", |
| 1140 | not_reviewed: "未查看", |
| 1141 | not_completed: "未完成", |
| 1142 | }, |
| 1143 | _ => Self { |
| 1144 | manager_header: "Constitution Manager", |
| 1145 | active_stack_header: "Active stack", |
| 1146 | bundled_active: "Bundled Constitution: active base law (always on)", |
| 1147 | user_global_label: "User-global constitution", |
| 1148 | repo_local_label: "Repo-local constitution", |
| 1149 | agents_label: "AGENTS/project instructions", |
| 1150 | legacy_whale_label: "Legacy WHALE.md", |
| 1151 | memory_handoff_label: "Memory/handoff", |
| 1152 | memory_label: "memory", |
| 1153 | handoff_label: "handoff", |
| 1154 | user_global_header: "User-global constitution", |
| 1155 | choice_label: "Choice", |
| 1156 | source_label: "Source", |
| 1157 | file_label: "File", |
| 1158 | validity_label: "Validity", |
| 1159 | language_label: "Language", |
| 1160 | last_preview_label: "Last accepted preview", |
| 1161 | runtime_posture_label: "Runtime posture", |
| 1162 | checkpoint_label: "Constitution checkpoint generation", |
| 1163 | preview_header: "Preview", |
| 1164 | preview_action: "/constitution preview opens the exact rendered user-global block when present.", |
| 1165 | repo_action: "/constitution repo shows .codewhale/constitution.json local law when present.", |
| 1166 | maintenance_header: "Maintenance", |
| 1167 | maintenance_actions: &[ |
| 1168 | "Edit guided constitution: /constitution edit", |
| 1169 | "Preview rendered constitution: /constitution preview", |
| 1170 | "Show the next turn's exact base prompt: /constitution base", |
| 1171 | "Review suggested clauses: /constitution suggestions", |
| 1172 | "Ratify a suggested clause: /constitution ratify <id>", |
| 1173 | "Migrate to the current schema: /constitution migrate", |
| 1174 | "Use bundled/default: /constitution bundled", |
| 1175 | "Review existing: /constitution review", |
| 1176 | "Repair invalid/empty/unreadable: /constitution repair", |
| 1177 | "Show repo-local law: /constitution repo", |
| 1178 | "Explain AGENTS.md vs constitution: /constitution explain", |
| 1179 | "Open runtime posture: /constitution posture", |
| 1180 | ], |
| 1181 | present: "present", |
| 1182 | not_present: "not present", |
| 1183 | generated_fallback: "generated fallback", |
| 1184 | whale_ignored: "ignored; migration needed", |
| 1185 | enabled: "enabled", |
| 1186 | disabled: "disabled", |
| 1187 | not_recorded: "not recorded", |
| 1188 | not_reviewed: "not reviewed", |
| 1189 | not_completed: "not completed", |
| 1190 | }, |
| 1191 | } |
| 1192 | } |
| 1193 | |
| 1194 | fn location_count_unit(&self, count: usize) -> &'static str { |
| 1195 | if self.manager_header == "协作准则管理器" { |
| 1196 | "处" |
| 1197 | } else if count == 1 { |
| 1198 | "location" |
| 1199 | } else { |
| 1200 | "locations" |
| 1201 | } |
| 1202 | } |
| 1203 | |
| 1204 | fn completed_for(&self, version: &str) -> String { |
| 1205 | if self.manager_header == "协作准则管理器" { |
| 1206 | format!("当前(自 {version} 引入)") |
| 1207 | } else { |
| 1208 | format!("current (introduced in {version})") |
| 1209 | } |
| 1210 | } |
| 1211 | } |
| 1212 | |
| 1213 | #[cfg(test)] |
| 1214 | mod tests { |
| 1215 | use super::*; |
| 1216 | use crate::config::Config; |
| 1217 | use crate::tui::app::TuiOptions; |
| 1218 | use crate::tui::pager::PagerView; |
| 1219 | use crate::tui::views::ModalKind; |
| 1220 | use std::path::PathBuf; |
| 1221 | use tempfile::tempdir; |
| 1222 | |
| 1223 | fn test_app() -> App { |
| 1224 | test_app_with_workspace(PathBuf::from(".")) |
| 1225 | } |
| 1226 | |
| 1227 | fn test_app_with_workspace(workspace: PathBuf) -> App { |
| 1228 | let options = TuiOptions { |
| 1229 | ..crate::test_support::test_tui_options(workspace) |
| 1230 | }; |
| 1231 | App::new(options, &Config::default()) |
| 1232 | } |
| 1233 | |
| 1234 | fn pop_pager_body(app: &mut App) -> String { |
| 1235 | let mut view = app.view_stack.pop().expect("pager view"); |
| 1236 | let pager = view |
| 1237 | .as_any_mut() |
| 1238 | .downcast_mut::<PagerView>() |
| 1239 | .expect("top view should be pager"); |
| 1240 | pager.body_text() |
| 1241 | } |
| 1242 | |
| 1243 | #[test] |
| 1244 | fn constitution_default_opens_manager_pager() { |
| 1245 | let mut app = test_app(); |
| 1246 | app.ui_locale = Locale::En; |
| 1247 | |
| 1248 | let result = ConstitutionCmd::execute(&mut app, None); |
| 1249 | |
| 1250 | assert!(result.message.is_none()); |
| 1251 | assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager)); |
| 1252 | assert!(pop_pager_body(&mut app).contains("Constitution Manager")); |
| 1253 | } |
| 1254 | |
| 1255 | #[test] |
| 1256 | fn constitution_manager_marks_whale_md_ignored() { |
| 1257 | let tmp = tempdir().expect("tempdir"); |
| 1258 | std::fs::write(tmp.path().join("WHALE.md"), "legacy instructions").expect("write whale"); |
| 1259 | let mut app = test_app_with_workspace(tmp.path().to_path_buf()); |
| 1260 | app.ui_locale = Locale::En; |
| 1261 | |
| 1262 | let result = ConstitutionCmd::execute(&mut app, None); |
| 1263 | |
| 1264 | assert!(result.message.is_none()); |
| 1265 | let body = pop_pager_body(&mut app); |
| 1266 | assert!(body.contains("Legacy WHALE.md: ignored")); |
| 1267 | assert!(body.contains("WHALE.md is ignored")); |
| 1268 | assert!(!body.contains("legacy instructions")); |
| 1269 | } |
| 1270 | |
| 1271 | #[test] |
| 1272 | fn constitution_bundled_emits_action() { |
| 1273 | let mut app = test_app(); |
| 1274 | |
| 1275 | let result = ConstitutionCmd::execute(&mut app, Some("bundled")); |
| 1276 | |
| 1277 | assert_eq!(result.action, Some(AppAction::UseBundledConstitution)); |
| 1278 | } |
| 1279 | |
| 1280 | #[test] |
| 1281 | fn constitution_edit_opens_setup_at_constitution() { |
| 1282 | let mut app = test_app(); |
| 1283 | |
| 1284 | let result = ConstitutionCmd::execute(&mut app, Some("edit")); |
| 1285 | |
| 1286 | assert_eq!( |
| 1287 | result.action, |
| 1288 | Some(AppAction::OpenSetupWizardAt { |
| 1289 | step: SetupStep::Constitution |
| 1290 | }) |
| 1291 | ); |
| 1292 | } |
| 1293 | |
| 1294 | #[test] |
| 1295 | fn constitution_help_lists_repair_and_runtime_boundary() { |
| 1296 | let mut app = test_app(); |
| 1297 | app.ui_locale = Locale::En; |
| 1298 | |
| 1299 | let result = ConstitutionCmd::execute(&mut app, Some("help")); |
| 1300 | |
| 1301 | let message = result.message.expect("help message"); |
| 1302 | assert!(message.contains("Usage: /constitution")); |
| 1303 | assert!(message.contains("/constitution repair")); |
| 1304 | assert!(message.contains("/constitution posture")); |
| 1305 | assert!(message.contains("model guidance only")); |
| 1306 | assert!(message.contains("does not change approvals")); |
| 1307 | } |
| 1308 | |
| 1309 | #[test] |
| 1310 | fn constitution_repair_explains_invalid_file_and_opens_setup() { |
| 1311 | let _env_guard = crate::test_support::lock_test_env(); |
| 1312 | let tmp = tempdir().expect("tempdir"); |
| 1313 | let home = tmp.path().join("codewhale-home"); |
| 1314 | std::fs::create_dir_all(&home).expect("home"); |
| 1315 | std::fs::write(home.join("constitution.json"), "{not valid json").expect("invalid file"); |
| 1316 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 1317 | let mut app = test_app(); |
| 1318 | app.ui_locale = Locale::En; |
| 1319 | |
| 1320 | let result = ConstitutionCmd::execute(&mut app, Some("repair")); |
| 1321 | |
| 1322 | assert_eq!( |
| 1323 | result.action, |
| 1324 | Some(AppAction::OpenSetupWizardAt { |
| 1325 | step: SetupStep::Constitution |
| 1326 | }) |
| 1327 | ); |
| 1328 | let message = result.message.expect("repair message"); |
| 1329 | assert!(message.contains("User-global constitution repair")); |
| 1330 | assert!(message.contains("Current state: invalid; repair recommended")); |
| 1331 | assert!(message.contains("Validity: invalid")); |
| 1332 | assert!(message.contains("constitution.json")); |
| 1333 | assert!(message.contains("will not be injected")); |
| 1334 | assert!(message.contains("Runtime approval")); |
| 1335 | } |
| 1336 | |
| 1337 | #[test] |
| 1338 | fn constitution_preview_renders_structured_block() { |
| 1339 | let _env_guard = crate::test_support::lock_test_env(); |
| 1340 | let tmp = tempdir().expect("tempdir"); |
| 1341 | let home = tmp.path().join("codewhale-home"); |
| 1342 | std::fs::create_dir_all(&home).expect("home"); |
| 1343 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 1344 | let constitution = UserConstitution { |
| 1345 | about: Some("Maintains release lanes.".to_string()), |
| 1346 | ..UserConstitution::default() |
| 1347 | }; |
| 1348 | constitution.save().expect("save constitution"); |
| 1349 | let mut app = test_app(); |
| 1350 | |
| 1351 | let result = ConstitutionCmd::execute(&mut app, Some("preview")); |
| 1352 | |
| 1353 | assert!(result.message.is_none()); |
| 1354 | let body = pop_pager_body(&mut app); |
| 1355 | assert!(body.contains("<codewhale_user_constitution")); |
| 1356 | assert!(body.contains("Maintains release lanes.")); |
| 1357 | } |
| 1358 | |
| 1359 | /// Seal `CODEWHALE_HOME` to a temp dir and write a constitution there. |
| 1360 | fn sealed_home( |
| 1361 | tmp: &tempfile::TempDir, |
| 1362 | constitution: &UserConstitution, |
| 1363 | ) -> crate::test_support::EnvVarGuard { |
| 1364 | let home = tmp.path().join("codewhale-home"); |
| 1365 | std::fs::create_dir_all(&home).expect("home"); |
| 1366 | let guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 1367 | constitution |
| 1368 | .save_to(&home.join("constitution.json")) |
| 1369 | .expect("save constitution"); |
| 1370 | guard |
| 1371 | } |
| 1372 | |
| 1373 | fn with_suggestion() -> UserConstitution { |
| 1374 | UserConstitution { |
| 1375 | about: Some("Maintains release lanes.".to_string()), |
| 1376 | ..UserConstitution::default() |
| 1377 | } |
| 1378 | .with_recommendation(&codewhale_config::ConstitutionRecommendation { |
| 1379 | clauses: vec![codewhale_config::ConstitutionClause::suggested( |
| 1380 | "c1", |
| 1381 | "Always run the focused suite before claiming green.", |
| 1382 | )], |
| 1383 | rationale: Vec::new(), |
| 1384 | }) |
| 1385 | } |
| 1386 | |
| 1387 | #[test] |
| 1388 | fn constitution_base_previews_the_exact_next_turn_prompt() { |
| 1389 | let mut app = test_app(); |
| 1390 | |
| 1391 | let result = ConstitutionCmd::execute(&mut app, Some("base")); |
| 1392 | |
| 1393 | // Routed as an action so the preview is assembled by the same function |
| 1394 | // the dispatch path uses, with the session config in hand. |
| 1395 | assert_eq!(result.action, Some(AppAction::PreviewEffectiveBasePrompt)); |
| 1396 | assert!(result.message.is_none()); |
| 1397 | } |
| 1398 | |
| 1399 | #[test] |
| 1400 | fn suggestions_are_listed_but_never_previewed_as_law() { |
| 1401 | let _env_guard = crate::test_support::lock_test_env(); |
| 1402 | let tmp = tempdir().expect("tempdir"); |
| 1403 | let _home = sealed_home(&tmp, &with_suggestion()); |
| 1404 | let mut app = test_app(); |
| 1405 | app.ui_locale = Locale::En; |
| 1406 | |
| 1407 | ConstitutionCmd::execute(&mut app, Some("suggestions")); |
| 1408 | let suggestions = pop_pager_body(&mut app); |
| 1409 | assert!(suggestions.contains("[c1]")); |
| 1410 | assert!(suggestions.contains("focused suite")); |
| 1411 | assert!(suggestions.contains("Ratified clauses in force: 0")); |
| 1412 | |
| 1413 | // The same clause must be absent from the injected preview. |
| 1414 | ConstitutionCmd::execute(&mut app, Some("preview")); |
| 1415 | let preview = pop_pager_body(&mut app); |
| 1416 | assert!(preview.contains("<codewhale_user_constitution")); |
| 1417 | assert!( |
| 1418 | !preview.contains("focused suite"), |
| 1419 | "unratified advice reached the model-facing block: {preview}" |
| 1420 | ); |
| 1421 | } |
| 1422 | |
| 1423 | #[test] |
| 1424 | fn ratify_requires_an_id_and_then_makes_the_clause_law() { |
| 1425 | let _env_guard = crate::test_support::lock_test_env(); |
| 1426 | let tmp = tempdir().expect("tempdir"); |
| 1427 | let _home = sealed_home(&tmp, &with_suggestion()); |
| 1428 | let mut app = test_app(); |
| 1429 | app.ui_locale = Locale::En; |
| 1430 | |
| 1431 | let bare = ConstitutionCmd::execute(&mut app, Some("ratify")) |
| 1432 | .message |
| 1433 | .expect("usage message"); |
| 1434 | assert!(bare.contains("Usage: /constitution ratify <id>")); |
| 1435 | |
| 1436 | let unknown = ConstitutionCmd::execute(&mut app, Some("ratify nope")) |
| 1437 | .message |
| 1438 | .expect("refusal message"); |
| 1439 | assert!(unknown.contains("Nothing was ratified")); |
| 1440 | |
| 1441 | let ok = ConstitutionCmd::execute(&mut app, Some("ratify c1")) |
| 1442 | .message |
| 1443 | .expect("ratified message"); |
| 1444 | assert!(ok.contains("Ratified 1 clause(s): c1"), "{ok}"); |
| 1445 | assert!(ok.contains("Digest"), "{ok}"); |
| 1446 | |
| 1447 | // Now — and only now — it appears in the injected block. |
| 1448 | ConstitutionCmd::execute(&mut app, Some("preview")); |
| 1449 | assert!(pop_pager_body(&mut app).contains("focused suite")); |
| 1450 | } |
| 1451 | |
| 1452 | #[test] |
| 1453 | fn migrate_reports_a_receipt_and_rollback_restores() { |
| 1454 | let _env_guard = crate::test_support::lock_test_env(); |
| 1455 | let tmp = tempdir().expect("tempdir"); |
| 1456 | let home = tmp.path().join("codewhale-home"); |
| 1457 | std::fs::create_dir_all(&home).expect("home"); |
| 1458 | let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 1459 | let path = home.join("constitution.json"); |
| 1460 | let v1 = r#"{"schema_version":1,"about":"Legacy user."}"#; |
| 1461 | std::fs::write(&path, v1).expect("write v1"); |
| 1462 | let mut app = test_app(); |
| 1463 | app.ui_locale = Locale::En; |
| 1464 | |
| 1465 | let message = ConstitutionCmd::execute(&mut app, Some("migrate")) |
| 1466 | .message |
| 1467 | .expect("migrate receipt"); |
| 1468 | assert!(message.contains("Migrated schema v1"), "{message}"); |
| 1469 | assert!( |
| 1470 | message.contains("Preserved unknown fields: none"), |
| 1471 | "{message}" |
| 1472 | ); |
| 1473 | assert!( |
| 1474 | message.contains("the prompt cache survives"), |
| 1475 | "a v1 file carries no clauses, so no model-facing byte moved: {message}" |
| 1476 | ); |
| 1477 | |
| 1478 | let rolled = ConstitutionCmd::execute(&mut app, Some("rollback")) |
| 1479 | .message |
| 1480 | .expect("rollback receipt"); |
| 1481 | assert!(rolled.contains("Restored the pre-migration constitution")); |
| 1482 | assert_eq!(std::fs::read_to_string(&path).expect("read back"), v1); |
| 1483 | } |
| 1484 | |
| 1485 | #[test] |
| 1486 | fn migrate_rejects_a_runtime_policy_key_and_changes_nothing() { |
| 1487 | let _env_guard = crate::test_support::lock_test_env(); |
| 1488 | let tmp = tempdir().expect("tempdir"); |
| 1489 | let home = tmp.path().join("codewhale-home"); |
| 1490 | std::fs::create_dir_all(&home).expect("home"); |
| 1491 | let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 1492 | let path = home.join("constitution.json"); |
| 1493 | let raw = r#"{"about":"x","approval_policy":"bypass"}"#; |
| 1494 | std::fs::write(&path, raw).expect("write file"); |
| 1495 | let mut app = test_app(); |
| 1496 | app.ui_locale = Locale::En; |
| 1497 | |
| 1498 | let message = ConstitutionCmd::execute(&mut app, Some("migrate")) |
| 1499 | .message |
| 1500 | .expect("rejection receipt"); |
| 1501 | assert!(message.contains("Not migrated"), "{message}"); |
| 1502 | assert!(message.contains("approval_policy"), "{message}"); |
| 1503 | assert_eq!(std::fs::read_to_string(&path).expect("read back"), raw); |
| 1504 | } |
| 1505 | |
| 1506 | #[test] |
| 1507 | fn help_and_manager_name_the_new_inspection_surfaces() { |
| 1508 | let mut app = test_app(); |
| 1509 | app.ui_locale = Locale::En; |
| 1510 | |
| 1511 | let help = ConstitutionCmd::execute(&mut app, Some("help")) |
| 1512 | .message |
| 1513 | .expect("help message"); |
| 1514 | assert!(help.contains("/constitution base")); |
| 1515 | assert!(help.contains("no provider request")); |
| 1516 | assert!(help.contains("/constitution ratify <id>")); |
| 1517 | assert!(help.contains("Refused if the base changed")); |
| 1518 | assert!(help.contains("/constitution migrate")); |
| 1519 | |
| 1520 | ConstitutionCmd::execute(&mut app, None); |
| 1521 | let body = pop_pager_body(&mut app); |
| 1522 | assert!(body.contains("/constitution base")); |
| 1523 | assert!(body.contains("/constitution suggestions")); |
| 1524 | } |
| 1525 | |
| 1526 | #[test] |
| 1527 | fn constitution_manager_uses_zh_hans_copy() { |
| 1528 | let _env_guard = crate::test_support::lock_test_env(); |
| 1529 | let tmp = tempdir().expect("tempdir"); |
| 1530 | let home = tmp.path().join("codewhale-home"); |
| 1531 | std::fs::create_dir_all(&home).expect("home"); |
| 1532 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 1533 | let mut app = test_app(); |
| 1534 | app.ui_locale = crate::localization::Locale::ZhHans; |
| 1535 | |
| 1536 | let result = ConstitutionCmd::execute(&mut app, None); |
| 1537 | |
| 1538 | assert!(result.message.is_none()); |
| 1539 | let body = pop_pager_body(&mut app); |
| 1540 | assert!(body.contains("协作准则管理器")); |
| 1541 | assert!(body.contains("生效层级")); |
| 1542 | assert!(body.contains("用户全局协作准则")); |
| 1543 | assert!(body.contains("/constitution preview 会")); |
| 1544 | assert!(!body.contains("宪法")); |
| 1545 | assert!(!body.contains("Constitution Manager")); |
| 1546 | } |
| 1547 | |
| 1548 | #[test] |
| 1549 | fn constitution_preview_missing_uses_zh_hans_copy() { |
| 1550 | let _env_guard = crate::test_support::lock_test_env(); |
| 1551 | let tmp = tempdir().expect("tempdir"); |
| 1552 | let home = tmp.path().join("codewhale-home"); |
| 1553 | std::fs::create_dir_all(&home).expect("home"); |
| 1554 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 1555 | let mut app = test_app(); |
| 1556 | app.ui_locale = crate::localization::Locale::ZhHans; |
| 1557 | |
| 1558 | let result = ConstitutionCmd::execute(&mut app, Some("preview")); |
| 1559 | |
| 1560 | assert!(result.message.is_none()); |
| 1561 | let body = pop_pager_body(&mut app); |
| 1562 | assert!(body.contains("未在")); |
| 1563 | assert!(body.contains("当前使用内置准则")); |
| 1564 | assert!(body.contains("/constitution edit")); |
| 1565 | assert!(!body.contains("宪法")); |
| 1566 | assert!(!body.contains("No structured user-global constitution")); |
| 1567 | } |
| 1568 | |
| 1569 | #[test] |
| 1570 | fn constitution_explanation_uses_zh_hans_copy() { |
| 1571 | let mut app = test_app(); |
| 1572 | app.ui_locale = crate::localization::Locale::ZhHans; |
| 1573 | |
| 1574 | let result = ConstitutionCmd::execute(&mut app, Some("explain")); |
| 1575 | |
| 1576 | assert!(result.message.is_none()); |
| 1577 | let body = pop_pager_body(&mut app); |
| 1578 | assert!(body.contains("AGENTS.md 与协作准则")); |
| 1579 | assert!(body.contains(".codewhale/constitution.json")); |
| 1580 | assert!(body.contains("运行时姿态是独立设置")); |
| 1581 | assert!(!body.contains("宪法")); |
| 1582 | assert!(!body.contains("项目法律")); |
| 1583 | assert!(!body.contains("Runtime posture is separate")); |
| 1584 | } |
| 1585 | } |
| 1586 |