Skip to main content

devflow_core/
prompt.rs

1//! Stage-specific agent prompts.
2//!
3//! Prompts are minimal: each stage hands the agent its GSD slash command
4//! (from [`Stage::gsd_command`]) and the `DEVFLOW_RESULT` completion contract.
5//! There is no long instruction template — the GSD command carries the process,
6//! and DevFlow only needs the structured completion marker back.
7
8use crate::phase_id::PhaseId;
9use crate::stage::Stage;
10use std::path::Path;
11
12const SHIP_REVIEW_ANGLES: &[&str] = &[
13    "doc-accuracy cross-reference (do documented claims match source?)",
14    "security / leaked-data (does anything commit secrets, session data, or telemetry?)",
15    "CI/build correctness (can a failing step still report green?)",
16    "external-state claims (does the diff claim merges, tags, or deletions that are not actually true?)",
17    "one generalist deep pass",
18];
19
20/// The token that stops GSD from wiping `workflow._auto_chain_active` before
21/// it is read (35.1-01, RESEARCH Pitfall 1).
22///
23/// `execute-phase.md:161-165` clears that flag at the top of every invocation
24/// whose `$ARGUMENTS` does not carry this token. DevFlow sets the flag in the
25/// monitor immediately before launching the child, so without this the flag is
26/// wiped by the very command it was set for and `checkpoint_handling` never
27/// auto-approves anything.
28///
29/// **The token alone enables nothing.** GSD's `check auto-mode` reads
30/// `.planning/config.json` and nothing else — a full grep of
31/// `execute-phase.md` for `--auto` / `--chain` / `AUTO_CHAIN` / `auto_advance`
32/// returns exactly three hits, all inside that sync-clear block. So within
33/// `execute-phase.md` this token's only effect is to skip the clear. The
34/// mode gate that decides whether checkpoints may actually be auto-approved
35/// lives on the config write, in `pipeline_launch::auto_chain_flag_eligible`.
36///
37/// Named rather than inlined so the three command strings that must carry it
38/// (Code, and `fix_prompt`'s two `execute-phase` arms) cannot drift apart, and
39/// so a reader of any one of them can find this explanation.
40const AUTO_CHAIN_PRESERVING_FLAG: &str = "--auto";
41
42/// The completion contract every agent must honor as its final message.
43pub const COMPLETION_PROTOCOL: &str = "\
44## Completion Protocol (REQUIRED)\n\
45\n\
46When all work is done, your FINAL message must be exactly:\n\
47\n\
48DEVFLOW_RESULT: {\"status\": \"success\"}\n\
49\n\
50If something prevents completion:\n\
51\n\
52DEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"specific explanation\"}\n\
53\n\
54DevFlow reads this line to decide whether the stage succeeded. \
55Output nothing after it.";
56
57/// The data a stage wants rendered, with NO agent-specific syntax.
58///
59/// This is the de-Claude-ification artifact (999.31 / 37-01): the old
60/// `Stage::gsd_command()` returned a `/gsd-*` slash-command string that
61/// `prompt.rs` interpolated identically for every agent. `StageIntent` instead
62/// carries the stage's *data* (phase, fix kind, review angles), and each
63/// adapter's `render_prompt` turns that data into its own instruction — Claude
64/// and OpenCode render the legacy slash-command text byte-for-byte, Codex
65/// renders a Codex-native instruction with no `/gsd-*` string.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum StageIntent {
68    Define {
69        phase: PhaseId,
70    },
71    Plan {
72        phase: PhaseId,
73    },
74    Code {
75        phase: PhaseId,
76        fix: Option<FixType>,
77    },
78    Validate {
79        phase: PhaseId,
80    },
81    Ship {
82        phase: PhaseId,
83        review_angles: Vec<String>,
84    },
85}
86
87impl StageIntent {
88    /// The stage this intent drives.
89    pub fn stage(&self) -> Stage {
90        match self {
91            StageIntent::Define { .. } => Stage::Define,
92            StageIntent::Plan { .. } => Stage::Plan,
93            StageIntent::Code { .. } => Stage::Code,
94            StageIntent::Validate { .. } => Stage::Validate,
95            StageIntent::Ship { .. } => Stage::Ship,
96        }
97    }
98
99    /// Build the intent for a stage with no fix and default review angles
100    /// (callers that need a project-local review-angle override use
101    /// [`StageIntent::for_stage_in_project`]).
102    pub fn for_stage(stage: Stage, phase: PhaseId) -> Self {
103        Self::for_stage_in_project(stage, phase, None)
104    }
105
106    /// Build the intent for a stage, resolving project-local review angles.
107    pub fn for_stage_in_project(stage: Stage, phase: PhaseId, project_root: Option<&Path>) -> Self {
108        match stage {
109            Stage::Define => StageIntent::Define { phase },
110            Stage::Plan => StageIntent::Plan { phase },
111            Stage::Code => StageIntent::Code { phase, fix: None },
112            Stage::Validate => StageIntent::Validate { phase },
113            Stage::Ship => {
114                let review_angles = project_root
115                    .and_then(crate::config::review_angles)
116                    .unwrap_or_else(|| {
117                        SHIP_REVIEW_ANGLES
118                            .iter()
119                            .map(|angle| (*angle).to_owned())
120                            .collect()
121                    });
122                StageIntent::Ship {
123                    phase,
124                    review_angles,
125                }
126            }
127        }
128    }
129}
130
131/// A fix variant used when looping Code ↔ Validate.
132///
133/// `#[non_exhaustive]`: operator decision, 2026-08-04. This enum is public in
134/// the published `devflow-core` crate, so adding a variant is already a
135/// breaking change for any external crate matching on it exhaustively — this
136/// release (`FullExecute`, added for D-01) already pays that cost. Paying for
137/// `#[non_exhaustive]` at the same time makes every later variant addition
138/// additive instead of breaking again, the same reasoning `State` records for
139/// its own `#[non_exhaustive]` (`state.rs:30-31`). Verified empirically before
140/// applying: the only `match` over a `FixType` value anywhere in the
141/// workspace is `fix_prompt` below, which lives in this crate and is
142/// therefore unaffected by the attribute — no wildcard arm is needed, here or
143/// anywhere else in the workspace, and none should be added to `fix_prompt`
144/// itself (see its doc comment).
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146#[non_exhaustive]
147pub enum FixType {
148    /// Run the GSD audit-fix pipeline over review findings.
149    AuditFix,
150    /// Re-run execution targeting only the gaps left by validation.
151    GapsOnly,
152    /// Re-run the phase's remaining plans with the plain, unflagged
153    /// `/gsd-execute-phase {N}` command, because the phase is mid-arc rather
154    /// than defective — D-01 (33-CONTEXT.md): a phase with no
155    /// `{N}-VERIFICATION.md` yet has not been judged, so `--gaps-only` would
156    /// match zero plans and gate unresolvably.
157    FullExecute,
158}
159
160/// Substitute the `{N}` phase placeholder in a GSD command string.
161fn gsd_command_for(stage: Stage, phase: PhaseId) -> String {
162    stage.gsd_command().replace("{N}", &phase.to_string())
163}
164
165/// The Ship stage's dedicated prompt.
166///
167/// Headless-safety rationale: `/gsd-ship`'s own `optional_review` step is an
168/// interactive `AskUserQuestion` with undefined behavior under
169/// `--dangerously-skip-permissions` (RESEARCH Pitfall 2). Rather than relying
170/// on that step being skipped, this prompt sidesteps it entirely: the agent
171/// runs `/gsd-code-review {N}` first (non-interactive; writes `REVIEW.md`
172/// with severity-classified findings), and MUST NOT run `/gsd-ship {N}` at
173/// all if `REVIEW.md` contains any Critical-severity finding — instead it
174/// reports a `review:`-prefixed failure. Only a clean (no-Critical) review
175/// proceeds to `/gsd-ship {N}`. The `review:` reason prefix is the
176/// ReviewFailed contract that `handle_ship_failure` matches (trimmed,
177/// case-folded) to loop back to Code with `AuditFix`.
178fn ship_stage_prompt(phase: PhaseId, review_angles: &[String]) -> String {
179    let code_review = format!("/gsd-code-review {phase}");
180    let ship = format!("/gsd-ship {phase}");
181    let review_angles = review_angles
182        .iter()
183        .map(|angle| format!("- {angle}"))
184        .collect::<Vec<_>>()
185        .join("\n");
186    format!(
187        "Run the Ship stage in two steps:\n\
188        \n\
189        1. Run `{code_review}` (non-interactive). This writes a `REVIEW.md` \
190        artifact with severity-classified findings. Review at high depth from \
191        every angle below:\n\
192        \n\
193        {review_angles}\n\
194        \n\
195        If your harness supports parallel finder subagents, dispatch one per \
196        angle; otherwise run each angle as a focused sequential pass. Merge \
197        and deduplicate every angle's findings into one `REVIEW.md`.\n\
198        2. Check `REVIEW.md` for the Critical-severity gate:\n\
199        \n\
200        - If `REVIEW.md` contains ANY finding at Critical severity: do NOT \
201        run `{ship}` at all. Your FINAL message must be exactly:\n\
202        \n\
203        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"review: <short summary of the Critical findings>\"}}\n\
204        \n\
205        - If `REVIEW.md` has NO Critical-severity findings: run `{ship}` and \
206        report the outcome via the normal completion protocol below.\n\
207        \n\
208        {COMPLETION_PROTOCOL}"
209    )
210}
211
212/// The Validate stage's dedicated prompt.
213///
214/// 13b verdict-vs-ran: `status` only reports whether the stage's task (running
215/// `/gsd-validate-phase {N}`) completed — it says nothing about whether
216/// validation itself passed. This prompt REQUIRES a distinct `verdict` field
217/// so `advance()`'s Validate arm can tell "the agent ran validation" apart
218/// from "validation passed," and never advances to Ship on a bare `status:
219/// success` for this stage.
220/// The Validate verdict contract (13b verdict-vs-ran): a Validate stage must
221/// report `verdict: pass|gaps`, not a bare `status`. Shared by the legacy and
222/// workflow renderers so they cannot drift.
223const VALIDATE_VERDICT_CONTRACT: &str = "\
224## Completion Protocol (REQUIRED)\n\
225\n\
226When all work is done, your FINAL message must be exactly one of:\n\
227\n\
228DEVFLOW_RESULT: {\"status\": \"success\", \"verdict\": \"pass\"}\n\
229\n\
230if validation found NO gaps, or:\n\
231\n\
232DEVFLOW_RESULT: {\"status\": \"success\", \"verdict\": \"gaps\"}\n\
233\n\
234if validation found gaps that still need fixing. The `verdict` field is \
235REQUIRED for this stage — it is distinct from `status` (which only reports \
236whether the validation task itself completed) and MUST be exactly the \
237lowercase string `pass` or `gaps`.\n\
238\n\
239If something prevents completion:\n\
240\n\
241DEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"specific explanation\"}\n\
242\n\
243DevFlow reads this line to decide whether the stage succeeded. \
244Output nothing after it.";
245
246fn validate_stage_prompt(phase: PhaseId) -> String {
247    let command = gsd_command_for(Stage::Validate, phase);
248    format!(
249        "Run the GSD workflow command for this stage:\n\n    {command}\n\n{VALIDATE_VERDICT_CONTRACT}"
250    )
251}
252
253/// The Plan stage's idempotency contract.
254///
255/// Headless-safety rationale (13-06 dogfood finding, Codex leg): GSD's
256/// plan-phase demands an interactive "Overwrite/Append/Cancel" decision
257/// when the phase's PLAN.md already exists, and headless Codex cannot
258/// answer it (`request_user_input is unavailable`) — the stage would fail on
259/// every retry, forever. When the stage's deliverable already exists, the
260/// stage's work is done: re-running it must be a no-op success, not an
261/// interactive dead end. This is idempotency for a completed stage, NOT the
262/// v1 skip-stage config flags removed by the 2026-06-19 architecture
263/// decision — a stage with no pre-existing artifact still runs in full.
264///
265/// D-14 update: Define used to share this branch (dispatched by a `stage`
266/// parameter), but its missing-artifact arm ran an interactive interview
267/// command that cannot be answered headlessly. That branch was deleted
268/// rather than made conditional — see [`define_stage_prompt`] for Define's
269/// actual (always-no-op) contract. This function now serves only Plan.
270fn idempotent_stage_prompt(phase: PhaseId) -> String {
271    let artifact = "PLAN.md";
272    let command = gsd_command_for(Stage::Plan, phase);
273    let padded = phase.padded();
274    format!(
275        "First check whether this stage's deliverable already exists:\n\
276        \n\
277        ls .planning/phases/{padded}-*/{padded}-*{artifact} 2>/dev/null\n\
278        \n\
279        - If it EXISTS: the stage's work is already done. Do NOT run the GSD \
280        command, do NOT ask for input, and do NOT modify the existing \
281        artifacts. Your FINAL message must be exactly:\n\
282        \n\
283        DEVFLOW_RESULT: {{\"status\": \"success\"}}\n\
284        \n\
285        - If it does NOT exist: run the GSD workflow command for this stage:\n\
286        \n\
287        \x20   {command}\n\
288        \n\
289        {COMPLETION_PROTOCOL}"
290    )
291}
292
293/// The Define stage's dedicated prompt (D-14).
294///
295/// Headless-safety rationale: `idempotent_stage_prompt`'s missing-artifact
296/// arm used to run an interactive interview command for Define — one that
297/// hangs or errors under `claude -p` with no operator present to answer it
298/// (T-28-08). D-14 settles the fix as deletion, not disambiguation: the
299/// Define stage never runs that command in a DevFlow launch, whether or not
300/// CONTEXT.md already exists. The operator decides whether to run the
301/// interview before invoking `devflow start`; DevFlow makes no runtime
302/// accommodation for that choice.
303fn define_stage_prompt(phase: PhaseId) -> String {
304    format!(
305        "This is the Define stage of a headless DevFlow run for phase {phase}.\n\
306        \n\
307        There is no agent work to perform here. Whether or not this phase's \
308        CONTEXT.md already exists, you must NOT run an interactive \
309        discuss-phase or interview command, and you must NOT ask for input \
310        — this run is headless and no operator is available to answer \
311        interactive questions. Do NOT modify any existing planning \
312        artifacts.\n\
313        \n\
314        {COMPLETION_PROTOCOL}"
315    )
316}
317
318/// Build the prompt for a stage of a phase.
319pub fn stage_prompt(stage: Stage, phase: PhaseId) -> String {
320    stage_prompt_with_project(stage, phase, None)
321}
322
323/// Build a stage prompt with project-local configuration applied.
324///
325/// The CLI uses this entry point after resolving the canonical project root;
326/// library callers that have no project context keep using [`stage_prompt`]
327/// and receive built-in defaults.
328pub fn stage_prompt_for_project(stage: Stage, phase: PhaseId, project_root: &Path) -> String {
329    stage_prompt_with_project(stage, phase, Some(project_root))
330}
331
332/// The Code stage's dedicated prompt.
333///
334/// The Code arm ONLY carries [`AUTO_CHAIN_PRESERVING_FLAG`] (D-04/D-05, 35.1-01
335/// Pitfall 1): `execute-phase.md` wipes `workflow._auto_chain_active` at the top
336/// of every invocation whose `$ARGUMENTS` lacks this token, which would clear
337/// the flag DevFlow just set before `checkpoint_handling` ever reads it.
338fn code_stage_prompt(phase: PhaseId) -> String {
339    let command = format!(
340        "{} {AUTO_CHAIN_PRESERVING_FLAG}",
341        gsd_command_for(Stage::Code, phase)
342    );
343    format!(
344        "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
345        ## Advisory incremental self-review\n\
346        \n\
347        After each plan or wave lands, perform a quick, shallow self-check \
348        for doc accuracy, leaked data, CI/build correctness, and \
349        external-state claims. Record any drift in the working output and \
350        continue execution; the authoritative review happens during Ship. \
351        This check must not pause execution or request human input.\n\
352        \n\
353        {COMPLETION_PROTOCOL}"
354    )
355}
356
357/// Render a [`StageIntent`] as the legacy Claude/OpenCode slash-command text.
358///
359/// This is the byte-identical renderer: Claude and OpenCode produce exactly
360/// what `stage_prompt` produced before the migration (CONTEXT D-01 zero
361/// regression). It lives here — not in the adapters — so the two agents cannot
362/// drift apart, and the per-stage snapshot tests pin it.
363pub fn render_claude_style(intent: &StageIntent) -> String {
364    match intent {
365        StageIntent::Define { phase } => define_stage_prompt(*phase),
366        StageIntent::Plan { phase } => idempotent_stage_prompt(*phase),
367        StageIntent::Code { phase, fix: None } => code_stage_prompt(*phase),
368        StageIntent::Code {
369            phase,
370            fix: Some(fix),
371        } => fix_prompt(*fix, *phase),
372        StageIntent::Validate { phase } => validate_stage_prompt(*phase),
373        StageIntent::Ship {
374            phase,
375            review_angles,
376        } => ship_stage_prompt(*phase, review_angles),
377    }
378}
379
380/// Render a [`StageIntent`] as a workflow-reference instruction for agents that
381/// cannot receive the legacy `/gsd-*` slash command (Codex, Pi). The instruction
382/// points at the GSD workflow file to follow, carries the `--auto` token where
383/// the workflow requires it, and states the completion contract. Contains NO
384/// GSD slash command.
385/// Render a [`StageIntent`] for an agent that cannot receive the legacy
386/// `/gsd-*` slash command (Codex, Pi). Each stage's *contract* is preserved —
387/// Validate verdict, Ship review gate, Define no-op, Plan idempotency — but the
388/// instruction references the workflow file under `workflow_root` (a per-driver
389/// path) instead of naming a slash command.
390pub fn render_workflow_style(intent: &StageIntent, workflow_root: &str) -> String {
391    match intent {
392        // D-14: Define is a no-op for every agent — `define_stage_prompt`
393        // already carries no slash command, so it is shared verbatim.
394        StageIntent::Define { phase } => define_stage_prompt(*phase),
395        StageIntent::Plan { phase } => workflow_plan_prompt(*phase, workflow_root),
396        StageIntent::Code { phase, fix } => workflow_code_prompt(*phase, *fix, workflow_root),
397        StageIntent::Validate { phase } => workflow_validate_prompt(*phase, workflow_root),
398        StageIntent::Ship {
399            phase,
400            review_angles,
401        } => workflow_ship_prompt(*phase, review_angles, workflow_root),
402    }
403}
404
405fn workflow_plan_prompt(phase: PhaseId, workflow_root: &str) -> String {
406    let artifact = "PLAN.md";
407    let padded = phase.padded();
408    format!(
409        "First check whether this stage's deliverable already exists:\n\
410        \n\
411        ls .planning/phases/{padded}-*/{padded}-*{artifact} 2>/dev/null\n\
412        \n\
413        - If it EXISTS: the stage's work is already done. Do NOT run the \
414        workflow, do NOT ask for input, and do NOT modify the existing \
415        artifacts. Your FINAL message must be exactly:\n\
416        \n\
417        DEVFLOW_RESULT: {{\"status\": \"success\"}}\n\
418        \n\
419        - If it does NOT exist: read and follow the GSD workflow file at \
420        {workflow_root}/plan-phase.md for phase {phase}.\n\
421        \n\
422        {COMPLETION_PROTOCOL}"
423    )
424}
425
426fn workflow_code_prompt(phase: PhaseId, fix: Option<FixType>, workflow_root: &str) -> String {
427    match fix {
428        Some(FixType::AuditFix) => format!(
429            "Read and follow the GSD workflow file at {workflow_root}/audit-fix.md for \
430            phase {phase}.\n\n{COMPLETION_PROTOCOL}"
431        ),
432        Some(FixType::GapsOnly) => format!(
433            "Read and follow the GSD workflow file at {workflow_root}/execute-phase.md for \
434            phase {phase} --auto --gaps-only. The `--auto` and `--gaps-only` flags are part \
435            of the workflow invocation and must be preserved verbatim.\n\n{COMPLETION_PROTOCOL}"
436        ),
437        Some(FixType::FullExecute) | None => format!(
438            "Read and follow the GSD workflow file at {workflow_root}/execute-phase.md for \
439            phase {phase} --auto. The `--auto` flag is part of the workflow invocation and \
440            must be preserved verbatim.\n\n\
441            ## Advisory incremental self-review\n\
442            \n\
443            After each plan or wave lands, perform a quick, shallow self-check \
444            for doc accuracy, leaked data, CI/build correctness, and \
445            external-state claims. Record any drift in the working output and \
446            continue execution; the authoritative review happens during Ship. \
447            This check must not pause execution or request human input.\n\
448            \n\
449            {COMPLETION_PROTOCOL}"
450        ),
451    }
452}
453
454fn workflow_validate_prompt(phase: PhaseId, workflow_root: &str) -> String {
455    format!(
456        "Read and follow the GSD workflow file at {workflow_root}/validate-phase.md for \
457        phase {phase}.\n\n{VALIDATE_VERDICT_CONTRACT}"
458    )
459}
460
461fn workflow_ship_prompt(phase: PhaseId, review_angles: &[String], workflow_root: &str) -> String {
462    let review_angles = review_angles
463        .iter()
464        .map(|angle| format!("- {angle}"))
465        .collect::<Vec<_>>()
466        .join("\n");
467    format!(
468        "Run the Ship stage in two steps:\n\
469        \n\
470        1. Read and follow the GSD workflow file at {workflow_root}/code-review.md for \
471        phase {phase}. This writes a REVIEW.md artifact with severity-classified findings. \
472        Review at high depth from every angle below:\n\
473        \n\
474        {review_angles}\n\
475        \n\
476        If your harness supports parallel finder subagents, dispatch one per angle; otherwise \
477        run each angle as a focused sequential pass. Merge and deduplicate every angle's \
478        findings into one REVIEW.md.\n\
479        2. Check REVIEW.md for the Critical-severity gate:\n\
480        \n\
481        - If REVIEW.md contains ANY finding at Critical severity: do NOT run the ship workflow \
482        at all. Your FINAL message must be exactly:\n\
483        \n\
484        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"review: <short summary of the \
485        Critical findings>\"}}\n\
486        \n\
487        - If REVIEW.md has NO Critical-severity findings: read and follow the GSD workflow file \
488        at {workflow_root}/ship.md for phase {phase} and report the outcome via the normal \
489        completion protocol below.\n\
490        \n\
491        {COMPLETION_PROTOCOL}"
492    )
493}
494
495fn stage_prompt_with_project(stage: Stage, phase: PhaseId, project_root: Option<&Path>) -> String {
496    render_claude_style(&StageIntent::for_stage_in_project(
497        stage,
498        phase,
499        project_root,
500    ))
501}
502
503/// The synthesized instruction sent into a resumed Claude session when a
504/// confirmed human-blocking checkpoint has nobody available to answer it
505/// (D-03, 28-CONTEXT.md): DevFlow's default, unconditional policy — no flag,
506/// no config toggle — is for the agent to resolve the checkpoint itself,
507/// using its own judgment, and record why.
508///
509/// Deliberately deterministic: no timestamp, no random content, no varying
510/// state. Two calls for the same `phase` produce byte-identical strings, so
511/// the `checkpoint_auto_decided` audit event (D-07, plan 28-03) can quote
512/// this exact instruction without churning on every resume. `phase` is
513/// included only for operator legibility in the captured stdout — the
514/// instruction's meaning does not depend on it.
515pub fn checkpoint_auto_decide_prompt(phase: PhaseId) -> String {
516    format!(
517        "This is phase {phase} of a headless DevFlow run. You previously \
518        stopped at a human-blocking checkpoint, but no human operator is \
519        available to answer it — this run is unattended, and none is \
520        coming. DevFlow's policy is for you to resolve the checkpoint \
521        yourself, using your own best judgment, and continue the work. You \
522        MUST record your reasoning for the decision you made in your final \
523        message, so the decision is auditable after the fact.\n\
524        \n\
525        {COMPLETION_PROTOCOL}"
526    )
527}
528
529/// Build a fix prompt used on Code → Validate loop-backs.
530///
531/// Both arms that dispatch to `execute-phase` carry
532/// [`AUTO_CHAIN_PRESERVING_FLAG`], for the same reason the Code prompt does:
533/// they reach `execute-phase.md`'s sync-clear step, and without the token that
534/// step wipes the chain flag DevFlow just set. The `--gaps-only` loop is
535/// named explicitly by ROADMAP criterion 1 — a fix pass gets exactly the same
536/// treatment as the first Code pass, or the phase's unattended behaviour
537/// changes the moment validation reports a gap.
538///
539/// `AuditFix` is deliberately left alone: it routes to `/gsd-audit-fix`, never
540/// reaches `execute-phase.md`, and so never meets the sync-clear step.
541///
542/// Flag ORDER within the command string does not matter — GSD extracts
543/// `--`-prefixed tokens position-independently
544/// (`references/phase-argument-parsing.md`).
545pub fn fix_prompt(fix_type: FixType, phase: PhaseId) -> String {
546    let command = match fix_type {
547        FixType::AuditFix => format!("/gsd-audit-fix {phase}"),
548        FixType::GapsOnly => {
549            format!("/gsd-execute-phase {phase} --gaps-only {AUTO_CHAIN_PRESERVING_FLAG}")
550        }
551        FixType::FullExecute => {
552            format!("/gsd-execute-phase {phase} {AUTO_CHAIN_PRESERVING_FLAG}")
553        }
554    };
555    format!(
556        "Validation reported issues. Run the fix command for this loop:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
557    )
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn each_stage_prompt_carries_its_gsd_command_and_marker() {
566        // Define is excluded here (D-14): its prompt never contains its GSD
567        // command — see `define_prompt_never_invokes_discuss_phase` below.
568        let cases = [
569            (Stage::Plan, "/gsd-plan-phase 11"),
570            (Stage::Code, "/gsd-execute-phase 11"),
571            (Stage::Validate, "/gsd-validate-phase 11"),
572            (Stage::Ship, "/gsd-ship 11"),
573        ];
574        for (stage, command) in cases {
575            let prompt = stage_prompt(stage, PhaseId::new(11));
576            assert!(prompt.contains(command), "{stage} prompt missing {command}");
577            assert!(prompt.contains("DEVFLOW_RESULT"));
578        }
579    }
580
581    #[test]
582    fn phase_placeholder_is_substituted() {
583        assert!(stage_prompt(Stage::Code, PhaseId::new(7)).contains("/gsd-execute-phase 7"));
584        assert!(!stage_prompt(Stage::Code, PhaseId::new(7)).contains("{N}"));
585    }
586
587    #[test]
588    fn ship_prompt_sequences_code_review_before_ship() {
589        let prompt = stage_prompt(Stage::Ship, PhaseId::new(13));
590        let review_pos = prompt
591            .find("/gsd-code-review 13")
592            .expect("Ship prompt must run /gsd-code-review {N}");
593        let ship_pos = prompt
594            .find("/gsd-ship 13")
595            .expect("Ship prompt must run /gsd-ship {N}");
596        assert!(
597            review_pos < ship_pos,
598            "code-review must be sequenced before ship"
599        );
600    }
601
602    #[test]
603    fn ship_prompt_defines_critical_gate_and_review_failed_contract() {
604        let prompt = stage_prompt(Stage::Ship, PhaseId::new(13));
605        assert!(
606            prompt.contains("REVIEW.md"),
607            "Ship prompt must reference the REVIEW.md artifact"
608        );
609        assert!(
610            prompt.to_lowercase().contains("critical"),
611            "Ship prompt must name the Critical-severity gate"
612        );
613        assert!(
614            prompt.contains("do not run")
615                || prompt.contains("do NOT run")
616                || prompt.contains("DO NOT run"),
617            "Ship prompt must instruct the agent not to run /gsd-ship on Critical findings"
618        );
619        assert!(
620            prompt.contains("review:"),
621            "Ship prompt must define the review: ReviewFailed reason convention"
622        );
623        assert!(prompt.contains("DEVFLOW_RESULT"));
624    }
625
626    #[test]
627    fn ship_prompt_includes_multi_angle_conditional_review() {
628        let prompt = stage_prompt(Stage::Ship, PhaseId::new(13));
629        for angle in [
630            "doc-accuracy cross-reference",
631            "security / leaked-data",
632            "CI/build correctness",
633            "external-state claims",
634            "generalist deep pass",
635        ] {
636            assert!(prompt.contains(angle), "Ship prompt missing angle: {angle}");
637        }
638        assert!(prompt.contains("parallel finder subagents"));
639        assert!(prompt.contains("focused sequential pass"));
640        assert!(prompt.contains("Merge and deduplicate"));
641        assert!(prompt.contains("REVIEW.md"));
642    }
643
644    #[test]
645    fn ship_prompt_uses_project_review_angle_override() {
646        let dir = tempfile::tempdir().unwrap();
647        std::fs::write(
648            dir.path().join("devflow.toml"),
649            "review_angles = [\"custom release evidence\", \"custom threat boundary\"]\n",
650        )
651        .unwrap();
652
653        let prompt = stage_prompt_for_project(Stage::Ship, PhaseId::new(13), dir.path());
654
655        assert!(prompt.contains("custom release evidence"));
656        assert!(prompt.contains("custom threat boundary"));
657        assert!(!prompt.contains("doc-accuracy cross-reference"));
658    }
659
660    #[test]
661    fn code_stage_prompt_is_unchanged_single_command_template() {
662        // Validate is excluded here (Task 2, 13-05): it now gets its own
663        // dedicated prompt requiring a verdict — see
664        // `validate_stage_prompt_requires_verdict` below. Define and Plan
665        // are excluded too: Plan carries the idempotency contract (see
666        // `plan_prompt_is_idempotent` below); Define carries its own D-14
667        // always-no-op contract (see `define_prompt_never_invokes_discuss_phase`
668        // below).
669        let prompt = stage_prompt(Stage::Code, PhaseId::new(9));
670        assert!(prompt.contains("/gsd-execute-phase 9"));
671        assert!(prompt.contains("DEVFLOW_RESULT"));
672        assert!(
673            !prompt.contains("/gsd-code-review"),
674            "Code prompt should not carry Ship-specific code-review sequencing"
675        );
676        assert!(
677            !prompt.contains("already exists"),
678            "Code prompt should not carry the Define/Plan idempotency contract"
679        );
680        assert!(prompt.contains("Advisory incremental self-review"));
681        for angle in [
682            "doc accuracy",
683            "leaked data",
684            "CI/build correctness",
685            "external-state claims",
686        ] {
687            assert!(prompt.contains(angle), "Code prompt missing angle: {angle}");
688        }
689        assert!(!prompt.contains("AskUserQuestion"));
690        assert!(!prompt.contains("request_user_input"));
691    }
692
693    /// 13-06 dogfood regression (Codex leg), Plan half only after the D-14
694    /// split: GSD's plan-phase demands an interactive decision when PLAN.md
695    /// already exists, which headless Codex can never answer — Plan must
696    /// no-op with success when its deliverable pre-exists. See T-28-09.
697    #[test]
698    fn plan_prompt_is_idempotent() {
699        let prompt = stage_prompt(Stage::Plan, PhaseId::new(9));
700        assert!(
701            prompt.contains("/gsd-plan-phase 9"),
702            "Plan prompt missing /gsd-plan-phase 9"
703        );
704        assert!(
705            prompt.contains("09-*PLAN.md"),
706            "Plan prompt must check for its pre-existing artifact"
707        );
708        assert!(
709            prompt.contains("Do NOT run the GSD command"),
710            "Plan prompt must no-op when the artifact exists"
711        );
712        assert!(
713            prompt.contains("do NOT ask for input"),
714            "Plan prompt must forbid interactive input"
715        );
716        assert!(prompt.contains("DEVFLOW_RESULT"));
717    }
718
719    /// D-14: the Define stage must never invoke the interactive
720    /// discuss-phase command, whether or not CONTEXT.md exists — the branch
721    /// that did so is deleted, not disambiguated. Regression guard for
722    /// T-28-08 (a headless run has no operator to answer it).
723    #[test]
724    fn define_prompt_never_invokes_discuss_phase() {
725        let prompt = stage_prompt(Stage::Define, PhaseId::new(9));
726        assert!(
727            !prompt.contains("/gsd-discuss-phase"),
728            "Define prompt must never invoke the interactive discuss-phase command (D-14)"
729        );
730        assert!(
731            prompt.contains("must NOT run") || prompt.contains("do NOT run"),
732            "Define prompt must forbid running an interactive interview headlessly"
733        );
734        assert!(
735            prompt.contains("do NOT ask for input") || prompt.contains("must NOT ask for input"),
736            "Define prompt must forbid requesting input"
737        );
738        assert!(
739            prompt.to_lowercase().contains("modify"),
740            "Define prompt must forbid modifying existing planning artifacts"
741        );
742        assert!(prompt.contains("DEVFLOW_RESULT"));
743    }
744
745    #[test]
746    fn validate_stage_prompt_requires_verdict() {
747        let prompt = stage_prompt(Stage::Validate, PhaseId::new(13));
748        assert!(
749            prompt.contains("/gsd-validate-phase 13"),
750            "Validate prompt missing its GSD command"
751        );
752        assert!(
753            prompt.contains("\"verdict\": \"pass\""),
754            "Validate prompt must name the exact lowercase pass verdict"
755        );
756        assert!(
757            prompt.contains("\"verdict\": \"gaps\""),
758            "Validate prompt must name the exact lowercase gaps verdict"
759        );
760        assert!(prompt.contains("REQUIRED"));
761        assert!(prompt.contains("DEVFLOW_RESULT"));
762    }
763
764    #[test]
765    fn fix_prompts_select_the_right_command() {
766        assert!(fix_prompt(FixType::AuditFix, PhaseId::new(11)).contains("/gsd-audit-fix 11"));
767        assert!(fix_prompt(FixType::GapsOnly, PhaseId::new(11)).contains("--gaps-only"));
768        assert!(fix_prompt(FixType::AuditFix, PhaseId::new(11)).contains("DEVFLOW_RESULT"));
769
770        // D-01: FullExecute renders the plain, unflagged execute command.
771        let full_execute_prompt = fix_prompt(FixType::FullExecute, PhaseId::new(11));
772        assert!(full_execute_prompt.contains("/gsd-execute-phase 11"));
773        // Negative control: without this, FullExecute's command string would
774        // just be a substring of GapsOnly's — this proves the two are
775        // actually distinguishable, not that FullExecute merely contains
776        // GapsOnly's prefix.
777        assert!(!full_execute_prompt.contains("--gaps-only"));
778    }
779
780    /// The flag-preserving token belongs on exactly the command strings that
781    /// reach `execute-phase.md`'s sync-clear step, and nowhere else.
782    ///
783    /// All three `FixType` arms are asserted, present AND absent, so this test
784    /// distinguishes "added where it belongs" from "added everywhere" — the
785    /// same habit `fix_prompts_select_the_right_command` above already uses for
786    /// `--gaps-only`.
787    #[test]
788    fn fix_prompts_carry_the_chain_flag_token_only_where_it_reaches_execute_phase() {
789        let phase = PhaseId::new(11);
790
791        assert!(
792            fix_prompt(FixType::GapsOnly, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
793            "the --gaps-only fix loop reaches execute-phase.md, so it meets the \
794             sync-clear step and needs the token exactly as the first Code pass does"
795        );
796        assert!(
797            fix_prompt(FixType::FullExecute, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
798            "the full-execute loop-back reaches execute-phase.md too"
799        );
800        assert!(
801            !fix_prompt(FixType::AuditFix, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
802            "audit-fix routes to /gsd-audit-fix and never reaches execute-phase.md, \
803             so it never meets the sync-clear step the token exists to skip"
804        );
805    }
806
807    /// The first Code pass and the fix loop must be treated identically —
808    /// ROADMAP criterion 1 names the fix loop explicitly.
809    #[test]
810    fn the_code_prompt_carries_the_chain_flag_token() {
811        let prompt = stage_prompt(Stage::Code, PhaseId::new(11));
812        assert!(prompt.contains(&format!(
813            "/gsd-execute-phase 11 {AUTO_CHAIN_PRESERVING_FLAG}"
814        )));
815    }
816
817    /// Criterion 3a / D-04: the Plan prompt must NEVER carry the token.
818    ///
819    /// The flag that would enable checkpoint auto-approval at Plan is the same
820    /// flag that makes `plan-phase.md` chain into `execute-phase.md`
821    /// (`plan-phase.md:1564`) — which double-executes the Code stage and
822    /// misattributes its commits. This is ROADMAP criterion 3, and it is why
823    /// the token is appended inside the `Stage::Code` arm rather than in
824    /// `gsd_command_for`, which Plan shares.
825    #[test]
826    fn the_plan_prompt_never_carries_the_chain_flag_token() {
827        let plan = stage_prompt(Stage::Plan, PhaseId::new(11));
828        assert!(
829            !plan.contains(AUTO_CHAIN_PRESERVING_FLAG),
830            "the Plan prompt must not chain into execute-phase (D-04)"
831        );
832        // Negative control: the Plan prompt DOES carry its own command, so the
833        // assertion above is about the token and not about an empty string.
834        assert!(plan.contains("/gsd-plan-phase 11"));
835    }
836
837    /// D-03/D-07 (28-03): the audit event quotes this instruction verbatim,
838    /// so it must be byte-identical across calls for the same phase — no
839    /// timestamp, no random content that would churn the recorded string.
840    #[test]
841    fn checkpoint_auto_decide_prompt_is_deterministic() {
842        assert_eq!(
843            checkpoint_auto_decide_prompt(PhaseId::new(28)),
844            checkpoint_auto_decide_prompt(PhaseId::new(28))
845        );
846    }
847
848    #[test]
849    fn checkpoint_auto_decide_prompt_terminates_with_completion_protocol() {
850        let prompt = checkpoint_auto_decide_prompt(PhaseId::new(28));
851        assert!(
852            prompt.ends_with(COMPLETION_PROTOCOL),
853            "the resumed session's exit must still be parseable by the same \
854             Layer 1 path as any other stage"
855        );
856        assert!(prompt.contains("DEVFLOW_RESULT"));
857    }
858
859    #[test]
860    fn checkpoint_auto_decide_prompt_states_no_operator_judgment_and_record_reasoning() {
861        let prompt = checkpoint_auto_decide_prompt(PhaseId::new(28)).to_lowercase();
862        assert!(
863            prompt.contains("no human operator") || prompt.contains("nobody"),
864            "must state plainly that no operator is available"
865        );
866        assert!(
867            prompt.contains("judgment") || prompt.contains("judgement"),
868            "must instruct the agent to use its own judgment"
869        );
870        assert!(
871            prompt.contains("record") && prompt.contains("reasoning"),
872            "must require recording the reasoning in the final message, since \
873             this is the ONLY record of what was decided (D-07)"
874        );
875    }
876
877    #[test]
878    fn checkpoint_auto_decide_prompt_substitutes_phase_for_legibility() {
879        assert!(checkpoint_auto_decide_prompt(PhaseId::new(42)).contains("phase 42"));
880    }
881}