1use std::path::{Path, PathBuf};
8
9pub const TRUST_EXTERNAL_VERIFY_ENV: &str = "DEVFLOW_TRUST_EXTERNAL_VERIFY";
11
12pub fn external_verification_approval() -> Option<Vec<String>> {
18 let value = std::env::var(TRUST_EXTERNAL_VERIFY_ENV).ok()?;
19 parse_external_verification_approval(&value)
20}
21
22fn parse_external_verification_approval(value: &str) -> Option<Vec<String>> {
23 let commands = serde_json::from_str::<Vec<String>>(value).ok()?;
24 (!commands.is_empty() && commands.iter().all(|command| !command.trim().is_empty()))
25 .then_some(commands)
26}
27
28pub fn phase_plan_files(project_root: &Path, phase: u32) -> Vec<PathBuf> {
37 let phases_dir = project_root.join(".planning/phases");
38 let phase_prefix = format!("{phase:02}-");
39 let plan_prefix = format!("{phase:02}-");
40 let mut plans = Vec::<PathBuf>::new();
41
42 let Ok(phase_entries) = std::fs::read_dir(phases_dir) else {
43 return Vec::new();
44 };
45 for phase_entry in phase_entries.flatten() {
46 if !phase_entry
47 .file_name()
48 .to_string_lossy()
49 .starts_with(&phase_prefix)
50 {
51 continue;
52 }
53 let Ok(plan_entries) = std::fs::read_dir(phase_entry.path()) else {
54 continue;
55 };
56 plans.extend(plan_entries.flatten().filter_map(|entry| {
57 let name = entry.file_name();
58 let name = name.to_string_lossy();
59 (name.starts_with(&plan_prefix) && name.ends_with("-PLAN.md")).then(|| entry.path())
60 }));
61 }
62 plans.sort();
63 plans
64}
65
66pub fn external_verify_commands(project_root: &Path, phase: u32) -> Vec<String> {
73 phase_plan_files(project_root, phase)
74 .into_iter()
75 .filter_map(|path| std::fs::read_to_string(path).ok())
76 .filter_map(|contents| command_from_frontmatter(&contents))
77 .collect()
78}
79
80fn command_from_frontmatter(contents: &str) -> Option<String> {
81 let mut lines = contents.lines();
82 if lines.next()?.trim() != "---" {
83 return None;
84 }
85
86 for line in lines {
87 let line = line.trim();
88 if line == "---" {
89 break;
90 }
91 let Some(value) = line.strip_prefix("external_verify:") else {
92 continue;
93 };
94 let value = value.trim();
95 if value.is_empty() {
96 return None;
97 }
98 let command = if value.starts_with('"') {
99 serde_json::from_str::<String>(value).ok()
100 } else if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
101 Some(value[1..value.len() - 1].replace("''", "'"))
102 } else {
103 Some(value.to_owned())
104 };
105 return command.filter(|command| !command.trim().is_empty());
106 }
107 None
108}
109
110pub fn phase_has_blocking_human_checkpoint(project_root: &Path, phase: u32) -> bool {
122 const HUMAN_BLOCKING_GATE: &str = r#"gate="blocking-human""#;
123 phase_plan_files(project_root, phase)
124 .into_iter()
125 .filter_map(|path| std::fs::read_to_string(path).ok())
126 .any(|contents| contents.contains(HUMAN_BLOCKING_GATE))
127}
128
129pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
136 std::process::Command::new("sh")
137 .arg("-c")
138 .arg(cmd)
139 .current_dir(project_root)
140 .output()
141 .map(|output| output.status.success())
142 .unwrap_or(false)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 fn write_plan(root: &std::path::Path, contents: &str) {
150 let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
151 std::fs::create_dir_all(&phase_dir).unwrap();
152 std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
153 }
154
155 #[test]
156 fn approval_parser_accepts_only_nonempty_json_command_arrays() {
157 assert_eq!(
158 parse_external_verification_approval(r#"["test -f shipped", "cargo test"]"#),
159 Some(vec!["test -f shipped".into(), "cargo test".into()])
160 );
161 for invalid in [
162 "",
163 "true",
164 "{}",
165 "[]",
166 r#"[""]"#,
167 r#"[" "]"#,
168 r#"["ok", 1]"#,
169 ] {
170 assert_eq!(
171 parse_external_verification_approval(invalid),
172 None,
173 "approval must fail closed for {invalid:?}"
174 );
175 }
176 }
177
178 #[test]
179 fn reads_external_verify_only_from_plan_frontmatter() {
180 let dir = tempfile::tempdir().unwrap();
181 write_plan(
182 dir.path(),
183 "---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
184 );
185 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
186 std::fs::write(
187 dir.path().join(".devflow/phase-16-stdout"),
188 "external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
189 )
190 .unwrap();
191
192 assert_eq!(
193 external_verify_commands(dir.path(), 16),
194 vec!["test -f shipped.txt"]
195 );
196 }
197
198 #[test]
199 fn ignores_external_verify_outside_frontmatter() {
200 let dir = tempfile::tempdir().unwrap();
201 write_plan(
202 dir.path(),
203 "---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
204 );
205
206 assert!(external_verify_commands(dir.path(), 16).is_empty());
207 }
208
209 #[test]
210 fn ignores_empty_external_verify_commands() {
211 for value in [r#""""#, "''"] {
212 let dir = tempfile::tempdir().unwrap();
213 write_plan(
214 dir.path(),
215 &format!("---\nphase: 16\nexternal_verify: {value}\n---\n"),
216 );
217 assert!(
218 external_verify_commands(dir.path(), 16).is_empty(),
219 "empty command {value:?} must not count as affirmative verification"
220 );
221 }
222 }
223
224 #[test]
225 fn runs_probe_from_project_root_and_reports_exit_status() {
226 let dir = tempfile::tempdir().unwrap();
227 std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
228
229 assert!(run_external_verification("test -f shipped.txt", dir.path()));
230 assert!(!run_external_verification(
231 "test -f missing.txt",
232 dir.path()
233 ));
234 }
235
236 const HUMAN_GATE_VALUE: &str = "blocking-human";
240 const PLAIN_GATE_VALUE: &str = "blocking";
241
242 fn write_phase_file(root: &std::path::Path, phase_dir: &str, file_name: &str, contents: &str) {
243 let dir = root.join(".planning/phases").join(phase_dir);
244 std::fs::create_dir_all(&dir).unwrap();
245 std::fs::write(dir.join(file_name), contents).unwrap();
246 }
247
248 #[test]
249 fn phase_has_blocking_human_checkpoint_detects_declared_gate() {
250 let dir = tempfile::tempdir().unwrap();
251 let body = format!(
252 "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
253 );
254 write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
255
256 assert!(phase_has_blocking_human_checkpoint(dir.path(), 91));
257 }
258
259 #[test]
260 fn phase_has_blocking_human_checkpoint_false_for_plain_blocking_gate() {
261 let dir = tempfile::tempdir().unwrap();
262 let body = format!(
263 "---\nphase: 91\n---\n\n<task type=\"checkpoint:decision\" gate=\"{PLAIN_GATE_VALUE}\">\n</task>\n"
264 );
265 write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
266
267 assert!(
268 !phase_has_blocking_human_checkpoint(dir.path(), 91),
269 "the plain `blocking` gate (no -human suffix) must not match — Phase 26 near-miss distinction"
270 );
271 }
272
273 #[test]
274 fn phase_has_blocking_human_checkpoint_false_when_no_gate_attribute() {
275 let dir = tempfile::tempdir().unwrap();
276 write_phase_file(
277 dir.path(),
278 "91-probe",
279 "91-01-PLAN.md",
280 "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
281 );
282
283 assert!(!phase_has_blocking_human_checkpoint(dir.path(), 91));
284 }
285
286 #[test]
287 fn phase_has_blocking_human_checkpoint_false_for_missing_phase_directory() {
288 let dir = tempfile::tempdir().unwrap();
289
290 assert!(!phase_has_blocking_human_checkpoint(dir.path(), 404));
291 }
292
293 #[test]
294 fn phase_has_blocking_human_checkpoint_true_when_only_second_plan_carries_attribute() {
295 let dir = tempfile::tempdir().unwrap();
296 write_phase_file(
297 dir.path(),
298 "91-probe",
299 "91-01-PLAN.md",
300 "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
301 );
302 let body = format!(
303 "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
304 );
305 write_phase_file(dir.path(), "91-probe", "91-02-PLAN.md", &body);
306
307 assert!(
308 phase_has_blocking_human_checkpoint(dir.path(), 91),
309 "every plan must be inspected, not just the first"
310 );
311 }
312
313 #[test]
314 fn phase_has_blocking_human_checkpoint_ignores_non_plan_files() {
315 let dir = tempfile::tempdir().unwrap();
316 let body = format!(
317 "---\nphase: 91\n---\n\nRecorded checkpoint gate=\"{HUMAN_GATE_VALUE}\" in the executor return.\n"
318 );
319 write_phase_file(dir.path(), "91-probe", "91-01-SUMMARY.md", &body);
320
321 assert!(
322 !phase_has_blocking_human_checkpoint(dir.path(), 91),
323 "only *-PLAN.md files are scanned, not SUMMARY/RESEARCH files"
324 );
325 }
326}