返回 CodeWhale
constitution.rs
根目录 / crates / tui / src / commands / groups / core / constitution.rs
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
1586 lines RUST