devflow-core 2.5.0

Opinionated AI-driven development workflow state machine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
//! Stage-specific agent prompts.
//!
//! Prompts are minimal: each stage hands the agent its GSD slash command
//! (from [`Stage::gsd_command`]) and the `DEVFLOW_RESULT` completion contract.
//! There is no long instruction template — the GSD command carries the process,
//! and DevFlow only needs the structured completion marker back.

use crate::phase_id::PhaseId;
use crate::stage::Stage;
use std::path::Path;

const SHIP_REVIEW_ANGLES: &[&str] = &[
    "doc-accuracy cross-reference (do documented claims match source?)",
    "security / leaked-data (does anything commit secrets, session data, or telemetry?)",
    "CI/build correctness (can a failing step still report green?)",
    "external-state claims (does the diff claim merges, tags, or deletions that are not actually true?)",
    "one generalist deep pass",
];

/// The token that stops GSD from wiping `workflow._auto_chain_active` before
/// it is read (35.1-01, RESEARCH Pitfall 1).
///
/// `execute-phase.md:161-165` clears that flag at the top of every invocation
/// whose `$ARGUMENTS` does not carry this token. DevFlow sets the flag in the
/// monitor immediately before launching the child, so without this the flag is
/// wiped by the very command it was set for and `checkpoint_handling` never
/// auto-approves anything.
///
/// **The token alone enables nothing.** GSD's `check auto-mode` reads
/// `.planning/config.json` and nothing else — a full grep of
/// `execute-phase.md` for `--auto` / `--chain` / `AUTO_CHAIN` / `auto_advance`
/// returns exactly three hits, all inside that sync-clear block. So within
/// `execute-phase.md` this token's only effect is to skip the clear. The
/// mode gate that decides whether checkpoints may actually be auto-approved
/// lives on the config write, in `pipeline_launch::auto_chain_flag_eligible`.
///
/// Named rather than inlined so the three command strings that must carry it
/// (Code, and `fix_prompt`'s two `execute-phase` arms) cannot drift apart, and
/// so a reader of any one of them can find this explanation.
const AUTO_CHAIN_PRESERVING_FLAG: &str = "--auto";

/// The completion contract every agent must honor as its final message.
const COMPLETION_PROTOCOL: &str = "\
## Completion Protocol (REQUIRED)\n\
\n\
When all work is done, your FINAL message must be exactly:\n\
\n\
DEVFLOW_RESULT: {\"status\": \"success\"}\n\
\n\
If something prevents completion:\n\
\n\
DEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"specific explanation\"}\n\
\n\
DevFlow reads this line to decide whether the stage succeeded. \
Output nothing after it.";

/// A fix variant used when looping Code ↔ Validate.
///
/// `#[non_exhaustive]`: operator decision, 2026-08-04. This enum is public in
/// the published `devflow-core` crate, so adding a variant is already a
/// breaking change for any external crate matching on it exhaustively — this
/// release (`FullExecute`, added for D-01) already pays that cost. Paying for
/// `#[non_exhaustive]` at the same time makes every later variant addition
/// additive instead of breaking again, the same reasoning `State` records for
/// its own `#[non_exhaustive]` (`state.rs:30-31`). Verified empirically before
/// applying: the only `match` over a `FixType` value anywhere in the
/// workspace is `fix_prompt` below, which lives in this crate and is
/// therefore unaffected by the attribute — no wildcard arm is needed, here or
/// anywhere else in the workspace, and none should be added to `fix_prompt`
/// itself (see its doc comment).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FixType {
    /// Run the GSD audit-fix pipeline over review findings.
    AuditFix,
    /// Re-run execution targeting only the gaps left by validation.
    GapsOnly,
    /// Re-run the phase's remaining plans with the plain, unflagged
    /// `/gsd-execute-phase {N}` command, because the phase is mid-arc rather
    /// than defective — D-01 (33-CONTEXT.md): a phase with no
    /// `{N}-VERIFICATION.md` yet has not been judged, so `--gaps-only` would
    /// match zero plans and gate unresolvably.
    FullExecute,
}

/// Substitute the `{N}` phase placeholder in a GSD command string.
fn gsd_command_for(stage: Stage, phase: PhaseId) -> String {
    stage.gsd_command().replace("{N}", &phase.to_string())
}

/// The Ship stage's dedicated prompt.
///
/// Headless-safety rationale: `/gsd-ship`'s own `optional_review` step is an
/// interactive `AskUserQuestion` with undefined behavior under
/// `--dangerously-skip-permissions` (RESEARCH Pitfall 2). Rather than relying
/// on that step being skipped, this prompt sidesteps it entirely: the agent
/// runs `/gsd-code-review {N}` first (non-interactive; writes `REVIEW.md`
/// with severity-classified findings), and MUST NOT run `/gsd-ship {N}` at
/// all if `REVIEW.md` contains any Critical-severity finding — instead it
/// reports a `review:`-prefixed failure. Only a clean (no-Critical) review
/// proceeds to `/gsd-ship {N}`. The `review:` reason prefix is the
/// ReviewFailed contract that `handle_ship_failure` matches (trimmed,
/// case-folded) to loop back to Code with `AuditFix`.
fn ship_stage_prompt(phase: PhaseId, review_angles: &[String]) -> String {
    let code_review = format!("/gsd-code-review {phase}");
    let ship = format!("/gsd-ship {phase}");
    let review_angles = review_angles
        .iter()
        .map(|angle| format!("- {angle}"))
        .collect::<Vec<_>>()
        .join("\n");
    format!(
        "Run the Ship stage in two steps:\n\
        \n\
        1. Run `{code_review}` (non-interactive). This writes a `REVIEW.md` \
        artifact with severity-classified findings. Review at high depth from \
        every angle below:\n\
        \n\
        {review_angles}\n\
        \n\
        If your harness supports parallel finder subagents, dispatch one per \
        angle; otherwise run each angle as a focused sequential pass. Merge \
        and deduplicate every angle's findings into one `REVIEW.md`.\n\
        2. Check `REVIEW.md` for the Critical-severity gate:\n\
        \n\
        - If `REVIEW.md` contains ANY finding at Critical severity: do NOT \
        run `{ship}` at all. Your FINAL message must be exactly:\n\
        \n\
        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"review: <short summary of the Critical findings>\"}}\n\
        \n\
        - If `REVIEW.md` has NO Critical-severity findings: run `{ship}` and \
        report the outcome via the normal completion protocol below.\n\
        \n\
        {COMPLETION_PROTOCOL}"
    )
}

/// The Validate stage's dedicated prompt.
///
/// 13b verdict-vs-ran: `status` only reports whether the stage's task (running
/// `/gsd-validate-phase {N}`) completed — it says nothing about whether
/// validation itself passed. This prompt REQUIRES a distinct `verdict` field
/// so `advance()`'s Validate arm can tell "the agent ran validation" apart
/// from "validation passed," and never advances to Ship on a bare `status:
/// success` for this stage.
fn validate_stage_prompt(phase: PhaseId) -> String {
    let command = gsd_command_for(Stage::Validate, phase);
    format!(
        "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
        ## Completion Protocol (REQUIRED)\n\
        \n\
        When all work is done, your FINAL message must be exactly one of:\n\
        \n\
        DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"pass\"}}\n\
        \n\
        if validation found NO gaps, or:\n\
        \n\
        DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"gaps\"}}\n\
        \n\
        if validation found gaps that still need fixing. The `verdict` field \
        is REQUIRED for this stage — it is distinct from `status` (which only \
        reports whether the validation task itself completed) and MUST be \
        exactly the lowercase string `pass` or `gaps`.\n\
        \n\
        If something prevents completion:\n\
        \n\
        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"specific explanation\"}}\n\
        \n\
        DevFlow reads this line to decide whether the stage succeeded. \
        Output nothing after it."
    )
}

/// The Plan stage's idempotency contract.
///
/// Headless-safety rationale (13-06 dogfood finding, Codex leg): GSD's
/// plan-phase demands an interactive "Overwrite/Append/Cancel" decision
/// when the phase's PLAN.md already exists, and headless Codex cannot
/// answer it (`request_user_input is unavailable`) — the stage would fail on
/// every retry, forever. When the stage's deliverable already exists, the
/// stage's work is done: re-running it must be a no-op success, not an
/// interactive dead end. This is idempotency for a completed stage, NOT the
/// v1 skip-stage config flags removed by the 2026-06-19 architecture
/// decision — a stage with no pre-existing artifact still runs in full.
///
/// D-14 update: Define used to share this branch (dispatched by a `stage`
/// parameter), but its missing-artifact arm ran an interactive interview
/// command that cannot be answered headlessly. That branch was deleted
/// rather than made conditional — see [`define_stage_prompt`] for Define's
/// actual (always-no-op) contract. This function now serves only Plan.
fn idempotent_stage_prompt(phase: PhaseId) -> String {
    let artifact = "PLAN.md";
    let command = gsd_command_for(Stage::Plan, phase);
    let padded = phase.padded();
    format!(
        "First check whether this stage's deliverable already exists:\n\
        \n\
        ls .planning/phases/{padded}-*/{padded}-*{artifact} 2>/dev/null\n\
        \n\
        - If it EXISTS: the stage's work is already done. Do NOT run the GSD \
        command, do NOT ask for input, and do NOT modify the existing \
        artifacts. Your FINAL message must be exactly:\n\
        \n\
        DEVFLOW_RESULT: {{\"status\": \"success\"}}\n\
        \n\
        - If it does NOT exist: run the GSD workflow command for this stage:\n\
        \n\
        \x20   {command}\n\
        \n\
        {COMPLETION_PROTOCOL}"
    )
}

/// The Define stage's dedicated prompt (D-14).
///
/// Headless-safety rationale: `idempotent_stage_prompt`'s missing-artifact
/// arm used to run an interactive interview command for Define — one that
/// hangs or errors under `claude -p` with no operator present to answer it
/// (T-28-08). D-14 settles the fix as deletion, not disambiguation: the
/// Define stage never runs that command in a DevFlow launch, whether or not
/// CONTEXT.md already exists. The operator decides whether to run the
/// interview before invoking `devflow start`; DevFlow makes no runtime
/// accommodation for that choice.
fn define_stage_prompt(phase: PhaseId) -> String {
    format!(
        "This is the Define stage of a headless DevFlow run for phase {phase}.\n\
        \n\
        There is no agent work to perform here. Whether or not this phase's \
        CONTEXT.md already exists, you must NOT run an interactive \
        discuss-phase or interview command, and you must NOT ask for input \
        — this run is headless and no operator is available to answer \
        interactive questions. Do NOT modify any existing planning \
        artifacts.\n\
        \n\
        {COMPLETION_PROTOCOL}"
    )
}

/// Build the prompt for a stage of a phase.
pub fn stage_prompt(stage: Stage, phase: PhaseId) -> String {
    stage_prompt_with_project(stage, phase, None)
}

/// Build a stage prompt with project-local configuration applied.
///
/// The CLI uses this entry point after resolving the canonical project root;
/// library callers that have no project context keep using [`stage_prompt`]
/// and receive built-in defaults.
pub fn stage_prompt_for_project(stage: Stage, phase: PhaseId, project_root: &Path) -> String {
    stage_prompt_with_project(stage, phase, Some(project_root))
}

fn stage_prompt_with_project(stage: Stage, phase: PhaseId, project_root: Option<&Path>) -> String {
    if stage == Stage::Ship {
        let review_angles = project_root
            .and_then(crate::config::review_angles)
            .unwrap_or_else(|| {
                SHIP_REVIEW_ANGLES
                    .iter()
                    .map(|angle| (*angle).to_owned())
                    .collect()
            });
        return ship_stage_prompt(phase, &review_angles);
    }
    if stage == Stage::Validate {
        return validate_stage_prompt(phase);
    }
    if stage == Stage::Define {
        return define_stage_prompt(phase);
    }
    if stage == Stage::Plan {
        return idempotent_stage_prompt(phase);
    }
    let command = gsd_command_for(stage, phase);
    if stage == Stage::Code {
        // The Code arm ONLY (D-04/D-05, 35.1-01 Pitfall 1). `execute-phase.md`
        // wipes `workflow._auto_chain_active` at the top of every invocation
        // whose `$ARGUMENTS` lacks this token, which would clear the flag
        // DevFlow just set before `checkpoint_handling` ever reads it. Within
        // `execute-phase.md` the token's only effect is to skip that clear — it
        // does not chain and it sets nothing, so it is safe to send
        // unconditionally here even though the config write that gives it
        // meaning is gated on `Mode::Auto` (F-2).
        //
        // Deliberately NOT applied to `gsd_command_for` or
        // `Stage::gsd_command`: both are shared with Plan via
        // `idempotent_stage_prompt`, and the same flag makes `plan-phase.md`
        // chain into `execute-phase.md`. Leaking the token into the Plan prompt
        // is precisely the D-04 defect.
        let command = format!("{command} {AUTO_CHAIN_PRESERVING_FLAG}");
        return format!(
            "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
            ## Advisory incremental self-review\n\
            \n\
            After each plan or wave lands, perform a quick, shallow self-check \
            for doc accuracy, leaked data, CI/build correctness, and \
            external-state claims. Record any drift in the working output and \
            continue execution; the authoritative review happens during Ship. \
            This check must not pause execution or request human input.\n\
            \n\
            {COMPLETION_PROTOCOL}"
        );
    }
    format!(
        "Run the GSD workflow command for this stage:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
    )
}

/// The synthesized instruction sent into a resumed Claude session when a
/// confirmed human-blocking checkpoint has nobody available to answer it
/// (D-03, 28-CONTEXT.md): DevFlow's default, unconditional policy — no flag,
/// no config toggle — is for the agent to resolve the checkpoint itself,
/// using its own judgment, and record why.
///
/// Deliberately deterministic: no timestamp, no random content, no varying
/// state. Two calls for the same `phase` produce byte-identical strings, so
/// the `checkpoint_auto_decided` audit event (D-07, plan 28-03) can quote
/// this exact instruction without churning on every resume. `phase` is
/// included only for operator legibility in the captured stdout — the
/// instruction's meaning does not depend on it.
pub fn checkpoint_auto_decide_prompt(phase: PhaseId) -> String {
    format!(
        "This is phase {phase} of a headless DevFlow run. You previously \
        stopped at a human-blocking checkpoint, but no human operator is \
        available to answer it — this run is unattended, and none is \
        coming. DevFlow's policy is for you to resolve the checkpoint \
        yourself, using your own best judgment, and continue the work. You \
        MUST record your reasoning for the decision you made in your final \
        message, so the decision is auditable after the fact.\n\
        \n\
        {COMPLETION_PROTOCOL}"
    )
}

/// Build a fix prompt used on Code → Validate loop-backs.
///
/// Both arms that dispatch to `execute-phase` carry
/// [`AUTO_CHAIN_PRESERVING_FLAG`], for the same reason the Code prompt does:
/// they reach `execute-phase.md`'s sync-clear step, and without the token that
/// step wipes the chain flag DevFlow just set. The `--gaps-only` loop is
/// named explicitly by ROADMAP criterion 1 — a fix pass gets exactly the same
/// treatment as the first Code pass, or the phase's unattended behaviour
/// changes the moment validation reports a gap.
///
/// `AuditFix` is deliberately left alone: it routes to `/gsd-audit-fix`, never
/// reaches `execute-phase.md`, and so never meets the sync-clear step.
///
/// Flag ORDER within the command string does not matter — GSD extracts
/// `--`-prefixed tokens position-independently
/// (`references/phase-argument-parsing.md`).
pub fn fix_prompt(fix_type: FixType, phase: PhaseId) -> String {
    let command = match fix_type {
        FixType::AuditFix => format!("/gsd-audit-fix {phase}"),
        FixType::GapsOnly => {
            format!("/gsd-execute-phase {phase} --gaps-only {AUTO_CHAIN_PRESERVING_FLAG}")
        }
        FixType::FullExecute => {
            format!("/gsd-execute-phase {phase} {AUTO_CHAIN_PRESERVING_FLAG}")
        }
    };
    format!(
        "Validation reported issues. Run the fix command for this loop:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn each_stage_prompt_carries_its_gsd_command_and_marker() {
        // Define is excluded here (D-14): its prompt never contains its GSD
        // command — see `define_prompt_never_invokes_discuss_phase` below.
        let cases = [
            (Stage::Plan, "/gsd-plan-phase 11"),
            (Stage::Code, "/gsd-execute-phase 11"),
            (Stage::Validate, "/gsd-validate-phase 11"),
            (Stage::Ship, "/gsd-ship 11"),
        ];
        for (stage, command) in cases {
            let prompt = stage_prompt(stage, PhaseId::new(11));
            assert!(prompt.contains(command), "{stage} prompt missing {command}");
            assert!(prompt.contains("DEVFLOW_RESULT"));
        }
    }

    #[test]
    fn phase_placeholder_is_substituted() {
        assert!(stage_prompt(Stage::Code, PhaseId::new(7)).contains("/gsd-execute-phase 7"));
        assert!(!stage_prompt(Stage::Code, PhaseId::new(7)).contains("{N}"));
    }

    #[test]
    fn ship_prompt_sequences_code_review_before_ship() {
        let prompt = stage_prompt(Stage::Ship, PhaseId::new(13));
        let review_pos = prompt
            .find("/gsd-code-review 13")
            .expect("Ship prompt must run /gsd-code-review {N}");
        let ship_pos = prompt
            .find("/gsd-ship 13")
            .expect("Ship prompt must run /gsd-ship {N}");
        assert!(
            review_pos < ship_pos,
            "code-review must be sequenced before ship"
        );
    }

    #[test]
    fn ship_prompt_defines_critical_gate_and_review_failed_contract() {
        let prompt = stage_prompt(Stage::Ship, PhaseId::new(13));
        assert!(
            prompt.contains("REVIEW.md"),
            "Ship prompt must reference the REVIEW.md artifact"
        );
        assert!(
            prompt.to_lowercase().contains("critical"),
            "Ship prompt must name the Critical-severity gate"
        );
        assert!(
            prompt.contains("do not run")
                || prompt.contains("do NOT run")
                || prompt.contains("DO NOT run"),
            "Ship prompt must instruct the agent not to run /gsd-ship on Critical findings"
        );
        assert!(
            prompt.contains("review:"),
            "Ship prompt must define the review: ReviewFailed reason convention"
        );
        assert!(prompt.contains("DEVFLOW_RESULT"));
    }

    #[test]
    fn ship_prompt_includes_multi_angle_conditional_review() {
        let prompt = stage_prompt(Stage::Ship, PhaseId::new(13));
        for angle in [
            "doc-accuracy cross-reference",
            "security / leaked-data",
            "CI/build correctness",
            "external-state claims",
            "generalist deep pass",
        ] {
            assert!(prompt.contains(angle), "Ship prompt missing angle: {angle}");
        }
        assert!(prompt.contains("parallel finder subagents"));
        assert!(prompt.contains("focused sequential pass"));
        assert!(prompt.contains("Merge and deduplicate"));
        assert!(prompt.contains("REVIEW.md"));
    }

    #[test]
    fn ship_prompt_uses_project_review_angle_override() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("devflow.toml"),
            "review_angles = [\"custom release evidence\", \"custom threat boundary\"]\n",
        )
        .unwrap();

        let prompt = stage_prompt_for_project(Stage::Ship, PhaseId::new(13), dir.path());

        assert!(prompt.contains("custom release evidence"));
        assert!(prompt.contains("custom threat boundary"));
        assert!(!prompt.contains("doc-accuracy cross-reference"));
    }

    #[test]
    fn code_stage_prompt_is_unchanged_single_command_template() {
        // Validate is excluded here (Task 2, 13-05): it now gets its own
        // dedicated prompt requiring a verdict — see
        // `validate_stage_prompt_requires_verdict` below. Define and Plan
        // are excluded too: Plan carries the idempotency contract (see
        // `plan_prompt_is_idempotent` below); Define carries its own D-14
        // always-no-op contract (see `define_prompt_never_invokes_discuss_phase`
        // below).
        let prompt = stage_prompt(Stage::Code, PhaseId::new(9));
        assert!(prompt.contains("/gsd-execute-phase 9"));
        assert!(prompt.contains("DEVFLOW_RESULT"));
        assert!(
            !prompt.contains("/gsd-code-review"),
            "Code prompt should not carry Ship-specific code-review sequencing"
        );
        assert!(
            !prompt.contains("already exists"),
            "Code prompt should not carry the Define/Plan idempotency contract"
        );
        assert!(prompt.contains("Advisory incremental self-review"));
        for angle in [
            "doc accuracy",
            "leaked data",
            "CI/build correctness",
            "external-state claims",
        ] {
            assert!(prompt.contains(angle), "Code prompt missing angle: {angle}");
        }
        assert!(!prompt.contains("AskUserQuestion"));
        assert!(!prompt.contains("request_user_input"));
    }

    /// 13-06 dogfood regression (Codex leg), Plan half only after the D-14
    /// split: GSD's plan-phase demands an interactive decision when PLAN.md
    /// already exists, which headless Codex can never answer — Plan must
    /// no-op with success when its deliverable pre-exists. See T-28-09.
    #[test]
    fn plan_prompt_is_idempotent() {
        let prompt = stage_prompt(Stage::Plan, PhaseId::new(9));
        assert!(
            prompt.contains("/gsd-plan-phase 9"),
            "Plan prompt missing /gsd-plan-phase 9"
        );
        assert!(
            prompt.contains("09-*PLAN.md"),
            "Plan prompt must check for its pre-existing artifact"
        );
        assert!(
            prompt.contains("Do NOT run the GSD command"),
            "Plan prompt must no-op when the artifact exists"
        );
        assert!(
            prompt.contains("do NOT ask for input"),
            "Plan prompt must forbid interactive input"
        );
        assert!(prompt.contains("DEVFLOW_RESULT"));
    }

    /// D-14: the Define stage must never invoke the interactive
    /// discuss-phase command, whether or not CONTEXT.md exists — the branch
    /// that did so is deleted, not disambiguated. Regression guard for
    /// T-28-08 (a headless run has no operator to answer it).
    #[test]
    fn define_prompt_never_invokes_discuss_phase() {
        let prompt = stage_prompt(Stage::Define, PhaseId::new(9));
        assert!(
            !prompt.contains("/gsd-discuss-phase"),
            "Define prompt must never invoke the interactive discuss-phase command (D-14)"
        );
        assert!(
            prompt.contains("must NOT run") || prompt.contains("do NOT run"),
            "Define prompt must forbid running an interactive interview headlessly"
        );
        assert!(
            prompt.contains("do NOT ask for input") || prompt.contains("must NOT ask for input"),
            "Define prompt must forbid requesting input"
        );
        assert!(
            prompt.to_lowercase().contains("modify"),
            "Define prompt must forbid modifying existing planning artifacts"
        );
        assert!(prompt.contains("DEVFLOW_RESULT"));
    }

    #[test]
    fn validate_stage_prompt_requires_verdict() {
        let prompt = stage_prompt(Stage::Validate, PhaseId::new(13));
        assert!(
            prompt.contains("/gsd-validate-phase 13"),
            "Validate prompt missing its GSD command"
        );
        assert!(
            prompt.contains("\"verdict\": \"pass\""),
            "Validate prompt must name the exact lowercase pass verdict"
        );
        assert!(
            prompt.contains("\"verdict\": \"gaps\""),
            "Validate prompt must name the exact lowercase gaps verdict"
        );
        assert!(prompt.contains("REQUIRED"));
        assert!(prompt.contains("DEVFLOW_RESULT"));
    }

    #[test]
    fn fix_prompts_select_the_right_command() {
        assert!(fix_prompt(FixType::AuditFix, PhaseId::new(11)).contains("/gsd-audit-fix 11"));
        assert!(fix_prompt(FixType::GapsOnly, PhaseId::new(11)).contains("--gaps-only"));
        assert!(fix_prompt(FixType::AuditFix, PhaseId::new(11)).contains("DEVFLOW_RESULT"));

        // D-01: FullExecute renders the plain, unflagged execute command.
        let full_execute_prompt = fix_prompt(FixType::FullExecute, PhaseId::new(11));
        assert!(full_execute_prompt.contains("/gsd-execute-phase 11"));
        // Negative control: without this, FullExecute's command string would
        // just be a substring of GapsOnly's — this proves the two are
        // actually distinguishable, not that FullExecute merely contains
        // GapsOnly's prefix.
        assert!(!full_execute_prompt.contains("--gaps-only"));
    }

    /// The flag-preserving token belongs on exactly the command strings that
    /// reach `execute-phase.md`'s sync-clear step, and nowhere else.
    ///
    /// All three `FixType` arms are asserted, present AND absent, so this test
    /// distinguishes "added where it belongs" from "added everywhere" — the
    /// same habit `fix_prompts_select_the_right_command` above already uses for
    /// `--gaps-only`.
    #[test]
    fn fix_prompts_carry_the_chain_flag_token_only_where_it_reaches_execute_phase() {
        let phase = PhaseId::new(11);

        assert!(
            fix_prompt(FixType::GapsOnly, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
            "the --gaps-only fix loop reaches execute-phase.md, so it meets the \
             sync-clear step and needs the token exactly as the first Code pass does"
        );
        assert!(
            fix_prompt(FixType::FullExecute, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
            "the full-execute loop-back reaches execute-phase.md too"
        );
        assert!(
            !fix_prompt(FixType::AuditFix, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
            "audit-fix routes to /gsd-audit-fix and never reaches execute-phase.md, \
             so it never meets the sync-clear step the token exists to skip"
        );
    }

    /// The first Code pass and the fix loop must be treated identically —
    /// ROADMAP criterion 1 names the fix loop explicitly.
    #[test]
    fn the_code_prompt_carries_the_chain_flag_token() {
        let prompt = stage_prompt(Stage::Code, PhaseId::new(11));
        assert!(prompt.contains(&format!(
            "/gsd-execute-phase 11 {AUTO_CHAIN_PRESERVING_FLAG}"
        )));
    }

    /// Criterion 3a / D-04: the Plan prompt must NEVER carry the token.
    ///
    /// The flag that would enable checkpoint auto-approval at Plan is the same
    /// flag that makes `plan-phase.md` chain into `execute-phase.md`
    /// (`plan-phase.md:1564`) — which double-executes the Code stage and
    /// misattributes its commits. This is ROADMAP criterion 3, and it is why
    /// the token is appended inside the `Stage::Code` arm rather than in
    /// `gsd_command_for`, which Plan shares.
    #[test]
    fn the_plan_prompt_never_carries_the_chain_flag_token() {
        let plan = stage_prompt(Stage::Plan, PhaseId::new(11));
        assert!(
            !plan.contains(AUTO_CHAIN_PRESERVING_FLAG),
            "the Plan prompt must not chain into execute-phase (D-04)"
        );
        // Negative control: the Plan prompt DOES carry its own command, so the
        // assertion above is about the token and not about an empty string.
        assert!(plan.contains("/gsd-plan-phase 11"));
    }

    /// D-03/D-07 (28-03): the audit event quotes this instruction verbatim,
    /// so it must be byte-identical across calls for the same phase — no
    /// timestamp, no random content that would churn the recorded string.
    #[test]
    fn checkpoint_auto_decide_prompt_is_deterministic() {
        assert_eq!(
            checkpoint_auto_decide_prompt(PhaseId::new(28)),
            checkpoint_auto_decide_prompt(PhaseId::new(28))
        );
    }

    #[test]
    fn checkpoint_auto_decide_prompt_terminates_with_completion_protocol() {
        let prompt = checkpoint_auto_decide_prompt(PhaseId::new(28));
        assert!(
            prompt.ends_with(COMPLETION_PROTOCOL),
            "the resumed session's exit must still be parseable by the same \
             Layer 1 path as any other stage"
        );
        assert!(prompt.contains("DEVFLOW_RESULT"));
    }

    #[test]
    fn checkpoint_auto_decide_prompt_states_no_operator_judgment_and_record_reasoning() {
        let prompt = checkpoint_auto_decide_prompt(PhaseId::new(28)).to_lowercase();
        assert!(
            prompt.contains("no human operator") || prompt.contains("nobody"),
            "must state plainly that no operator is available"
        );
        assert!(
            prompt.contains("judgment") || prompt.contains("judgement"),
            "must instruct the agent to use its own judgment"
        );
        assert!(
            prompt.contains("record") && prompt.contains("reasoning"),
            "must require recording the reasoning in the final message, since \
             this is the ONLY record of what was decided (D-07)"
        );
    }

    #[test]
    fn checkpoint_auto_decide_prompt_substitutes_phase_for_legibility() {
        assert!(checkpoint_auto_decide_prompt(PhaseId::new(42)).contains("phase 42"));
    }
}