1use std::io::Read;
4use std::path::Path;
5use std::time::Instant;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum SupervisorVerdict {
13 Pass,
15 Warn,
17 Block,
19}
20
21impl std::fmt::Display for SupervisorVerdict {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 Self::Pass => write!(f, "pass"),
25 Self::Warn => write!(f, "warn"),
26 Self::Block => write!(f, "block"),
27 }
28 }
29}
30
31#[allow(clippy::derivable_impls)]
32impl Default for SupervisorVerdict {
33 fn default() -> Self {
34 Self::Warn
35 }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SupervisorReview {
42 pub verdict: SupervisorVerdict,
44 pub scope_ok: bool,
46 pub findings: Vec<String>,
48 pub summary: String,
50 pub agent: String,
52 pub duration_secs: f32,
54}
55
56#[derive(Debug, Clone)]
58pub struct SupervisorRunConfig {
59 pub enabled: bool,
61 pub agent: String,
63 pub verdict_on_block: String,
65 pub constitution_path: Option<std::path::PathBuf>,
67 pub skip_if_no_constitution: bool,
69 pub heartbeat_stale_secs: u64,
75 pub timeout_secs: u64,
78 pub api_key_env: Option<String>,
82 pub staging_path: Option<std::path::PathBuf>,
84 pub heartbeat_path: Option<std::path::PathBuf>,
87 pub agent_profile: Option<String>,
91 pub resolved_model: Option<String>,
93 pub enable_hooks: bool,
99}
100
101#[derive(Deserialize, Debug)]
103struct LlmSupervisorResponse {
104 verdict: Option<String>,
105 scope_ok: Option<bool>,
106 findings: Option<Vec<String>>,
107 summary: Option<String>,
108}
109
110pub fn invoke_supervisor_agent(
121 objective: &str,
122 changed_files: &[String],
123 constitution_text: Option<&str>,
124 config: &SupervisorRunConfig,
125) -> SupervisorReview {
126 let started = Instant::now();
127 let prompt = build_supervisor_prompt(objective, changed_files, constitution_text);
128
129 if let Some(ref env_var) = config.api_key_env {
131 if std::env::var(env_var).is_err() {
132 let msg = format!(
133 "Supervisor agent '{}' requires {} — set it or change [supervisor] agent in workflow.toml.",
134 config.agent, env_var
135 );
136 tracing::warn!("{}", msg);
137 return fallback_supervisor_review(&config.agent, &msg, 0.0);
138 }
139 }
140
141 let result = match config.agent.as_str() {
142 "builtin" | "claude-code" => invoke_claude_cli_supervisor(&prompt, config),
143 "codex" => invoke_codex_supervisor(&prompt, config),
144 "ollama" => invoke_ollama_supervisor(&prompt, config),
145 other => {
146 if let Some(ref staging) = config.staging_path {
147 run_manifest_supervisor(staging, other, objective, changed_files, config, started)
148 } else {
149 Err(anyhow::anyhow!(
150 "Custom agent '{}' requires staging_path to be set in SupervisorRunConfig",
151 other
152 ))
153 }
154 }
155 };
156
157 let duration_secs = started.elapsed().as_secs_f32();
158
159 match result {
160 Ok(mut review) => {
161 review.duration_secs = duration_secs;
162 review
163 }
164 Err(e) => {
165 tracing::warn!(
166 error = %e,
167 agent = %config.agent,
168 "Supervisor agent failed — falling back to warn verdict"
169 );
170 fallback_supervisor_review(&config.agent, &e.to_string(), duration_secs)
171 }
172 }
173}
174
175fn invoke_claude_cli_supervisor(
181 prompt: &str,
182 config: &SupervisorRunConfig,
183) -> anyhow::Result<SupervisorReview> {
184 let staging = config.staging_path.as_deref();
185
186 let mut args_owned: Vec<String> = vec![
189 "--print".into(),
190 "--verbose".into(),
191 "--output-format".into(),
192 "stream-json".into(),
193 "--allowedTools".into(),
194 "Read(*),Grep(*),Glob(*)".into(),
195 ];
196
197 if let Some(ref model) = config.resolved_model {
198 args_owned.push("--model".into());
199 args_owned.push(model.clone());
200 }
201
202 let args_refs: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
204
205 let disable_hooks_env: &[(&str, &str)] = if config.enable_hooks {
206 &[]
207 } else {
208 &[("CLAUDE_CODE_DISABLE_HOOKS", "1")]
209 };
210
211 let stdout = spawn_with_heartbeat_monitor(
212 "claude",
213 &args_refs,
214 config.heartbeat_stale_secs,
215 config.heartbeat_path.as_deref(),
216 "Claude Code CLI",
217 staging,
218 disable_hooks_env,
219 Some(prompt),
220 )?;
221
222 let text = extract_claude_stream_json_text(&stdout);
223 let mut review = parse_supervisor_response_or_text(&text, "claude-code");
224 apply_hedging_quality_gate(&mut review);
225 Ok(review)
226}
227
228fn invoke_codex_supervisor(
233 prompt: &str,
234 config: &SupervisorRunConfig,
235) -> anyhow::Result<SupervisorReview> {
236 let staging = config.staging_path.as_deref();
237 let disable_hooks_env: &[(&str, &str)] = if config.enable_hooks {
238 &[]
239 } else {
240 &[("CLAUDE_CODE_DISABLE_HOOKS", "1")]
241 };
242 let stdout = spawn_with_heartbeat_monitor(
243 "codex",
244 &["--approval-mode", "full-auto", "--quiet", prompt],
245 config.heartbeat_stale_secs,
246 config.heartbeat_path.as_deref(),
247 "Codex CLI",
248 staging,
249 disable_hooks_env,
250 None,
251 )?;
252
253 let mut review = parse_supervisor_response_or_text(&stdout, "codex");
254 apply_hedging_quality_gate(&mut review);
255 Ok(review)
256}
257
258fn invoke_ollama_supervisor(
260 prompt: &str,
261 config: &SupervisorRunConfig,
262) -> anyhow::Result<SupervisorReview> {
263 let staging = config.staging_path.as_deref();
264 let disable_hooks_env: &[(&str, &str)] = if config.enable_hooks {
265 &[]
266 } else {
267 &[("CLAUDE_CODE_DISABLE_HOOKS", "1")]
268 };
269 let stdout = spawn_with_heartbeat_monitor(
270 "ta",
271 &[
272 "agent",
273 "run",
274 "ollama",
275 "--headless",
276 "--tools",
277 "read,grep,glob",
278 "--prompt",
279 prompt,
280 ],
281 config.heartbeat_stale_secs,
282 config.heartbeat_path.as_deref(),
283 "ta-agent-ollama",
284 staging,
285 disable_hooks_env,
286 None,
287 )?;
288
289 let mut review = parse_supervisor_response_or_text(&stdout, "ollama");
290 apply_hedging_quality_gate(&mut review);
291 Ok(review)
292}
293
294fn is_hook_json_line(line: &str) -> bool {
301 let trimmed = line.trim();
302 if !trimmed.starts_with('{') || !trimmed.contains("\"type\"") {
304 return false;
305 }
306 if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
307 val.get("type").and_then(|t| t.as_str()) == Some("system")
308 } else {
309 false
310 }
311}
312
313#[allow(clippy::too_many_arguments)]
331fn spawn_with_heartbeat_monitor(
332 program: &str,
333 args: &[&str],
334 stale_secs: u64,
335 heartbeat_path: Option<&std::path::Path>,
336 label: &str,
337 current_dir: Option<&std::path::Path>,
338 extra_env: &[(&str, &str)],
339 stdin_input: Option<&str>,
340) -> anyhow::Result<String> {
341 use std::io::BufRead;
342 use std::sync::mpsc;
343
344 let mut cmd = std::process::Command::new(program);
345 cmd.args(args);
346 if let Some(dir) = current_dir {
347 cmd.current_dir(dir);
348 }
349 for (k, v) in extra_env {
350 cmd.env(k, v);
351 }
352 let stdin_stdio = if stdin_input.is_some() {
353 std::process::Stdio::piped()
354 } else {
355 std::process::Stdio::null()
356 };
357 let mut child = cmd
358 .stdin(stdin_stdio)
359 .stdout(std::process::Stdio::piped())
360 .stderr(std::process::Stdio::piped())
361 .spawn()
362 .map_err(|e| {
363 anyhow::anyhow!(
364 "Failed to spawn '{}': {} — is {} installed and on PATH?",
365 program,
366 e,
367 label
368 )
369 })?;
370
371 if let Some(hb) = heartbeat_path {
373 let _ = std::fs::write(hb, b"");
374 }
375
376 if let Some(input) = stdin_input {
379 if let Some(mut stdin_pipe) = child.stdin.take() {
380 let input_owned = input.to_string();
381 std::thread::spawn(move || {
382 use std::io::Write;
383 let _ = stdin_pipe.write_all(input_owned.as_bytes());
384 });
386 }
387 }
388
389 let (line_tx, line_rx) = mpsc::channel::<Option<String>>();
392 let stdout = child.stdout.take();
393 let reader_handle = std::thread::spawn(move || {
394 if let Some(stdout) = stdout {
395 let reader = std::io::BufReader::new(stdout);
396 for line in reader.lines() {
397 match line {
398 Ok(l) => {
399 if line_tx.send(Some(l)).is_err() {
400 break;
401 }
402 }
403 Err(_) => break,
404 }
405 }
406 }
407 let _ = line_tx.send(None);
408 });
409
410 let poll_interval = std::time::Duration::from_millis(100);
412 let stale_duration = std::time::Duration::from_secs(stale_secs);
413 let mut stdout_str = String::new();
414 let mut partial_output = String::new();
415 let mut last_token = std::time::Instant::now();
416 let mut eof = false;
417
418 while !eof {
419 match line_rx.recv_timeout(poll_interval) {
420 Ok(Some(line)) => {
421 if is_hook_json_line(&line) {
427 continue;
428 }
429 last_token = std::time::Instant::now();
430 stdout_str.push_str(&line);
431 stdout_str.push('\n');
432 if partial_output.len() < 200 {
434 partial_output.push_str(&line);
435 partial_output.push('\n');
436 }
437 if let Some(hb) = heartbeat_path {
439 let _ = std::fs::write(hb, b"");
440 }
441 }
442 Ok(None) => {
443 eof = true; }
445 Err(mpsc::RecvTimeoutError::Timeout) => {
446 if last_token.elapsed() >= stale_duration {
448 let _ = child.kill();
449 let _ = reader_handle.join();
450 if let Some(hb) = heartbeat_path {
451 let _ = std::fs::remove_file(hb);
452 }
453 let partial = partial_output.trim().to_string();
454 anyhow::bail!(
455 "Supervisor stalled — no tokens received for {}s. Findings so far: {}",
456 stale_secs,
457 partial
458 );
459 }
460 }
461 Err(mpsc::RecvTimeoutError::Disconnected) => {
462 eof = true;
463 }
464 }
465 }
466
467 let _ = reader_handle.join();
468
469 let status = child.wait()?;
471
472 if let Some(hb) = heartbeat_path {
474 let _ = std::fs::remove_file(hb);
475 }
476
477 if !status.success() && stdout_str.trim().is_empty() {
478 let mut stderr = String::new();
479 if let Some(mut err) = child.stderr.take() {
480 let _ = err.read_to_string(&mut stderr);
481 }
482 anyhow::bail!(
483 "{} exited with status {}: {}",
484 label,
485 status,
486 &stderr[..stderr.len().min(200)]
487 );
488 }
489
490 Ok(stdout_str)
491}
492
493fn extract_claude_stream_json_text(stdout: &str) -> String {
499 for line in stdout.lines().rev() {
501 let line = line.trim();
502 if line.is_empty() {
503 continue;
504 }
505 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
506 continue;
507 };
508 if val.get("type").and_then(|t| t.as_str()) == Some("result") {
509 if let Some(text) = val.get("result").and_then(|r| r.as_str()) {
511 if !text.trim().is_empty() {
512 return text.to_string();
513 }
514 }
515 if let Some(content) = val.get("content") {
517 let text = extract_content_text(content);
518 if !text.is_empty() {
519 return text;
520 }
521 }
522 }
523 }
524
525 for line in stdout.lines().rev() {
527 let line = line.trim();
528 if line.is_empty() {
529 continue;
530 }
531 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
532 continue;
533 };
534 if val.get("type").and_then(|t| t.as_str()) == Some("assistant") {
535 if let Some(content) = val.get("message").and_then(|m| m.get("content")) {
536 let text = extract_content_text(content);
537 if !text.is_empty() {
538 return text;
539 }
540 }
541 }
542 }
543
544 stdout.to_string()
546}
547
548fn extract_content_text(content: &serde_json::Value) -> String {
550 if let Some(arr) = content.as_array() {
551 arr.iter()
552 .filter_map(|item| {
553 if item.get("type").and_then(|t| t.as_str()) == Some("text") {
554 item.get("text")
555 .and_then(|t| t.as_str())
556 .map(|s| s.to_string())
557 } else {
558 None
559 }
560 })
561 .collect::<Vec<_>>()
562 .join("")
563 } else {
564 content.as_str().unwrap_or("").to_string()
565 }
566}
567
568fn parse_supervisor_response_or_text(text: &str, agent: &str) -> SupervisorReview {
574 if let Ok(review) = parse_supervisor_response(text) {
575 return SupervisorReview {
576 agent: agent.to_string(),
577 ..review
578 };
579 }
580 let summary = if text.len() > 300 {
582 format!("{}…", &text[..300])
583 } else if text.trim().is_empty() {
584 format!("Supervisor agent '{}' returned empty response.", agent)
585 } else {
586 text.trim().to_string()
587 };
588 SupervisorReview {
589 verdict: SupervisorVerdict::Warn,
590 scope_ok: true,
591 findings: vec![],
592 summary,
593 agent: agent.to_string(),
594 duration_secs: 0.0,
595 }
596}
597
598fn run_manifest_supervisor(
604 staging_path: &Path,
605 agent_name: &str,
606 objective: &str,
607 changed_files: &[String],
608 config: &SupervisorRunConfig,
609 started: Instant,
610) -> anyhow::Result<SupervisorReview> {
611 let input = serde_json::json!({
613 "objective": objective,
614 "changed_files": changed_files,
615 "instruction": "Read the changed files using your available tools before forming each finding. \
616 Cite file:line in every finding that references code. \
617 Never write 'cannot be verified without viewing files' — view the files first.",
618 });
619 let input_path = staging_path.join(".ta/supervisor_input.json");
620 if let Err(e) = std::fs::write(
621 &input_path,
622 serde_json::to_string_pretty(&input).unwrap_or_default(),
623 ) {
624 tracing::warn!(error = %e, "Failed to write supervisor input file");
625 }
626
627 let agent_manifest = staging_path
629 .join(".ta/agents")
630 .join(format!("{}.toml", agent_name));
631 if !agent_manifest.exists() {
632 anyhow::bail!(
633 "Custom supervisor agent '{}' manifest not found at .ta/agents/{}.toml",
634 agent_name,
635 agent_name
636 );
637 }
638
639 let result_path = staging_path.join(".ta/supervisor_result.json");
641 let _ = std::fs::remove_file(&result_path);
642
643 let manifest_content = std::fs::read_to_string(&agent_manifest)
645 .map_err(|e| anyhow::anyhow!("Failed to read agent manifest: {}", e))?;
646 let manifest: toml::Value = toml::from_str(&manifest_content)
647 .map_err(|e| anyhow::anyhow!("Failed to parse agent manifest: {}", e))?;
648 let cmd_str = manifest
649 .get("agent")
650 .and_then(|a| a.get("command"))
651 .and_then(|c| c.as_str())
652 .unwrap_or("");
653 if cmd_str.is_empty() {
654 anyhow::bail!(
655 "Agent manifest '{}' missing [agent] command field",
656 agent_name
657 );
658 }
659
660 let parts: Vec<&str> = cmd_str.split_whitespace().collect();
661 let mut spawn_cmd = std::process::Command::new(parts[0]);
662 spawn_cmd
663 .args(&parts[1..])
664 .current_dir(staging_path)
665 .env("TA_SUPERVISOR_INPUT", input_path.to_str().unwrap_or(""))
666 .env("TA_SUPERVISOR_OUTPUT", result_path.to_str().unwrap_or(""));
667 if !config.enable_hooks {
668 spawn_cmd.env("CLAUDE_CODE_DISABLE_HOOKS", "1");
669 }
670 let mut child = spawn_cmd
671 .spawn()
672 .map_err(|e| anyhow::anyhow!("Failed to spawn custom agent '{}': {}", agent_name, e))?;
673
674 if let Some(ref hb) = config.heartbeat_path {
676 let _ = std::fs::write(hb, b"");
677 }
678
679 let stale_secs = config.heartbeat_stale_secs;
682 let mut last_result_size: u64 = 0;
683 let mut last_progress = std::time::Instant::now();
684 loop {
685 match child.try_wait() {
686 Ok(Some(_)) => break,
687 Ok(None) => {
688 let current_size = std::fs::metadata(&result_path)
690 .map(|m| m.len())
691 .unwrap_or(0);
692 if current_size > last_result_size {
693 last_result_size = current_size;
694 last_progress = std::time::Instant::now();
695 if let Some(ref hb) = config.heartbeat_path {
696 let _ = std::fs::write(hb, b"");
697 }
698 }
699 if last_progress.elapsed().as_secs() >= stale_secs {
700 let _ = child.kill();
701 if let Some(ref hb) = config.heartbeat_path {
703 let _ = std::fs::remove_file(hb);
704 }
705 anyhow::bail!(
706 "Custom agent '{}' stalled — no progress for {}s",
707 agent_name,
708 stale_secs
709 );
710 }
711 std::thread::sleep(std::time::Duration::from_millis(500));
712 }
713 Err(e) => {
714 anyhow::bail!("Error waiting for custom agent '{}': {}", agent_name, e);
715 }
716 }
717 }
718
719 if let Some(ref hb) = config.heartbeat_path {
721 let _ = std::fs::remove_file(hb);
722 }
723
724 let content = std::fs::read_to_string(&result_path).map_err(|e| {
725 anyhow::anyhow!(
726 "Custom agent '{}' did not write result file (.ta/supervisor_result.json): {}",
727 agent_name,
728 e
729 )
730 })?;
731
732 let mut review: SupervisorReview = serde_json::from_str(&content).map_err(|e| {
733 anyhow::anyhow!(
734 "Failed to parse result JSON from custom agent '{}': {}",
735 agent_name,
736 e
737 )
738 })?;
739 review.agent = agent_name.to_string();
740 review.duration_secs = started.elapsed().as_secs_f32();
741 Ok(review)
742}
743
744pub fn fallback_supervisor_review(
746 agent: &str,
747 reason: &str,
748 duration_secs: f32,
749) -> SupervisorReview {
750 SupervisorReview {
751 verdict: SupervisorVerdict::Warn,
752 scope_ok: true,
753 findings: vec![format!("Supervisor review incomplete: {}", reason)],
754 summary: "Supervisor could not complete review (fallback to warn).".to_string(),
755 agent: agent.to_string(),
756 duration_secs,
757 }
758}
759
760pub fn build_supervisor_prompt(
762 objective: &str,
763 changed_files: &[String],
764 constitution_text: Option<&str>,
765) -> String {
766 let files_list = if changed_files.is_empty() {
767 " (no files changed)".to_string()
768 } else {
769 changed_files
770 .iter()
771 .map(|f| format!(" - {}", f))
772 .collect::<Vec<_>>()
773 .join("\n")
774 };
775
776 let constitution_section = match constitution_text {
777 Some(text) if !text.trim().is_empty() => format!(
778 "\n\nProject Constitution:\n```\n{}\n```",
779 &text[..text.len().min(3000)]
780 ),
781 _ => "\n\nProject Constitution: (not available — skip constitution check)".to_string(),
782 };
783
784 format!(
785 r#"You are a supervisor reviewing an AI agent's work for goal alignment and constitution compliance.
786
787Goal Objective:
788{objective}
789
790Changed Files:
791{files_list}{constitution_section}
792
793Read the files listed above using your Read/Grep/Glob tools before forming each finding.
794Cite `file:line` in every finding that references code.
795Never write 'cannot be verified without viewing files' — view the files first.
796
797Review the changes and answer:
7981. Did the agent stay within the goal scope? (Only files directly needed for the objective should be modified)
7992. Are any changes surprising, potentially harmful, or out of scope?
8003. Does the work appear to satisfy the objective?
8014. If a constitution was provided, does the work comply with it?
802
803Respond with ONLY a JSON object (no markdown, no explanation):
804{{
805 "verdict": "pass" | "warn" | "block",
806 "scope_ok": true | false,
807 "findings": ["finding 1", "finding 2"],
808 "summary": "One sentence summary"
809}}
810
811Use:
812- "pass": Changes align well with the objective and constitution
813- "warn": Minor concerns (e.g., one extra file touched, or minor scope drift)
814- "block": Significant concerns (e.g., unrelated system files modified, or clear constitution violation)
815
816Keep findings concise (1-2 sentences each, max 5 findings)."#
817 )
818}
819
820const HEDGING_PHRASES: &[&str] = &[
822 "cannot be verified",
823 "unable to confirm",
824 "without viewing",
825 "depends on implementation",
826 "cannot verify",
827 "unable to verify",
828 "not possible to confirm",
829];
830
831pub(crate) fn apply_hedging_quality_gate(review: &mut SupervisorReview) -> bool {
836 let mut hedged = false;
837 for finding in &review.findings {
838 let lower = finding.to_lowercase();
839 if HEDGING_PHRASES.iter().any(|p| lower.contains(p)) {
840 hedged = true;
841 break;
842 }
843 }
844 if hedged {
845 if review.verdict == SupervisorVerdict::Pass {
846 review.verdict = SupervisorVerdict::Warn;
847 }
848 review.findings.push(
849 "Supervisor produced unverified finding — staging access may be missing or supervisor did not read the file.".to_string()
850 );
851 }
852 hedged
853}
854
855fn parse_supervisor_response(text: &str) -> anyhow::Result<SupervisorReview> {
856 let json_str = extract_json(text);
858
859 let parsed: LlmSupervisorResponse = serde_json::from_str(json_str).map_err(|e| {
860 anyhow::anyhow!(
861 "Failed to parse supervisor JSON: {} — response: {}",
862 e,
863 &text[..text.len().min(300)]
864 )
865 })?;
866
867 let verdict = match parsed.verdict.as_deref() {
868 Some("pass") => SupervisorVerdict::Pass,
869 Some("block") => SupervisorVerdict::Block,
870 _ => SupervisorVerdict::Warn, };
872
873 Ok(SupervisorReview {
874 verdict,
875 scope_ok: parsed.scope_ok.unwrap_or(true),
876 findings: parsed.findings.unwrap_or_default(),
877 summary: parsed
878 .summary
879 .unwrap_or_else(|| "No summary provided.".to_string()),
880 agent: "builtin".to_string(),
881 duration_secs: 0.0, })
883}
884
885fn extract_json(text: &str) -> &str {
887 if let Some(start) = text.find("```json") {
889 let after = &text[start + 7..];
890 if let Some(end) = after.find("```") {
891 return after[..end].trim();
892 }
893 }
894 if let Some(start) = text.find("```") {
896 let after = &text[start + 3..];
897 if let Some(end) = after.find("```") {
898 return after[..end].trim();
899 }
900 }
901 if let Some(start) = text.find('{') {
903 if let Some(end) = text.rfind('}') {
904 if end > start {
905 return &text[start..=end];
906 }
907 }
908 }
909 text.trim()
910}
911
912pub fn load_constitution(staging_path: &Path, config: &SupervisorRunConfig) -> Option<String> {
914 if let Some(ref path) = config.constitution_path {
916 let full = staging_path.join(path);
917 if full.exists() {
918 return std::fs::read_to_string(&full).ok();
919 }
920 }
921 let yaml_path = staging_path.join(".ta/constitution.yaml");
923 if yaml_path.exists() {
924 return std::fs::read_to_string(&yaml_path).ok();
925 }
926 let toml_path = staging_path.join(".ta/constitution.toml");
928 if toml_path.exists() {
929 return std::fs::read_to_string(&toml_path).ok();
930 }
931 let md_path = staging_path.join("docs/TA-CONSTITUTION.md");
933 if md_path.exists() {
934 return std::fs::read_to_string(&md_path).ok();
935 }
936 None
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942
943 #[cfg(unix)]
947 static PATH_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
948
949 #[test]
950 fn test_build_supervisor_prompt_includes_objective() {
951 let prompt = build_supervisor_prompt(
952 "Add JWT authentication to the API",
953 &["src/auth.rs".to_string(), "src/middleware.rs".to_string()],
954 None,
955 );
956 assert!(prompt.contains("Add JWT authentication to the API"));
957 assert!(prompt.contains("src/auth.rs"));
958 assert!(prompt.contains("src/middleware.rs"));
959 }
960
961 #[test]
962 fn test_build_supervisor_prompt_includes_constitution() {
963 let prompt = build_supervisor_prompt(
964 "Fix bug in parser",
965 &["src/parser.rs".to_string()],
966 Some("Never modify production database directly."),
967 );
968 assert!(prompt.contains("Never modify production database directly."));
969 }
970
971 #[test]
972 fn test_build_supervisor_prompt_no_constitution() {
973 let prompt = build_supervisor_prompt("Fix bug", &["src/foo.rs".to_string()], None);
974 assert!(prompt.contains("not available — skip constitution check"));
975 }
976
977 #[test]
978 fn test_build_supervisor_prompt_empty_files() {
979 let prompt = build_supervisor_prompt("Fix bug", &[], None);
980 assert!(prompt.contains("no files changed"));
981 }
982
983 #[test]
984 fn test_supervisor_verdict_display() {
985 assert_eq!(SupervisorVerdict::Pass.to_string(), "pass");
986 assert_eq!(SupervisorVerdict::Warn.to_string(), "warn");
987 assert_eq!(SupervisorVerdict::Block.to_string(), "block");
988 }
989
990 #[test]
991 fn test_supervisor_verdict_serde() {
992 let v: SupervisorVerdict = serde_json::from_str("\"pass\"").unwrap();
993 assert_eq!(v, SupervisorVerdict::Pass);
994 let v: SupervisorVerdict = serde_json::from_str("\"block\"").unwrap();
995 assert_eq!(v, SupervisorVerdict::Block);
996 let v: SupervisorVerdict = serde_json::from_str("\"warn\"").unwrap();
997 assert_eq!(v, SupervisorVerdict::Warn);
998 }
999
1000 #[test]
1001 fn test_parse_supervisor_response_pass() {
1002 let json =
1003 r#"{"verdict": "pass", "scope_ok": true, "findings": [], "summary": "All good."}"#;
1004 let review = parse_supervisor_response(json).unwrap();
1005 assert_eq!(review.verdict, SupervisorVerdict::Pass);
1006 assert!(review.scope_ok);
1007 assert_eq!(review.summary, "All good.");
1008 assert!(review.findings.is_empty());
1009 }
1010
1011 #[test]
1012 fn test_parse_supervisor_response_with_findings() {
1013 let json = r#"{"verdict": "warn", "scope_ok": false, "findings": ["Extra file modified", "Consider removing debug code"], "summary": "Minor concerns."}"#;
1014 let review = parse_supervisor_response(json).unwrap();
1015 assert_eq!(review.verdict, SupervisorVerdict::Warn);
1016 assert!(!review.scope_ok);
1017 assert_eq!(review.findings.len(), 2);
1018 }
1019
1020 #[test]
1021 fn test_parse_supervisor_response_markdown_wrapped() {
1022 let text = "Here is the review:\n```json\n{\"verdict\": \"pass\", \"scope_ok\": true, \"findings\": [], \"summary\": \"LGTM.\"}\n```";
1023 let review = parse_supervisor_response(text).unwrap();
1024 assert_eq!(review.verdict, SupervisorVerdict::Pass);
1025 }
1026
1027 #[test]
1028 fn test_parse_supervisor_response_unknown_verdict_falls_back_to_warn() {
1029 let json =
1030 r#"{"verdict": "unclear", "scope_ok": true, "findings": [], "summary": "Not sure."}"#;
1031 let review = parse_supervisor_response(json).unwrap();
1032 assert_eq!(review.verdict, SupervisorVerdict::Warn);
1033 }
1034
1035 #[test]
1036 fn test_parse_supervisor_response_block() {
1037 let json = r#"{"verdict": "block", "scope_ok": false, "findings": ["Modified unrelated system files"], "summary": "Significant scope violation."}"#;
1038 let review = parse_supervisor_response(json).unwrap();
1039 assert_eq!(review.verdict, SupervisorVerdict::Block);
1040 assert!(!review.scope_ok);
1041 }
1042
1043 #[test]
1044 fn test_extract_json_backtick_block() {
1045 let text = "Some prose\n```json\n{\"key\": \"value\"}\n```\nMore prose";
1046 let extracted = extract_json(text);
1047 assert_eq!(extracted, "{\"key\": \"value\"}");
1048 }
1049
1050 #[test]
1051 fn test_extract_json_plain() {
1052 let text = "{\"verdict\": \"pass\"}";
1053 let extracted = extract_json(text);
1054 assert_eq!(extracted, "{\"verdict\": \"pass\"}");
1055 }
1056
1057 #[test]
1058 fn test_fallback_supervisor_review_structure() {
1059 let fallback = fallback_supervisor_review("builtin", "ANTHROPIC_API_KEY not set", 0.001);
1061 assert_eq!(fallback.verdict, SupervisorVerdict::Warn);
1062 assert!(fallback.scope_ok);
1063 assert!(!fallback.findings.is_empty());
1064 assert_eq!(fallback.agent, "builtin");
1065 }
1066
1067 #[test]
1068 fn test_extract_claude_stream_json_result_event() {
1069 let stream = r#"{"type":"system","subtype":"init"}
1071{"type":"assistant","message":{"content":[{"type":"text","text":"Analyzing..."}]}}
1072{"type":"result","subtype":"success","result":"{\"verdict\":\"pass\",\"scope_ok\":true,\"findings\":[],\"summary\":\"All good.\"}"}
1073"#;
1074 let text = extract_claude_stream_json_text(stream);
1075 assert!(text.contains("verdict"));
1076 assert!(text.contains("pass"));
1077 }
1078
1079 #[test]
1080 fn test_extract_claude_stream_json_fallback_to_assistant() {
1081 let stream = r#"{"type":"system","subtype":"init"}
1083{"type":"assistant","message":{"content":[{"type":"text","text":"{\"verdict\":\"warn\",\"scope_ok\":true,\"findings\":[],\"summary\":\"Minor issue.\"}"}]}}
1084"#;
1085 let text = extract_claude_stream_json_text(stream);
1086 assert!(text.contains("verdict"));
1087 }
1088
1089 #[test]
1090 fn test_parse_supervisor_response_or_text_plain_text() {
1091 let text = "The changes look fine overall but one extra file was touched.";
1093 let review = parse_supervisor_response_or_text(text, "codex");
1094 assert_eq!(review.verdict, SupervisorVerdict::Warn);
1095 assert_eq!(review.agent, "codex");
1096 assert!(review.summary.contains("extra file"));
1097 }
1098
1099 #[test]
1100 fn test_parse_supervisor_response_or_text_structured_json() {
1101 let text = r#"{"verdict": "pass", "scope_ok": true, "findings": [], "summary": "LGTM."}"#;
1102 let review = parse_supervisor_response_or_text(text, "claude-code");
1103 assert_eq!(review.verdict, SupervisorVerdict::Pass);
1104 assert_eq!(review.agent, "claude-code");
1105 }
1106
1107 #[test]
1108 fn test_invoke_supervisor_agent_api_key_preflight_fails() {
1109 let config = SupervisorRunConfig {
1111 enabled: true,
1112 agent: "codex".to_string(),
1113 verdict_on_block: "warn".to_string(),
1114 constitution_path: None,
1115 skip_if_no_constitution: true,
1116 heartbeat_stale_secs: 30,
1117 timeout_secs: 30,
1118 api_key_env: Some("TA_TEST_MISSING_KEY_XYZ_SUPERVISOR".to_string()),
1119 staging_path: None,
1120 heartbeat_path: None,
1121 agent_profile: None,
1122 resolved_model: None,
1123 enable_hooks: false,
1124 };
1125 std::env::remove_var("TA_TEST_MISSING_KEY_XYZ_SUPERVISOR");
1127 let review = invoke_supervisor_agent("test objective", &[], None, &config);
1128 assert_eq!(review.verdict, SupervisorVerdict::Warn);
1129 assert!(review.findings[0].contains("TA_TEST_MISSING_KEY_XYZ_SUPERVISOR"));
1130 }
1131
1132 #[test]
1133 fn test_heartbeat_written_per_chunk() {
1134 use tempfile::tempdir;
1135 let dir = tempdir().unwrap();
1137 let hb_path = dir.path().join("supervisor.heartbeat");
1138
1139 assert!(!hb_path.exists());
1141
1142 let result = spawn_with_heartbeat_monitor(
1144 "echo",
1145 &["heartbeat_test"],
1146 30, Some(hb_path.as_path()),
1148 "echo",
1149 None,
1150 &[],
1151 None,
1152 );
1153 assert!(result.is_ok(), "echo should succeed: {:?}", result);
1155 let stdout = result.unwrap();
1156 assert!(stdout.contains("heartbeat_test"));
1157 assert!(
1159 !hb_path.exists(),
1160 "heartbeat file should be removed after completion"
1161 );
1162 }
1163
1164 #[test]
1165 fn test_monitor_kills_stalled_process() {
1166 use tempfile::tempdir;
1167 let dir = tempdir().unwrap();
1168 let hb_path = dir.path().join("supervisor_stall.heartbeat");
1169
1170 let result = spawn_with_heartbeat_monitor(
1173 "sleep",
1174 &["60"],
1175 1, Some(hb_path.as_path()),
1177 "sleep",
1178 None,
1179 &[],
1180 None,
1181 );
1182 assert!(result.is_err(), "stalled process should be killed");
1183 let err = result.unwrap_err().to_string();
1184 assert!(
1185 err.contains("stalled") || err.contains("no tokens"),
1186 "error should mention stall: {}",
1187 err
1188 );
1189 assert!(
1191 !hb_path.exists(),
1192 "heartbeat file should be removed after stall"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_active_streaming_not_killed() {
1198 let result = spawn_with_heartbeat_monitor(
1202 "sh",
1203 &["-c", "echo line1 && echo line2 && echo line3"],
1204 5,
1205 None, "sh",
1207 None,
1208 &[],
1209 None,
1210 );
1211 assert!(
1212 result.is_ok(),
1213 "fast-completing process should not be killed: {:?}",
1214 result
1215 );
1216 let stdout = result.unwrap();
1217 assert!(stdout.contains("line1"));
1218 assert!(stdout.contains("line3"));
1219 }
1220
1221 #[test]
1222 fn test_timeout_secs_field_preserved() {
1223 let config = SupervisorRunConfig {
1225 enabled: true,
1226 agent: "builtin".to_string(),
1227 verdict_on_block: "warn".to_string(),
1228 constitution_path: None,
1229 skip_if_no_constitution: true,
1230 heartbeat_stale_secs: 30,
1231 timeout_secs: 120, api_key_env: None,
1233 staging_path: None,
1234 heartbeat_path: None,
1235 agent_profile: None,
1236 resolved_model: None,
1237 enable_hooks: false,
1238 };
1239 assert_eq!(config.heartbeat_stale_secs, 30);
1240 assert_eq!(config.timeout_secs, 120);
1241 }
1242
1243 #[test]
1244 fn test_stall_message_includes_partial_output() {
1245 use tempfile::tempdir;
1246 let dir = tempdir().unwrap();
1247 let hb_path = dir.path().join("stall_partial.heartbeat");
1248
1249 let result = spawn_with_heartbeat_monitor(
1252 "sh",
1253 &["-c", "echo partial_finding && sleep 60"],
1254 1,
1255 Some(hb_path.as_path()),
1256 "sh",
1257 None,
1258 &[],
1259 None,
1260 );
1261 assert!(result.is_err());
1262 let err = result.unwrap_err().to_string();
1263 assert!(
1265 err.contains("partial_finding") || err.contains("Findings so far"),
1266 "stall error should include partial output: {}",
1267 err
1268 );
1269 }
1270
1271 #[test]
1272 fn test_invoke_supervisor_agent_custom_agent_no_staging_path() {
1273 let config = SupervisorRunConfig {
1275 enabled: true,
1276 agent: "my-custom-reviewer".to_string(),
1277 verdict_on_block: "warn".to_string(),
1278 constitution_path: None,
1279 skip_if_no_constitution: true,
1280 heartbeat_stale_secs: 30,
1281 timeout_secs: 30,
1282 api_key_env: None,
1283 staging_path: None,
1284 heartbeat_path: None,
1285 agent_profile: None,
1286 resolved_model: None,
1287 enable_hooks: false,
1288 };
1289 let review = invoke_supervisor_agent("test objective", &[], None, &config);
1290 assert_eq!(review.verdict, SupervisorVerdict::Warn);
1291 }
1292
1293 #[test]
1294 fn test_fallback_review_no_api_key_message() {
1295 let config = SupervisorRunConfig {
1297 enabled: true,
1298 agent: "codex".to_string(),
1299 verdict_on_block: "warn".to_string(),
1300 constitution_path: None,
1301 skip_if_no_constitution: true,
1302 heartbeat_stale_secs: 30,
1303 timeout_secs: 30,
1304 api_key_env: Some("OPENAI_API_KEY".to_string()),
1305 staging_path: None,
1306 heartbeat_path: None,
1307 agent_profile: None,
1308 resolved_model: None,
1309 enable_hooks: false,
1310 };
1311 std::env::remove_var("OPENAI_API_KEY");
1312 let review = invoke_supervisor_agent("objective", &[], None, &config);
1313 assert_eq!(review.verdict, SupervisorVerdict::Warn);
1314 assert!(
1315 review.findings[0].contains("OPENAI_API_KEY"),
1316 "finding should mention the missing env var"
1317 );
1318 }
1319
1320 #[test]
1327 #[cfg(unix)]
1328 fn test_claude_cli_supervisor_passes_verbose_flag() {
1329 use std::io::Write;
1330 use std::os::unix::fs::PermissionsExt;
1331
1332 let tmp = tempfile::tempdir().unwrap();
1333 let claude_path = tmp.path().join("claude");
1334 {
1335 let mut f = std::fs::File::create(&claude_path).unwrap();
1336 f.write_all(
1339 b"#!/bin/sh\n\
1340 found_verbose=''\n\
1341 found_tools=''\n\
1342 for arg in \"$@\"; do\n\
1343 [ \"$arg\" = \"--verbose\" ] && found_verbose=1\n\
1344 [ \"$arg\" = \"--allowedTools\" ] && found_tools=1\n\
1345 done\n\
1346 if [ -z \"$found_verbose\" ]; then echo 'Error: --verbose missing' >&2; exit 1; fi\n\
1347 if [ -z \"$found_tools\" ]; then echo 'Error: --allowedTools missing' >&2; exit 1; fi\n\
1348 echo '{\"verdict\":\"pass\",\"scope_ok\":true,\"findings\":[],\"summary\":\"ok\"}'\n",
1349 )
1350 .unwrap();
1351 }
1352 let mut perms = std::fs::metadata(&claude_path).unwrap().permissions();
1353 perms.set_mode(0o755);
1354 std::fs::set_permissions(&claude_path, perms).unwrap();
1355
1356 let _lock = PATH_MUTEX.lock().unwrap();
1357 let old_path = std::env::var("PATH").unwrap_or_default();
1358 std::env::set_var("PATH", format!("{}:{}", tmp.path().display(), old_path));
1360
1361 let config = SupervisorRunConfig {
1362 enabled: true,
1363 agent: "builtin".to_string(),
1364 verdict_on_block: "warn".to_string(),
1365 constitution_path: None,
1366 skip_if_no_constitution: true,
1367 heartbeat_stale_secs: 10,
1368 timeout_secs: 10,
1369 api_key_env: None,
1370 staging_path: None,
1371 heartbeat_path: None,
1372 agent_profile: None,
1373 resolved_model: None,
1374 enable_hooks: false,
1375 };
1376
1377 let review = invoke_supervisor_agent("test objective", &[], None, &config);
1378
1379 std::env::set_var("PATH", old_path);
1381
1382 assert_eq!(
1383 review.verdict,
1384 SupervisorVerdict::Pass,
1385 "Supervisor must pass --verbose to claude CLI; got findings: {:?}",
1386 review.findings
1387 );
1388 }
1389
1390 #[test]
1391 fn test_build_supervisor_prompt_includes_file_inspection_instruction() {
1392 let prompt =
1393 build_supervisor_prompt("Add JWT authentication", &["src/auth.rs".to_string()], None);
1394 assert!(
1395 prompt.contains("Read"),
1396 "prompt must instruct supervisor to read files"
1397 );
1398 assert!(
1399 prompt.contains("file:line") || prompt.contains("file:"),
1400 "prompt must require file:line citations"
1401 );
1402 assert!(
1403 prompt.contains("cannot be verified") || prompt.contains("Never write"),
1404 "prompt must ban hedging phrases"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_hedging_quality_gate_fires_on_hedging_phrase() {
1410 let mut review = SupervisorReview {
1411 verdict: SupervisorVerdict::Pass,
1412 scope_ok: true,
1413 findings: vec![
1414 "This change cannot be verified without viewing the actual file contents."
1415 .to_string(),
1416 ],
1417 summary: "Looks fine.".to_string(),
1418 agent: "claude-code".to_string(),
1419 duration_secs: 0.0,
1420 };
1421 let fired = apply_hedging_quality_gate(&mut review);
1422 assert!(fired, "quality gate should fire on 'cannot be verified'");
1423 assert_eq!(
1424 review.verdict,
1425 SupervisorVerdict::Warn,
1426 "verdict should be upgraded to Warn"
1427 );
1428 assert!(
1429 review
1430 .findings
1431 .last()
1432 .unwrap()
1433 .contains("Supervisor produced unverified finding"),
1434 "meta-finding should be appended"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_hedging_quality_gate_no_fire_on_clean_findings() {
1440 let mut review = SupervisorReview {
1441 verdict: SupervisorVerdict::Pass,
1442 scope_ok: true,
1443 findings: vec![
1444 "src/auth.rs:42: JWT secret is not rotated — consider adding rotation logic."
1445 .to_string(),
1446 ],
1447 summary: "One finding.".to_string(),
1448 agent: "claude-code".to_string(),
1449 duration_secs: 0.0,
1450 };
1451 let fired = apply_hedging_quality_gate(&mut review);
1452 assert!(
1453 !fired,
1454 "quality gate should not fire on clean file:line findings"
1455 );
1456 assert_eq!(review.verdict, SupervisorVerdict::Pass);
1457 }
1458
1459 #[test]
1460 fn test_hedging_quality_gate_preserves_block_verdict() {
1461 let mut review = SupervisorReview {
1462 verdict: SupervisorVerdict::Block,
1463 scope_ok: false,
1464 findings: vec![
1465 "Unable to confirm whether the migration is reversible without viewing migration files.".to_string(),
1466 ],
1467 summary: "Block.".to_string(),
1468 agent: "claude-code".to_string(),
1469 duration_secs: 0.0,
1470 };
1471 apply_hedging_quality_gate(&mut review);
1472 assert_eq!(
1474 review.verdict,
1475 SupervisorVerdict::Block,
1476 "Block verdict must not be changed"
1477 );
1478 }
1479
1480 #[test]
1481 fn test_supervisor_run_config_agent_profile_field() {
1482 let config = SupervisorRunConfig {
1483 enabled: true,
1484 agent: "builtin".to_string(),
1485 verdict_on_block: "warn".to_string(),
1486 constitution_path: None,
1487 skip_if_no_constitution: true,
1488 heartbeat_stale_secs: 30,
1489 timeout_secs: 30,
1490 api_key_env: None,
1491 staging_path: None,
1492 heartbeat_path: None,
1493 agent_profile: Some("supervisor".to_string()),
1494 resolved_model: Some("claude-sonnet-4-6".to_string()),
1495 enable_hooks: false,
1496 };
1497 assert_eq!(config.agent_profile.as_deref(), Some("supervisor"));
1498 assert_eq!(config.resolved_model.as_deref(), Some("claude-sonnet-4-6"));
1499 }
1500
1501 #[cfg(unix)]
1502 #[test]
1503 fn test_claude_supervisor_sets_current_dir_in_staging() {
1504 use std::io::Write;
1505 use std::os::unix::fs::PermissionsExt;
1506 let staging = tempfile::tempdir().unwrap();
1508 let sentinel = staging.path().join("STAGING_SENTINEL.txt");
1509 std::fs::write(&sentinel, b"yes").unwrap();
1510
1511 let bin_dir = tempfile::tempdir().unwrap();
1513 let claude_path = bin_dir.path().join("claude");
1514 {
1515 let mut f = std::fs::File::create(&claude_path).unwrap();
1516 f.write_all(
1517 b"#!/bin/sh\n\
1518 if [ ! -f STAGING_SENTINEL.txt ]; then\n\
1519 echo 'Error: not running in staging dir' >&2; exit 1\n\
1520 fi\n\
1521 echo '{\"verdict\":\"pass\",\"scope_ok\":true,\"findings\":[],\"summary\":\"staging ok\"}'\n",
1522 )
1523 .unwrap();
1524 }
1525 let mut perms = std::fs::metadata(&claude_path).unwrap().permissions();
1526 perms.set_mode(0o755);
1527 std::fs::set_permissions(&claude_path, perms).unwrap();
1528
1529 let _lock = PATH_MUTEX.lock().unwrap();
1530 let old_path = std::env::var("PATH").unwrap_or_default();
1531 std::env::set_var("PATH", format!("{}:{}", bin_dir.path().display(), old_path));
1532
1533 let config = SupervisorRunConfig {
1534 enabled: true,
1535 agent: "builtin".to_string(),
1536 verdict_on_block: "warn".to_string(),
1537 constitution_path: None,
1538 skip_if_no_constitution: true,
1539 heartbeat_stale_secs: 10,
1540 timeout_secs: 10,
1541 api_key_env: None,
1542 staging_path: Some(staging.path().to_path_buf()),
1543 heartbeat_path: None,
1544 agent_profile: None,
1545 resolved_model: None,
1546 enable_hooks: false,
1547 };
1548
1549 let review = invoke_supervisor_agent("test objective", &[], None, &config);
1550 std::env::set_var("PATH", old_path);
1551
1552 assert_eq!(
1553 review.verdict,
1554 SupervisorVerdict::Pass,
1555 "Supervisor must run in staging dir; got findings: {:?}",
1556 review.findings
1557 );
1558 }
1559
1560 #[test]
1563 fn test_is_hook_json_line_detects_system_type() {
1564 let hook_line = r#"{"type":"system","subtype":"hook_started","hook_name":"SessionStart"}"#;
1566 assert!(
1567 is_hook_json_line(hook_line),
1568 "SessionStart hook JSON must be detected"
1569 );
1570 }
1571
1572 #[test]
1573 fn test_is_hook_json_line_ignores_non_system_type() {
1574 let result_line =
1576 r#"{"type":"result","subtype":"success","result":"{\"verdict\":\"pass\"}"}"#;
1577 assert!(
1578 !is_hook_json_line(result_line),
1579 "result event must not be filtered"
1580 );
1581
1582 let assistant_line =
1583 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#;
1584 assert!(
1585 !is_hook_json_line(assistant_line),
1586 "assistant event must not be filtered"
1587 );
1588 }
1589
1590 #[test]
1591 fn test_is_hook_json_line_ignores_plain_text() {
1592 assert!(!is_hook_json_line("some plain output"));
1593 assert!(!is_hook_json_line(""));
1594 assert!(!is_hook_json_line("not json at all"));
1595 }
1596
1597 #[test]
1598 fn test_is_hook_json_line_ignores_non_json_braces() {
1599 assert!(!is_hook_json_line("{not valid json}"));
1601 }
1602
1603 #[cfg(unix)]
1606 #[test]
1607 fn test_hook_json_line_filtered_from_output() {
1608 let hook_json = r#"{"type":"system","subtype":"hook_started","hook_name":"SessionStart"}"#;
1611 let real_content = r#"{"type":"result","result":"done"}"#;
1612 let script = format!("echo '{}' && echo '{}'", hook_json, real_content);
1613
1614 let result =
1615 spawn_with_heartbeat_monitor("sh", &["-c", &script], 5, None, "sh", None, &[], None);
1616 assert!(result.is_ok(), "process should succeed: {:?}", result);
1617 let stdout = result.unwrap();
1618 assert!(
1620 !stdout.contains("hook_started"),
1621 "hook JSON must not appear in output: {}",
1622 stdout
1623 );
1624 assert!(
1626 stdout.contains("result"),
1627 "real content must be in output: {}",
1628 stdout
1629 );
1630 }
1631
1632 #[cfg(unix)]
1635 #[test]
1636 fn test_only_hook_json_lines_triggers_stall() {
1637 let hook_json = r#"{"type":"system","subtype":"hook_started","hook_name":"SessionStart"}"#;
1640 let script = format!("echo '{}' && sleep 60", hook_json);
1641
1642 let result = spawn_with_heartbeat_monitor(
1643 "sh",
1644 &["-c", &script],
1645 1, None,
1647 "sh",
1648 None,
1649 &[],
1650 None,
1651 );
1652 assert!(
1653 result.is_err(),
1654 "stream of only hook JSON should trigger stall"
1655 );
1656 let err = result.unwrap_err().to_string();
1657 assert!(
1658 err.contains("stalled") || err.contains("no tokens"),
1659 "stall error expected: {}",
1660 err
1661 );
1662 }
1663
1664 #[cfg(unix)]
1667 #[test]
1668 fn test_disable_hooks_env_var_set_when_enable_hooks_false() {
1669 use std::io::Write;
1670 use std::os::unix::fs::PermissionsExt;
1671
1672 let tmp = tempfile::tempdir().unwrap();
1673 let claude_path = tmp.path().join("claude");
1674 {
1675 let mut f = std::fs::File::create(&claude_path).unwrap();
1676 f.write_all(
1678 b"#!/bin/sh\n\
1679 if [ \"$CLAUDE_CODE_DISABLE_HOOKS\" = \"1\" ]; then\n\
1680 echo '{\"verdict\":\"pass\",\"scope_ok\":true,\"findings\":[],\"summary\":\"hooks disabled\"}'\n\
1681 else\n\
1682 echo '{\"verdict\":\"block\",\"scope_ok\":false,\"findings\":[\"CLAUDE_CODE_DISABLE_HOOKS not set\"],\"summary\":\"hooks not disabled\"}'\n\
1683 fi\n",
1684 )
1685 .unwrap();
1686 }
1687 let mut perms = std::fs::metadata(&claude_path).unwrap().permissions();
1688 perms.set_mode(0o755);
1689 std::fs::set_permissions(&claude_path, perms).unwrap();
1690
1691 let _lock = PATH_MUTEX.lock().unwrap();
1692 let old_path = std::env::var("PATH").unwrap_or_default();
1693 std::env::set_var("PATH", format!("{}:{}", tmp.path().display(), old_path));
1694
1695 let config = SupervisorRunConfig {
1696 enabled: true,
1697 agent: "builtin".to_string(),
1698 verdict_on_block: "warn".to_string(),
1699 constitution_path: None,
1700 skip_if_no_constitution: true,
1701 heartbeat_stale_secs: 10,
1702 timeout_secs: 10,
1703 api_key_env: None,
1704 staging_path: None,
1705 heartbeat_path: None,
1706 agent_profile: None,
1707 resolved_model: None,
1708 enable_hooks: false, };
1710
1711 let review = invoke_supervisor_agent("test objective", &[], None, &config);
1712 std::env::set_var("PATH", old_path);
1713
1714 assert_eq!(
1715 review.verdict,
1716 SupervisorVerdict::Pass,
1717 "CLAUDE_CODE_DISABLE_HOOKS=1 must be set when enable_hooks=false; got: {:?}",
1718 review.findings
1719 );
1720 }
1721
1722 #[cfg(unix)]
1724 #[test]
1725 fn test_enable_hooks_true_does_not_set_disable_env() {
1726 use std::io::Write;
1727 use std::os::unix::fs::PermissionsExt;
1728
1729 let tmp = tempfile::tempdir().unwrap();
1730 let claude_path = tmp.path().join("claude");
1731 {
1732 let mut f = std::fs::File::create(&claude_path).unwrap();
1733 f.write_all(
1735 b"#!/bin/sh\n\
1736 if [ \"$CLAUDE_CODE_DISABLE_HOOKS\" = \"1\" ]; then\n\
1737 echo '{\"verdict\":\"block\",\"scope_ok\":false,\"findings\":[\"CLAUDE_CODE_DISABLE_HOOKS was set unexpectedly\"],\"summary\":\"fail\"}'\n\
1738 else\n\
1739 echo '{\"verdict\":\"pass\",\"scope_ok\":true,\"findings\":[],\"summary\":\"hooks allowed\"}'\n\
1740 fi\n",
1741 )
1742 .unwrap();
1743 }
1744 let mut perms = std::fs::metadata(&claude_path).unwrap().permissions();
1745 perms.set_mode(0o755);
1746 std::fs::set_permissions(&claude_path, perms).unwrap();
1747
1748 let _lock = PATH_MUTEX.lock().unwrap();
1749 let old_path = std::env::var("PATH").unwrap_or_default();
1750 std::env::remove_var("CLAUDE_CODE_DISABLE_HOOKS");
1752 std::env::set_var("PATH", format!("{}:{}", tmp.path().display(), old_path));
1753
1754 let config = SupervisorRunConfig {
1755 enabled: true,
1756 agent: "builtin".to_string(),
1757 verdict_on_block: "warn".to_string(),
1758 constitution_path: None,
1759 skip_if_no_constitution: true,
1760 heartbeat_stale_secs: 10,
1761 timeout_secs: 10,
1762 api_key_env: None,
1763 staging_path: None,
1764 heartbeat_path: None,
1765 agent_profile: None,
1766 resolved_model: None,
1767 enable_hooks: true, };
1769
1770 let review = invoke_supervisor_agent("test objective", &[], None, &config);
1771 std::env::set_var("PATH", old_path);
1772
1773 assert_eq!(
1774 review.verdict,
1775 SupervisorVerdict::Pass,
1776 "CLAUDE_CODE_DISABLE_HOOKS must not be set when enable_hooks=true; got: {:?}",
1777 review.findings
1778 );
1779 }
1780}