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 {
131 const HUMAN_BLOCKING_GATE: &str = r#"gate="blocking-human""#;
132 phase_plan_files(project_root, phase)
133 .into_iter()
134 .filter_map(|path| std::fs::read_to_string(path).ok())
135 .any(|contents| contents.contains(HUMAN_BLOCKING_GATE))
136}
137
138pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
145 std::process::Command::new("sh")
146 .arg("-c")
147 .arg(cmd)
148 .current_dir(project_root)
149 .output()
150 .map(|output| output.status.success())
151 .unwrap_or(false)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn write_plan(root: &std::path::Path, contents: &str) {
159 let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
160 std::fs::create_dir_all(&phase_dir).unwrap();
161 std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
162 }
163
164 #[test]
165 fn approval_parser_accepts_only_nonempty_json_command_arrays() {
166 assert_eq!(
167 parse_external_verification_approval(r#"["test -f shipped", "cargo test"]"#),
168 Some(vec!["test -f shipped".into(), "cargo test".into()])
169 );
170 for invalid in [
171 "",
172 "true",
173 "{}",
174 "[]",
175 r#"[""]"#,
176 r#"[" "]"#,
177 r#"["ok", 1]"#,
178 ] {
179 assert_eq!(
180 parse_external_verification_approval(invalid),
181 None,
182 "approval must fail closed for {invalid:?}"
183 );
184 }
185 }
186
187 #[test]
188 fn reads_external_verify_only_from_plan_frontmatter() {
189 let dir = tempfile::tempdir().unwrap();
190 write_plan(
191 dir.path(),
192 "---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
193 );
194 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
195 std::fs::write(
196 dir.path().join(".devflow/phase-16-stdout"),
197 "external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
198 )
199 .unwrap();
200
201 assert_eq!(
202 external_verify_commands(dir.path(), 16),
203 vec!["test -f shipped.txt"]
204 );
205 }
206
207 #[test]
208 fn ignores_external_verify_outside_frontmatter() {
209 let dir = tempfile::tempdir().unwrap();
210 write_plan(
211 dir.path(),
212 "---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
213 );
214
215 assert!(external_verify_commands(dir.path(), 16).is_empty());
216 }
217
218 #[test]
219 fn ignores_empty_external_verify_commands() {
220 for value in [r#""""#, "''"] {
221 let dir = tempfile::tempdir().unwrap();
222 write_plan(
223 dir.path(),
224 &format!("---\nphase: 16\nexternal_verify: {value}\n---\n"),
225 );
226 assert!(
227 external_verify_commands(dir.path(), 16).is_empty(),
228 "empty command {value:?} must not count as affirmative verification"
229 );
230 }
231 }
232
233 #[test]
234 fn runs_probe_from_project_root_and_reports_exit_status() {
235 let dir = tempfile::tempdir().unwrap();
236 std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
237
238 assert!(run_external_verification("test -f shipped.txt", dir.path()));
239 assert!(!run_external_verification(
240 "test -f missing.txt",
241 dir.path()
242 ));
243 }
244
245 const HUMAN_GATE_VALUE: &str = "blocking-human";
249 const PLAIN_GATE_VALUE: &str = "blocking";
250
251 fn write_phase_file(root: &std::path::Path, phase_dir: &str, file_name: &str, contents: &str) {
252 let dir = root.join(".planning/phases").join(phase_dir);
253 std::fs::create_dir_all(&dir).unwrap();
254 std::fs::write(dir.join(file_name), contents).unwrap();
255 }
256
257 #[test]
258 fn phase_has_blocking_human_checkpoint_detects_declared_gate() {
259 let dir = tempfile::tempdir().unwrap();
260 let body = format!(
261 "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
262 );
263 write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
264
265 assert!(phase_has_blocking_human_checkpoint(dir.path(), 91));
266 }
267
268 #[test]
269 fn phase_has_blocking_human_checkpoint_false_for_plain_blocking_gate() {
270 let dir = tempfile::tempdir().unwrap();
271 let body = format!(
272 "---\nphase: 91\n---\n\n<task type=\"checkpoint:decision\" gate=\"{PLAIN_GATE_VALUE}\">\n</task>\n"
273 );
274 write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
275
276 assert!(
277 !phase_has_blocking_human_checkpoint(dir.path(), 91),
278 "the plain `blocking` gate (no -human suffix) must not match — Phase 26 near-miss distinction"
279 );
280 }
281
282 #[test]
283 fn phase_has_blocking_human_checkpoint_false_when_no_gate_attribute() {
284 let dir = tempfile::tempdir().unwrap();
285 write_phase_file(
286 dir.path(),
287 "91-probe",
288 "91-01-PLAN.md",
289 "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
290 );
291
292 assert!(!phase_has_blocking_human_checkpoint(dir.path(), 91));
293 }
294
295 #[test]
296 fn phase_has_blocking_human_checkpoint_false_for_missing_phase_directory() {
297 let dir = tempfile::tempdir().unwrap();
298
299 assert!(!phase_has_blocking_human_checkpoint(dir.path(), 404));
300 }
301
302 #[test]
303 fn phase_has_blocking_human_checkpoint_true_when_only_second_plan_carries_attribute() {
304 let dir = tempfile::tempdir().unwrap();
305 write_phase_file(
306 dir.path(),
307 "91-probe",
308 "91-01-PLAN.md",
309 "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
310 );
311 let body = format!(
312 "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
313 );
314 write_phase_file(dir.path(), "91-probe", "91-02-PLAN.md", &body);
315
316 assert!(
317 phase_has_blocking_human_checkpoint(dir.path(), 91),
318 "every plan must be inspected, not just the first"
319 );
320 }
321
322 #[test]
323 fn phase_has_blocking_human_checkpoint_ignores_non_plan_files() {
324 let dir = tempfile::tempdir().unwrap();
325 let body = format!(
326 "---\nphase: 91\n---\n\nRecorded checkpoint gate=\"{HUMAN_GATE_VALUE}\" in the executor return.\n"
327 );
328 write_phase_file(dir.path(), "91-probe", "91-01-SUMMARY.md", &body);
329
330 assert!(
331 !phase_has_blocking_human_checkpoint(dir.path(), 91),
332 "only *-PLAN.md files are scanned, not SUMMARY/RESEARCH files"
333 );
334 }
335
336 #[test]
351 fn phase_has_blocking_human_checkpoint_reads_the_execution_root_in_worktree_mode() {
352 let dir = tempfile::tempdir().unwrap();
353 let worktree = dir.path().join("phase-worktree");
354 std::fs::create_dir_all(&worktree).unwrap();
355 let body = format!(
356 "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
357 );
358 write_phase_file(&worktree, "91-probe", "91-01-PLAN.md", &body);
361
362 assert!(
363 phase_has_blocking_human_checkpoint(&worktree, 91),
364 "the execution root holds the PLAN, so the declaration must be found"
365 );
366 assert!(
367 !phase_has_blocking_human_checkpoint(dir.path(), 91),
368 "opposite-result case: the project root has no PLAN and must return false — \
369 if both roots returned true, this pair would be measuring the presence of a \
370 file somewhere rather than which root is read"
371 );
372 }
373
374 #[test]
377 fn phase_has_blocking_human_checkpoint_still_reads_the_project_root_without_a_worktree() {
378 let dir = tempfile::tempdir().unwrap();
379 let empty_sibling = dir.path().join("phase-worktree");
380 std::fs::create_dir_all(&empty_sibling).unwrap();
381 let body = format!(
382 "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
383 );
384 write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
385
386 assert!(
387 phase_has_blocking_human_checkpoint(dir.path(), 91),
388 "without a worktree the execution root IS the project root"
389 );
390 assert!(
391 !phase_has_blocking_human_checkpoint(&empty_sibling, 91),
392 "opposite-result case: a root without the PLAN must return false, so the \
393 assertion above is about which root is read and not about the file existing"
394 );
395 }
396}