use crate::phase_id::PhaseId;
use std::path::{Path, PathBuf};
pub const TRUST_EXTERNAL_VERIFY_ENV: &str = "DEVFLOW_TRUST_EXTERNAL_VERIFY";
pub fn external_verification_approval() -> Option<Vec<String>> {
let value = std::env::var(TRUST_EXTERNAL_VERIFY_ENV).ok()?;
parse_external_verification_approval(&value)
}
fn parse_external_verification_approval(value: &str) -> Option<Vec<String>> {
let commands = serde_json::from_str::<Vec<String>>(value).ok()?;
(!commands.is_empty() && commands.iter().all(|command| !command.trim().is_empty()))
.then_some(commands)
}
pub fn phase_plan_files(project_root: &Path, phase: PhaseId) -> Vec<PathBuf> {
let phases_dir = project_root.join(".planning/phases");
let phase_prefix = format!("{padded}-", padded = phase.padded());
let plan_prefix = format!("{padded}-", padded = phase.padded());
let mut plans = Vec::<PathBuf>::new();
let Ok(phase_entries) = std::fs::read_dir(phases_dir) else {
return Vec::new();
};
for phase_entry in phase_entries.flatten() {
if !phase_entry
.file_name()
.to_string_lossy()
.starts_with(&phase_prefix)
{
continue;
}
let Ok(plan_entries) = std::fs::read_dir(phase_entry.path()) else {
continue;
};
plans.extend(plan_entries.flatten().filter_map(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
(name.starts_with(&plan_prefix) && name.ends_with("-PLAN.md")).then(|| entry.path())
}));
}
plans.sort();
plans
}
pub fn external_verify_commands(project_root: &Path, phase: PhaseId) -> Vec<String> {
phase_plan_files(project_root, phase)
.into_iter()
.filter_map(|path| std::fs::read_to_string(path).ok())
.filter_map(|contents| command_from_frontmatter(&contents))
.collect()
}
fn command_from_frontmatter(contents: &str) -> Option<String> {
let mut lines = contents.lines();
if lines.next()?.trim() != "---" {
return None;
}
for line in lines {
let line = line.trim();
if line == "---" {
break;
}
let Some(value) = line.strip_prefix("external_verify:") else {
continue;
};
let value = value.trim();
if value.is_empty() {
return None;
}
let command = if value.starts_with('"') {
serde_json::from_str::<String>(value).ok()
} else if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
Some(value[1..value.len() - 1].replace("''", "'"))
} else {
Some(value.to_owned())
};
return command.filter(|command| !command.trim().is_empty());
}
None
}
pub fn phase_has_blocking_human_checkpoint(project_root: &Path, phase: PhaseId) -> bool {
const HUMAN_BLOCKING_GATE: &str = r#"gate="blocking-human""#;
phase_plan_files(project_root, phase)
.into_iter()
.filter_map(|path| std::fs::read_to_string(path).ok())
.any(|contents| contents.contains(HUMAN_BLOCKING_GATE))
}
const HUMAN_ONLY_CHECKPOINT_MARKERS: [&str; 2] = [
concat!("gate=", "\"", "blocking-human", "\""),
concat!("type=", "\"", "checkpoint:human-action", "\""),
];
const TASK_ELEMENT_OPENING: &str = "<task";
pub fn phase_has_human_only_checkpoint(project_root: &Path, phase: PhaseId) -> bool {
phase_plan_files(project_root, phase)
.into_iter()
.filter_map(|path| std::fs::read_to_string(path).ok())
.any(|contents| contents.lines().any(line_declares_human_only_checkpoint))
}
fn line_declares_human_only_checkpoint(line: &str) -> bool {
line.contains(TASK_ELEMENT_OPENING)
&& HUMAN_ONLY_CHECKPOINT_MARKERS
.iter()
.any(|marker| line.contains(marker))
}
pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(project_root)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_plan(root: &std::path::Path, contents: &str) {
let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
std::fs::create_dir_all(&phase_dir).unwrap();
std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
}
#[test]
fn approval_parser_accepts_only_nonempty_json_command_arrays() {
assert_eq!(
parse_external_verification_approval(r#"["test -f shipped", "cargo test"]"#),
Some(vec!["test -f shipped".into(), "cargo test".into()])
);
for invalid in [
"",
"true",
"{}",
"[]",
r#"[""]"#,
r#"[" "]"#,
r#"["ok", 1]"#,
] {
assert_eq!(
parse_external_verification_approval(invalid),
None,
"approval must fail closed for {invalid:?}"
);
}
}
#[test]
fn reads_external_verify_only_from_plan_frontmatter() {
let dir = tempfile::tempdir().unwrap();
write_plan(
dir.path(),
"---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
);
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(
dir.path().join(".devflow/phase-16-stdout"),
"external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
)
.unwrap();
assert_eq!(
external_verify_commands(dir.path(), PhaseId::new(16)),
vec!["test -f shipped.txt"]
);
}
#[test]
fn ignores_external_verify_outside_frontmatter() {
let dir = tempfile::tempdir().unwrap();
write_plan(
dir.path(),
"---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
);
assert!(external_verify_commands(dir.path(), PhaseId::new(16)).is_empty());
}
#[test]
fn ignores_empty_external_verify_commands() {
for value in [r#""""#, "''"] {
let dir = tempfile::tempdir().unwrap();
write_plan(
dir.path(),
&format!("---\nphase: 16\nexternal_verify: {value}\n---\n"),
);
assert!(
external_verify_commands(dir.path(), PhaseId::new(16)).is_empty(),
"empty command {value:?} must not count as affirmative verification"
);
}
}
#[test]
fn runs_probe_from_project_root_and_reports_exit_status() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
assert!(run_external_verification("test -f shipped.txt", dir.path()));
assert!(!run_external_verification(
"test -f missing.txt",
dir.path()
));
}
const HUMAN_GATE_VALUE: &str = "blocking-human";
const PLAIN_GATE_VALUE: &str = "blocking";
const HUMAN_ACTION_TYPE_VALUE: &str = "checkpoint:human-action";
fn write_phase_file(root: &std::path::Path, phase_dir: &str, file_name: &str, contents: &str) {
let dir = root.join(".planning/phases").join(phase_dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(file_name), contents).unwrap();
}
#[test]
fn phase_has_blocking_human_checkpoint_detects_declared_gate() {
let dir = tempfile::tempdir().unwrap();
let body = format!(
"---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
);
write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
assert!(phase_has_blocking_human_checkpoint(
dir.path(),
PhaseId::new(91)
));
}
#[test]
fn phase_has_blocking_human_checkpoint_false_for_plain_blocking_gate() {
let dir = tempfile::tempdir().unwrap();
let body = format!(
"---\nphase: 91\n---\n\n<task type=\"checkpoint:decision\" gate=\"{PLAIN_GATE_VALUE}\">\n</task>\n"
);
write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
assert!(
!phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
"the plain `blocking` gate (no -human suffix) must not match — Phase 26 near-miss distinction"
);
}
#[test]
fn phase_has_blocking_human_checkpoint_false_when_no_gate_attribute() {
let dir = tempfile::tempdir().unwrap();
write_phase_file(
dir.path(),
"91-probe",
"91-01-PLAN.md",
"---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
);
assert!(!phase_has_blocking_human_checkpoint(
dir.path(),
PhaseId::new(91)
));
}
#[test]
fn phase_has_blocking_human_checkpoint_false_for_missing_phase_directory() {
let dir = tempfile::tempdir().unwrap();
assert!(!phase_has_blocking_human_checkpoint(
dir.path(),
PhaseId::new(404)
));
}
#[test]
fn phase_has_blocking_human_checkpoint_true_when_only_second_plan_carries_attribute() {
let dir = tempfile::tempdir().unwrap();
write_phase_file(
dir.path(),
"91-probe",
"91-01-PLAN.md",
"---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
);
let body = format!(
"---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
);
write_phase_file(dir.path(), "91-probe", "91-02-PLAN.md", &body);
assert!(
phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
"every plan must be inspected, not just the first"
);
}
#[test]
fn phase_has_blocking_human_checkpoint_ignores_non_plan_files() {
let dir = tempfile::tempdir().unwrap();
let body = format!(
"---\nphase: 91\n---\n\nRecorded checkpoint gate=\"{HUMAN_GATE_VALUE}\" in the executor return.\n"
);
write_phase_file(dir.path(), "91-probe", "91-01-SUMMARY.md", &body);
assert!(
!phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
"only *-PLAN.md files are scanned, not SUMMARY/RESEARCH files"
);
}
#[test]
fn phase_has_blocking_human_checkpoint_reads_the_execution_root_in_worktree_mode() {
let dir = tempfile::tempdir().unwrap();
let worktree = dir.path().join("phase-worktree");
std::fs::create_dir_all(&worktree).unwrap();
let body = format!(
"---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
);
write_phase_file(&worktree, "91-probe", "91-01-PLAN.md", &body);
assert!(
phase_has_blocking_human_checkpoint(&worktree, PhaseId::new(91)),
"the execution root holds the PLAN, so the declaration must be found"
);
assert!(
!phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
"opposite-result case: the project root has no PLAN and must return false — \
if both roots returned true, this pair would be measuring the presence of a \
file somewhere rather than which root is read"
);
}
#[test]
fn phase_has_blocking_human_checkpoint_still_reads_the_project_root_without_a_worktree() {
let dir = tempfile::tempdir().unwrap();
let empty_sibling = dir.path().join("phase-worktree");
std::fs::create_dir_all(&empty_sibling).unwrap();
let body = format!(
"---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
);
write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
assert!(
phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
"without a worktree the execution root IS the project root"
);
assert!(
!phase_has_blocking_human_checkpoint(&empty_sibling, PhaseId::new(91)),
"opposite-result case: a root without the PLAN must return false, so the \
assertion above is about which root is read and not about the file existing"
);
}
#[test]
fn human_only_checkpoint_detects_the_gate_marker_on_a_task_tag() {
let dir = tempfile::tempdir().unwrap();
let body = format!(
"---\nphase: 92\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
);
write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
assert!(phase_has_human_only_checkpoint(
dir.path(),
PhaseId::new(92)
));
}
#[test]
fn human_only_checkpoint_detects_the_human_action_type_on_a_task_tag() {
let dir = tempfile::tempdir().unwrap();
let body =
format!("---\nphase: 92\n---\n\n<task type=\"{HUMAN_ACTION_TYPE_VALUE}\">\n</task>\n");
write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
assert!(phase_has_human_only_checkpoint(
dir.path(),
PhaseId::new(92)
));
}
#[test]
fn human_only_checkpoint_ignores_an_ordinary_blocking_gate() {
let dir = tempfile::tempdir().unwrap();
let body = format!(
"---\nphase: 92\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{PLAIN_GATE_VALUE}\">\n</task>\n"
);
write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
assert!(
!phase_has_human_only_checkpoint(dir.path(), PhaseId::new(92)),
"the ordinary `blocking` gate is exactly the class auto-mode may approve \
(checkpoints.md rule 5) — matching it here would refuse launches that are fine"
);
}
#[test]
fn human_only_checkpoint_ignores_a_marker_mentioned_only_in_prose() {
let dir = tempfile::tempdir().unwrap();
let body = format!(
"---\nphase: 92\n---\n\n\
A phase whose PLAN declares `gate=\"{HUMAN_GATE_VALUE}\"` has no mechanism \
for receiving an operator's answer, and the same goes for \
`type=\"{HUMAN_ACTION_TYPE_VALUE}\"`.\n\n\
```text\n\
gate=\"{HUMAN_GATE_VALUE}\"\n\
type=\"{HUMAN_ACTION_TYPE_VALUE}\"\n\
```\n\n\
<task type=\"auto\">\n</task>\n"
);
write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
assert!(
!phase_has_human_only_checkpoint(dir.path(), PhaseId::new(92)),
"a marker discussed in prose is not a marker declared on a task — \
34-04-PLAN.md:245 and 33-02-PLAN.md:109 are the real instances (F-14)"
);
}
#[test]
fn human_only_checkpoint_still_matches_a_task_tag_inside_a_fenced_example() {
let dir = tempfile::tempdir().unwrap();
let body = format!(
"---\nphase: 92\n---\n\n\
Here is what such a task looks like:\n\n\
```xml\n\
<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n\
</task>\n\
```\n"
);
write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
assert!(
phase_has_human_only_checkpoint(dir.path(), PhaseId::new(92)),
"documented limit: line-level anchoring cannot see markdown fences, \
so a complete example task tag reads as a declaration"
);
}
#[test]
fn human_only_checkpoint_is_false_for_a_phase_with_no_plans() {
let dir = tempfile::tempdir().unwrap();
assert!(!phase_has_human_only_checkpoint(
dir.path(),
PhaseId::new(404)
));
assert!(
phase_plan_files(dir.path(), PhaseId::new(404)).is_empty(),
"the companion fact the caller reads to distinguish the two cases"
);
}
}