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}