Skip to main content

devflow_core/
verify.rs

1//! Explicitly operator-approved external post-condition verification.
2//!
3//! Commands are discovered only from PLAN.md YAML frontmatter. Because those
4//! files are agent-writable, execution additionally requires the parent
5//! process's [`TRUST_EXTERNAL_VERIFY_ENV`] authorization.
6
7use crate::phase_id::PhaseId;
8use std::path::{Path, PathBuf};
9
10/// Explicit operator-owned approval for executing PLAN-declared shell.
11pub const TRUST_EXTERNAL_VERIFY_ENV: &str = "DEVFLOW_TRUST_EXTERNAL_VERIFY";
12
13/// Return the exact command bytes approved by the operator.
14///
15/// The value is a JSON string array. Comparing it to the commands reread
16/// after Code closes the review-to-execution TOCTOU: a modified PLAN fails
17/// closed instead of inheriting a blanket boolean authorization.
18pub fn external_verification_approval() -> Option<Vec<String>> {
19    let value = std::env::var(TRUST_EXTERNAL_VERIFY_ENV).ok()?;
20    parse_external_verification_approval(&value)
21}
22
23fn parse_external_verification_approval(value: &str) -> Option<Vec<String>> {
24    let commands = serde_json::from_str::<Vec<String>>(value).ok()?;
25    (!commands.is_empty() && commands.iter().all(|command| !command.trim().is_empty()))
26        .then_some(commands)
27}
28
29/// Discover a phase's declared plan files: `.planning/phases/{NN}-*/{NN}-*-PLAN.md`,
30/// sorted. Returns an empty vec if the phase directory is missing or
31/// unreadable — never panics, never errors.
32///
33/// Shared by [`external_verify_commands`] and
34/// [`phase_has_blocking_human_checkpoint`] so PLAN.md discovery lives in
35/// exactly one place; a second, slightly-different implementation would
36/// drift the moment one is updated and the other isn't.
37pub fn phase_plan_files(project_root: &Path, phase: PhaseId) -> Vec<PathBuf> {
38    let phases_dir = project_root.join(".planning/phases");
39    let phase_prefix = format!("{padded}-", padded = phase.padded());
40    let plan_prefix = format!("{padded}-", padded = phase.padded());
41    let mut plans = Vec::<PathBuf>::new();
42
43    let Ok(phase_entries) = std::fs::read_dir(phases_dir) else {
44        return Vec::new();
45    };
46    for phase_entry in phase_entries.flatten() {
47        if !phase_entry
48            .file_name()
49            .to_string_lossy()
50            .starts_with(&phase_prefix)
51        {
52            continue;
53        }
54        let Ok(plan_entries) = std::fs::read_dir(phase_entry.path()) else {
55            continue;
56        };
57        plans.extend(plan_entries.flatten().filter_map(|entry| {
58            let name = entry.file_name();
59            let name = name.to_string_lossy();
60            (name.starts_with(&plan_prefix) && name.ends_with("-PLAN.md")).then(|| entry.path())
61        }));
62    }
63    plans.sort();
64    plans
65}
66
67/// Return external verification commands declared by this phase's plans.
68///
69/// Only the first YAML frontmatter block is inspected. This intentionally
70/// small parser recognizes the scalar shape established by Phase 16:
71/// `external_verify: "command"` (single-quoted and unquoted scalars are also
72/// accepted). Runtime captures and agent output are never read here.
73pub fn external_verify_commands(project_root: &Path, phase: PhaseId) -> Vec<String> {
74    phase_plan_files(project_root, phase)
75        .into_iter()
76        .filter_map(|path| std::fs::read_to_string(path).ok())
77        .filter_map(|contents| command_from_frontmatter(&contents))
78        .collect()
79}
80
81fn command_from_frontmatter(contents: &str) -> Option<String> {
82    let mut lines = contents.lines();
83    if lines.next()?.trim() != "---" {
84        return None;
85    }
86
87    for line in lines {
88        let line = line.trim();
89        if line == "---" {
90            break;
91        }
92        let Some(value) = line.strip_prefix("external_verify:") else {
93            continue;
94        };
95        let value = value.trim();
96        if value.is_empty() {
97            return None;
98        }
99        let command = if value.starts_with('"') {
100            serde_json::from_str::<String>(value).ok()
101        } else if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
102            Some(value[1..value.len() - 1].replace("''", "'"))
103        } else {
104            Some(value.to_owned())
105        };
106        return command.filter(|command| !command.trim().is_empty());
107    }
108    None
109}
110
111/// Return `true` if any plan declared for `phase` carries a task with the
112/// human-blocking checkpoint gate attribute.
113///
114/// Reads declared plan content only — never any runtime capture or agent
115/// output (D-02: no re-implementation of "what does the agent mean"). This
116/// is the PRIMARY gate for the auto-decide path added in plan 28-03: an
117/// agent cannot route itself into that path for a phase whose plans never
118/// declared a `gate="blocking-human"` checkpoint, because this scan runs
119/// first and is read from `.planning/phases/`. Those files are agent-writable
120/// during Code, but that is the SAME trust boundary [`external_verify_commands`]
121/// already documents and accepts — reused, not newly introduced.
122///
123/// **The CALLER is responsible for passing the root the phase's plans actually
124/// live under** — the execution root (the worktree) in worktree mode, the
125/// project root otherwise. `.planning/` is tracked content, so an in-flight
126/// phase's `{N}-PLAN.md` sits on `feature/phase-{N}` inside the worktree and
127/// is absent from the main checkout; a caller passing the main checkout gets a
128/// silent `false` rather than an error (999.76, ROADMAP criterion 6). The
129/// caller that does this correctly is `pipeline_launch.rs`'s
130/// `Action::GateReview` arm, which resolves `state.worktree_path` first.
131pub fn phase_has_blocking_human_checkpoint(project_root: &Path, phase: PhaseId) -> bool {
132    const HUMAN_BLOCKING_GATE: &str = r#"gate="blocking-human""#;
133    phase_plan_files(project_root, phase)
134        .into_iter()
135        .filter_map(|path| std::fs::read_to_string(path).ok())
136        .any(|contents| contents.contains(HUMAN_BLOCKING_GATE))
137}
138
139/// The two checkpoint markers GSD will not auto-approve in ANY mode, each
140/// assembled from the attribute name, an equals sign and the quoted value
141/// exactly as GSD writes it onto a `<task>` opening tag.
142///
143/// - `gate="blocking-human"` — `checkpoints.md` rule 6: "a checkpoint carrying
144///   this gate stops for a human in *every* mode, including auto-mode,
145///   regardless of its type. Rule 5 does not apply to it."
146/// - `type="checkpoint:human-action"` — `checkpoints.md` rule 5, which
147///   auto-approves human-verify and auto-selects decision but says
148///   "human-action still stops (auth gates cannot be automated)". The type
149///   alone is sufficient; such a task need carry no `gate` attribute.
150///
151/// The CLOSING QUOTE is load-bearing on the first literal. Without it the
152/// match would fire on the ordinary `gate="blocking"` — precisely the
153/// checkpoint class phase 35.1 exists to make auto-approvable — and every
154/// unattended launch of a phase planning one would be refused.
155const HUMAN_ONLY_CHECKPOINT_MARKERS: [&str; 2] = [
156    concat!("gate=", "\"", "blocking-human", "\""),
157    concat!("type=", "\"", "checkpoint:human-action", "\""),
158];
159
160/// The opening bytes of a task element, the anchor every marker match must
161/// also satisfy.
162const TASK_ELEMENT_OPENING: &str = "<task";
163
164/// Return `true` if any plan declared for `phase` DECLARES a checkpoint task
165/// that GSD will not auto-approve in any mode.
166///
167/// **The match is anchored to a task element's own opening tag**, and that is
168/// the whole difference between this function and
169/// [`phase_has_blocking_human_checkpoint`] above: a marker qualifies only on a
170/// line that also opens a `<task`. A plan that merely *discusses* a marker — in
171/// a findings section, in a table, in a fenced example — has not declared one.
172///
173/// The concrete failures the anchoring prevents (F-14) were measured against
174/// this repository's own plan files, not assumed: `34-04-PLAN.md:245` and
175/// `33-02-PLAN.md:109` each quote `gate="blocking-human"` inside a sentence
176/// while declaring no such task. Under an unanchored whole-file `contains`,
177/// `preflight.rs`'s unattended-launch check reads phases 33 and 34 as carrying a
178/// checkpoint no mode can approve and refuses an overnight run that was fine. A
179/// false refusal has no in-product recovery (D-09), so a false positive here is
180/// not a cosmetic defect. (F-14 itself names `35.1-03-PLAN.md` as the instance;
181/// that file describes the markers only in English and never writes either
182/// literal, so it would not match an unanchored scan either. The finding stands;
183/// the example it cited does not.)
184///
185/// **Known limit, pinned by `human_only_checkpoint_still_matches_a_task_tag_
186/// inside_a_fenced_example`:** the anchor is line-level and has no notion of
187/// markdown fences, so a plan documenting a COMPLETE example `<task ...>` tag
188/// inside a fenced block still matches. No plan file in this repository has that
189/// shape today.
190///
191/// **[`phase_has_blocking_human_checkpoint`] is deliberately left alone, and the
192/// pair is not an accident.** That function serves the plan-28-03 auto-decide
193/// route, where a looser, over-inclusive match fails SAFE — it routes more
194/// checkpoints to a human. Here an over-inclusive match fails toward refusing a
195/// launch. Different consequence, different predicate; widening the older one to
196/// serve both would silently change the behaviour its seven tests pin.
197///
198/// Returns `false` for a phase with no plan files at all. That is NOT the same
199/// fact as "plans exist and declare no such checkpoint", and a caller that needs
200/// to tell them apart asks [`phase_plan_files`] separately rather than reading a
201/// third state out of one bit.
202///
203/// **The CALLER owns root resolution**, exactly as for
204/// [`phase_has_blocking_human_checkpoint`]: in worktree mode the phase's plans
205/// live on the feature branch inside the worktree and are absent from the main
206/// checkout (999.76).
207pub fn phase_has_human_only_checkpoint(project_root: &Path, phase: PhaseId) -> bool {
208    phase_plan_files(project_root, phase)
209        .into_iter()
210        .filter_map(|path| std::fs::read_to_string(path).ok())
211        .any(|contents| contents.lines().any(line_declares_human_only_checkpoint))
212}
213
214/// Whether one line both opens a task element and carries a human-only marker.
215fn line_declares_human_only_checkpoint(line: &str) -> bool {
216    line.contains(TASK_ELEMENT_OPENING)
217        && HUMAN_ONLY_CHECKPOINT_MARKERS
218            .iter()
219            .any(|marker| line.contains(marker))
220}
221
222/// Run one explicitly operator-approved external verification command.
223///
224/// `sh -c` is intentional because probes may contain pipelines. The caller
225/// must source `cmd` from [`external_verify_commands`] and first require
226/// [`external_verification_approval`]. Spawn failures and non-zero exits fail
227/// closed.
228pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
229    std::process::Command::new("sh")
230        .arg("-c")
231        .arg(cmd)
232        .current_dir(project_root)
233        .output()
234        .map(|output| output.status.success())
235        .unwrap_or(false)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn write_plan(root: &std::path::Path, contents: &str) {
243        let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
244        std::fs::create_dir_all(&phase_dir).unwrap();
245        std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
246    }
247
248    #[test]
249    fn approval_parser_accepts_only_nonempty_json_command_arrays() {
250        assert_eq!(
251            parse_external_verification_approval(r#"["test -f shipped", "cargo test"]"#),
252            Some(vec!["test -f shipped".into(), "cargo test".into()])
253        );
254        for invalid in [
255            "",
256            "true",
257            "{}",
258            "[]",
259            r#"[""]"#,
260            r#"["   "]"#,
261            r#"["ok", 1]"#,
262        ] {
263            assert_eq!(
264                parse_external_verification_approval(invalid),
265                None,
266                "approval must fail closed for {invalid:?}"
267            );
268        }
269    }
270
271    #[test]
272    fn reads_external_verify_only_from_plan_frontmatter() {
273        let dir = tempfile::tempdir().unwrap();
274        write_plan(
275            dir.path(),
276            "---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
277        );
278        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
279        std::fs::write(
280            dir.path().join(".devflow/phase-16-stdout"),
281            "external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
282        )
283        .unwrap();
284
285        assert_eq!(
286            external_verify_commands(dir.path(), PhaseId::new(16)),
287            vec!["test -f shipped.txt"]
288        );
289    }
290
291    #[test]
292    fn ignores_external_verify_outside_frontmatter() {
293        let dir = tempfile::tempdir().unwrap();
294        write_plan(
295            dir.path(),
296            "---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
297        );
298
299        assert!(external_verify_commands(dir.path(), PhaseId::new(16)).is_empty());
300    }
301
302    #[test]
303    fn ignores_empty_external_verify_commands() {
304        for value in [r#""""#, "''"] {
305            let dir = tempfile::tempdir().unwrap();
306            write_plan(
307                dir.path(),
308                &format!("---\nphase: 16\nexternal_verify: {value}\n---\n"),
309            );
310            assert!(
311                external_verify_commands(dir.path(), PhaseId::new(16)).is_empty(),
312                "empty command {value:?} must not count as affirmative verification"
313            );
314        }
315    }
316
317    #[test]
318    fn runs_probe_from_project_root_and_reports_exit_status() {
319        let dir = tempfile::tempdir().unwrap();
320        std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
321
322        assert!(run_external_verification("test -f shipped.txt", dir.path()));
323        assert!(!run_external_verification(
324            "test -f missing.txt",
325            dir.path()
326        ));
327    }
328
329    // The gate value is assembled at runtime from this const, not written as a
330    // literal in fixture bodies below, so this test file itself never contains
331    // the raw `gate="blocking-human"` string (28-01 Task 2 action note).
332    const HUMAN_GATE_VALUE: &str = "blocking-human";
333    const PLAIN_GATE_VALUE: &str = "blocking";
334    /// The second marker GSD never auto-approves in any mode
335    /// (`checkpoints.md` rule 5: "human-action still stops").
336    const HUMAN_ACTION_TYPE_VALUE: &str = "checkpoint:human-action";
337
338    fn write_phase_file(root: &std::path::Path, phase_dir: &str, file_name: &str, contents: &str) {
339        let dir = root.join(".planning/phases").join(phase_dir);
340        std::fs::create_dir_all(&dir).unwrap();
341        std::fs::write(dir.join(file_name), contents).unwrap();
342    }
343
344    #[test]
345    fn phase_has_blocking_human_checkpoint_detects_declared_gate() {
346        let dir = tempfile::tempdir().unwrap();
347        let body = format!(
348            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
349        );
350        write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
351
352        assert!(phase_has_blocking_human_checkpoint(
353            dir.path(),
354            PhaseId::new(91)
355        ));
356    }
357
358    #[test]
359    fn phase_has_blocking_human_checkpoint_false_for_plain_blocking_gate() {
360        let dir = tempfile::tempdir().unwrap();
361        let body = format!(
362            "---\nphase: 91\n---\n\n<task type=\"checkpoint:decision\" gate=\"{PLAIN_GATE_VALUE}\">\n</task>\n"
363        );
364        write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
365
366        assert!(
367            !phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
368            "the plain `blocking` gate (no -human suffix) must not match — Phase 26 near-miss distinction"
369        );
370    }
371
372    #[test]
373    fn phase_has_blocking_human_checkpoint_false_when_no_gate_attribute() {
374        let dir = tempfile::tempdir().unwrap();
375        write_phase_file(
376            dir.path(),
377            "91-probe",
378            "91-01-PLAN.md",
379            "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
380        );
381
382        assert!(!phase_has_blocking_human_checkpoint(
383            dir.path(),
384            PhaseId::new(91)
385        ));
386    }
387
388    #[test]
389    fn phase_has_blocking_human_checkpoint_false_for_missing_phase_directory() {
390        let dir = tempfile::tempdir().unwrap();
391
392        assert!(!phase_has_blocking_human_checkpoint(
393            dir.path(),
394            PhaseId::new(404)
395        ));
396    }
397
398    #[test]
399    fn phase_has_blocking_human_checkpoint_true_when_only_second_plan_carries_attribute() {
400        let dir = tempfile::tempdir().unwrap();
401        write_phase_file(
402            dir.path(),
403            "91-probe",
404            "91-01-PLAN.md",
405            "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
406        );
407        let body = format!(
408            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
409        );
410        write_phase_file(dir.path(), "91-probe", "91-02-PLAN.md", &body);
411
412        assert!(
413            phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
414            "every plan must be inspected, not just the first"
415        );
416    }
417
418    #[test]
419    fn phase_has_blocking_human_checkpoint_ignores_non_plan_files() {
420        let dir = tempfile::tempdir().unwrap();
421        let body = format!(
422            "---\nphase: 91\n---\n\nRecorded checkpoint gate=\"{HUMAN_GATE_VALUE}\" in the executor return.\n"
423        );
424        write_phase_file(dir.path(), "91-probe", "91-01-SUMMARY.md", &body);
425
426        assert!(
427            !phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
428            "only *-PLAN.md files are scanned, not SUMMARY/RESEARCH files"
429        );
430    }
431
432    /// 999.76 (ROADMAP criterion 6), the second call site.
433    ///
434    /// This function reads whatever root it is handed, so the CALLER decides
435    /// whether it can see the phase's plans at all. `pipeline_launch.rs`'s
436    /// `Action::GateReview` arm passed `project_root` unconditionally; in
437    /// worktree mode the phase's `{N}-PLAN.md` lives on `feature/phase-{N}`
438    /// inside the worktree and is absent from the main checkout, so this
439    /// returned `false` and the plan-28-03 checkpoint auto-decide path was
440    /// silently dead for the phase's whole duration.
441    ///
442    /// This test and its mirror below pin the property that makes the caller's
443    /// choice matter: the answer DEPENDS on the root. Each carries the
444    /// opposite-root assertion, because a pair that returned `true` for both
445    /// roots would only be measuring that a PLAN exists somewhere.
446    #[test]
447    fn phase_has_blocking_human_checkpoint_reads_the_execution_root_in_worktree_mode() {
448        let dir = tempfile::tempdir().unwrap();
449        let worktree = dir.path().join("phase-worktree");
450        std::fs::create_dir_all(&worktree).unwrap();
451        let body = format!(
452            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
453        );
454        // The PLAN exists ONLY inside the worktree — the project root's own
455        // `.planning/phases/` is deliberately never created.
456        write_phase_file(&worktree, "91-probe", "91-01-PLAN.md", &body);
457
458        assert!(
459            phase_has_blocking_human_checkpoint(&worktree, PhaseId::new(91)),
460            "the execution root holds the PLAN, so the declaration must be found"
461        );
462        assert!(
463            !phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
464            "opposite-result case: the project root has no PLAN and must return false — \
465             if both roots returned true, this pair would be measuring the presence of a \
466             file somewhere rather than which root is read"
467        );
468    }
469
470    /// The main-checkout mirror of the test above: with no worktree the two
471    /// roots coincide, so 999.76's call-site change leaves this path untouched.
472    #[test]
473    fn phase_has_blocking_human_checkpoint_still_reads_the_project_root_without_a_worktree() {
474        let dir = tempfile::tempdir().unwrap();
475        let empty_sibling = dir.path().join("phase-worktree");
476        std::fs::create_dir_all(&empty_sibling).unwrap();
477        let body = format!(
478            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
479        );
480        write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
481
482        assert!(
483            phase_has_blocking_human_checkpoint(dir.path(), PhaseId::new(91)),
484            "without a worktree the execution root IS the project root"
485        );
486        assert!(
487            !phase_has_blocking_human_checkpoint(&empty_sibling, PhaseId::new(91)),
488            "opposite-result case: a root without the PLAN must return false, so the \
489             assertion above is about which root is read and not about the file existing"
490        );
491    }
492
493    // -----------------------------------------------------------------
494    // 35.1-03 Task 1: `phase_has_human_only_checkpoint` — the ANCHORED
495    // scan for the two markers GSD never auto-approves in any mode.
496    // -----------------------------------------------------------------
497
498    #[test]
499    fn human_only_checkpoint_detects_the_gate_marker_on_a_task_tag() {
500        let dir = tempfile::tempdir().unwrap();
501        let body = format!(
502            "---\nphase: 92\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
503        );
504        write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
505
506        assert!(phase_has_human_only_checkpoint(
507            dir.path(),
508            PhaseId::new(92)
509        ));
510    }
511
512    #[test]
513    fn human_only_checkpoint_detects_the_human_action_type_on_a_task_tag() {
514        let dir = tempfile::tempdir().unwrap();
515        // No `gate` attribute at all: `checkpoints.md` rule 5 stops a
516        // human-action checkpoint in auto-mode on its TYPE alone, so the
517        // second marker must be sufficient by itself.
518        let body =
519            format!("---\nphase: 92\n---\n\n<task type=\"{HUMAN_ACTION_TYPE_VALUE}\">\n</task>\n");
520        write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
521
522        assert!(phase_has_human_only_checkpoint(
523            dir.path(),
524            PhaseId::new(92)
525        ));
526    }
527
528    /// The control that keeps this function off the checkpoint class phase
529    /// 35.1 exists to make auto-approvable. A substring match that dropped the
530    /// closing quote would match `blocking` inside `blocking-human` and, worse,
531    /// report every ordinary blocking checkpoint as human-only — refusing every
532    /// unattended launch of a phase that plans one.
533    #[test]
534    fn human_only_checkpoint_ignores_an_ordinary_blocking_gate() {
535        let dir = tempfile::tempdir().unwrap();
536        let body = format!(
537            "---\nphase: 92\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{PLAIN_GATE_VALUE}\">\n</task>\n"
538        );
539        write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
540
541        assert!(
542            !phase_has_human_only_checkpoint(dir.path(), PhaseId::new(92)),
543            "the ordinary `blocking` gate is exactly the class auto-mode may approve \
544             (checkpoints.md rule 5) — matching it here would refuse launches that are fine"
545        );
546    }
547
548    /// F-14's motivating case, and it is not hypothetical — but the instance is
549    /// NOT the one F-14 names. `35.1-03-PLAN.md` describes the markers only in
550    /// English ("the human-only gate value") and never writes either literal, so
551    /// it would not match even an unanchored scan. The real instances were found
552    /// by scanning this repository's own `*-PLAN.md` files:
553    ///
554    /// - `34-04-PLAN.md:245` — an acceptance-criteria bullet reading
555    ///   ``A phase whose PLAN declaring `gate="blocking-human"` lives only under
556    ///   a worktree-standing-in directory ...``
557    /// - `33-02-PLAN.md:109` — a findings paragraph reading ``... with
558    ///   `gate="blocking-human"` has no mechanism for receiving an operator's
559    ///   answer ...``
560    ///
561    /// Neither declares such a task; an unanchored scan reports both phases NOT
562    /// viable, refusing an unattended launch for a reason that does not exist.
563    /// Three further files (`19-05`, `19-11`, `15-05`) carry the markers on a
564    /// genuine `<task ...>` line, and both implementations agree on those — which
565    /// is what makes the first two the discriminating cases rather than the whole
566    /// scan being over-eager.
567    ///
568    /// The fixture below mirrors those two shapes: an inline-code mention inside
569    /// a sentence, plus a fenced block carrying the bare attribute with no task
570    /// tag on the line.
571    #[test]
572    fn human_only_checkpoint_ignores_a_marker_mentioned_only_in_prose() {
573        let dir = tempfile::tempdir().unwrap();
574        let body = format!(
575            "---\nphase: 92\n---\n\n\
576             A phase whose PLAN declares `gate=\"{HUMAN_GATE_VALUE}\"` has no mechanism \
577             for receiving an operator's answer, and the same goes for \
578             `type=\"{HUMAN_ACTION_TYPE_VALUE}\"`.\n\n\
579             ```text\n\
580             gate=\"{HUMAN_GATE_VALUE}\"\n\
581             type=\"{HUMAN_ACTION_TYPE_VALUE}\"\n\
582             ```\n\n\
583             <task type=\"auto\">\n</task>\n"
584        );
585        write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
586
587        assert!(
588            !phase_has_human_only_checkpoint(dir.path(), PhaseId::new(92)),
589            "a marker discussed in prose is not a marker declared on a task — \
590             34-04-PLAN.md:245 and 33-02-PLAN.md:109 are the real instances (F-14)"
591        );
592    }
593
594    /// The anchoring's KNOWN LIMIT, asserted rather than left to be discovered.
595    ///
596    /// The anchor is a single line: "contains a marker AND opens a task
597    /// element". It has no notion of markdown fences, so a plan that documents
598    /// a complete example `<task ...>` tag inside a fenced block DOES match, and
599    /// the phase is refused. No `*-PLAN.md` in this repository currently has that
600    /// shape — the three that carry a marker on a `<task` line are all genuine
601    /// declarations — so this is a live gap, not a live defect.
602    ///
603    /// Fence tracking was not added: it is materially more parsing than F-14
604    /// asked for, and it fails toward the SAME consequence (a false refusal) if
605    /// the fence detection is itself wrong. This test exists so the limit is
606    /// recorded as a measured property rather than rediscovered by an operator
607    /// whose overnight run refused.
608    #[test]
609    fn human_only_checkpoint_still_matches_a_task_tag_inside_a_fenced_example() {
610        let dir = tempfile::tempdir().unwrap();
611        let body = format!(
612            "---\nphase: 92\n---\n\n\
613             Here is what such a task looks like:\n\n\
614             ```xml\n\
615             <task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n\
616             </task>\n\
617             ```\n"
618        );
619        write_phase_file(dir.path(), "92-probe", "92-01-PLAN.md", &body);
620
621        assert!(
622            phase_has_human_only_checkpoint(dir.path(), PhaseId::new(92)),
623            "documented limit: line-level anchoring cannot see markdown fences, \
624             so a complete example task tag reads as a declaration"
625        );
626    }
627
628    /// "No plans at all" and "plans that declare no such checkpoint" are
629    /// DIFFERENT facts, and this boolean deliberately reports both as `false`.
630    /// The caller that needs to tell them apart asks [`phase_plan_files`]
631    /// separately rather than reading a third state out of this one bit —
632    /// `preflight.rs`'s unattended-launch check does exactly that, because at
633    /// Define an unplanned phase is pending, not failing.
634    #[test]
635    fn human_only_checkpoint_is_false_for_a_phase_with_no_plans() {
636        let dir = tempfile::tempdir().unwrap();
637
638        assert!(!phase_has_human_only_checkpoint(
639            dir.path(),
640            PhaseId::new(404)
641        ));
642        assert!(
643            phase_plan_files(dir.path(), PhaseId::new(404)).is_empty(),
644            "the companion fact the caller reads to distinguish the two cases"
645        );
646    }
647}