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::stage::Stage;
9
10/// The completion contract every agent must honor as its final message.
11const COMPLETION_PROTOCOL: &str = "\
12## Completion Protocol (REQUIRED)\n\
13\n\
14When all work is done, your FINAL message must be exactly:\n\
15\n\
16DEVFLOW_RESULT: {\"status\": \"success\"}\n\
17\n\
18If something prevents completion:\n\
19\n\
20DEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"specific explanation\"}\n\
21\n\
22DevFlow reads this line to decide whether the stage succeeded. \
23Output nothing after it.";
24
25/// A fix variant used when looping Code ↔ Validate.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum FixType {
28    /// Run the GSD audit-fix pipeline over review findings.
29    AuditFix,
30    /// Re-run execution targeting only the gaps left by validation.
31    GapsOnly,
32}
33
34/// Substitute the `{N}` phase placeholder in a GSD command string.
35fn gsd_command_for(stage: Stage, phase: u32) -> String {
36    stage.gsd_command().replace("{N}", &phase.to_string())
37}
38
39/// The Ship stage's dedicated prompt.
40///
41/// Headless-safety rationale: `/gsd-ship`'s own `optional_review` step is an
42/// interactive `AskUserQuestion` with undefined behavior under
43/// `--dangerously-skip-permissions` (RESEARCH Pitfall 2). Rather than relying
44/// on that step being skipped, this prompt sidesteps it entirely: the agent
45/// runs `/gsd-code-review {N}` first (non-interactive; writes `REVIEW.md`
46/// with severity-classified findings), and MUST NOT run `/gsd-ship {N}` at
47/// all if `REVIEW.md` contains any Critical-severity finding — instead it
48/// reports a `review:`-prefixed failure. Only a clean (no-Critical) review
49/// proceeds to `/gsd-ship {N}`. The `review:` reason prefix is the
50/// ReviewFailed contract that `handle_ship_failure` matches (trimmed,
51/// case-folded) to loop back to Code with `AuditFix`.
52fn ship_stage_prompt(phase: u32) -> String {
53    let code_review = format!("/gsd-code-review {phase}");
54    let ship = format!("/gsd-ship {phase}");
55    format!(
56        "Run the Ship stage in two steps:\n\
57        \n\
58        1. Run `{code_review}` (non-interactive). This writes a `REVIEW.md` \
59        artifact with severity-classified findings.\n\
60        2. Check `REVIEW.md` for the Critical-severity gate:\n\
61        \n\
62        - If `REVIEW.md` contains ANY finding at Critical severity: do NOT \
63        run `{ship}` at all. Your FINAL message must be exactly:\n\
64        \n\
65        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"review: <short summary of the Critical findings>\"}}\n\
66        \n\
67        - If `REVIEW.md` has NO Critical-severity findings: run `{ship}` and \
68        report the outcome via the normal completion protocol below.\n\
69        \n\
70        {COMPLETION_PROTOCOL}"
71    )
72}
73
74/// The Validate stage's dedicated prompt.
75///
76/// 13b verdict-vs-ran: `status` only reports whether the stage's task (running
77/// `/gsd-validate-phase {N}`) completed — it says nothing about whether
78/// validation itself passed. This prompt REQUIRES a distinct `verdict` field
79/// so `advance()`'s Validate arm can tell "the agent ran validation" apart
80/// from "validation passed," and never advances to Ship on a bare `status:
81/// success` for this stage.
82fn validate_stage_prompt(phase: u32) -> String {
83    let command = gsd_command_for(Stage::Validate, phase);
84    format!(
85        "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
86        ## Completion Protocol (REQUIRED)\n\
87        \n\
88        When all work is done, your FINAL message must be exactly one of:\n\
89        \n\
90        DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"pass\"}}\n\
91        \n\
92        if validation found NO gaps, or:\n\
93        \n\
94        DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"gaps\"}}\n\
95        \n\
96        if validation found gaps that still need fixing. The `verdict` field \
97        is REQUIRED for this stage — it is distinct from `status` (which only \
98        reports whether the validation task itself completed) and MUST be \
99        exactly the lowercase string `pass` or `gaps`.\n\
100        \n\
101        If something prevents completion:\n\
102        \n\
103        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"specific explanation\"}}\n\
104        \n\
105        DevFlow reads this line to decide whether the stage succeeded. \
106        Output nothing after it."
107    )
108}
109
110/// The Define and Plan stages' idempotency contract.
111///
112/// Headless-safety rationale (13-06 dogfood finding, Codex leg): GSD's
113/// discuss-phase demands an interactive "Overwrite/Append/Cancel" decision
114/// when the phase's CONTEXT.md already exists, and headless Codex cannot
115/// answer it (`request_user_input is unavailable`) — the stage would fail on
116/// every retry, forever. When the stage's deliverable already exists, the
117/// stage's work is done: re-running it must be a no-op success, not an
118/// interactive dead end. This is idempotency for a completed stage, NOT the
119/// v1 skip-stage config flags removed by the 2026-06-19 architecture
120/// decision — a stage with no pre-existing artifact still runs in full.
121fn idempotent_stage_prompt(stage: Stage, phase: u32) -> String {
122    let artifact = match stage {
123        Stage::Define => "CONTEXT.md",
124        _ => "PLAN.md",
125    };
126    let command = gsd_command_for(stage, phase);
127    let padded = format!("{phase:02}");
128    format!(
129        "First check whether this stage's deliverable already exists:\n\
130        \n\
131        ls .planning/phases/{padded}-*/{padded}-*{artifact} 2>/dev/null\n\
132        \n\
133        - If it EXISTS: the stage's work is already done. Do NOT run the GSD \
134        command, do NOT ask for input, and do NOT modify the existing \
135        artifacts. Your FINAL message must be exactly:\n\
136        \n\
137        DEVFLOW_RESULT: {{\"status\": \"success\"}}\n\
138        \n\
139        - If it does NOT exist: run the GSD workflow command for this stage:\n\
140        \n\
141        \x20   {command}\n\
142        \n\
143        {COMPLETION_PROTOCOL}"
144    )
145}
146
147/// Build the prompt for a stage of a phase.
148pub fn stage_prompt(stage: Stage, phase: u32) -> String {
149    if stage == Stage::Ship {
150        return ship_stage_prompt(phase);
151    }
152    if stage == Stage::Validate {
153        return validate_stage_prompt(phase);
154    }
155    if matches!(stage, Stage::Define | Stage::Plan) {
156        return idempotent_stage_prompt(stage, phase);
157    }
158    let command = gsd_command_for(stage, phase);
159    format!(
160        "Run the GSD workflow command for this stage:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
161    )
162}
163
164/// Build a fix prompt used on Code → Validate loop-backs.
165pub fn fix_prompt(fix_type: FixType, phase: u32) -> String {
166    let command = match fix_type {
167        FixType::AuditFix => format!("/gsd-audit-fix {phase}"),
168        FixType::GapsOnly => format!("/gsd-execute-phase {phase} --gaps-only"),
169    };
170    format!(
171        "Validation reported issues. Run the fix command for this loop:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
172    )
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn each_stage_prompt_carries_its_gsd_command_and_marker() {
181        let cases = [
182            (Stage::Define, "/gsd-discuss-phase 11"),
183            (Stage::Plan, "/gsd-plan-phase 11"),
184            (Stage::Code, "/gsd-execute-phase 11"),
185            (Stage::Validate, "/gsd-validate-phase 11"),
186            (Stage::Ship, "/gsd-ship 11"),
187        ];
188        for (stage, command) in cases {
189            let prompt = stage_prompt(stage, 11);
190            assert!(prompt.contains(command), "{stage} prompt missing {command}");
191            assert!(prompt.contains("DEVFLOW_RESULT"));
192        }
193    }
194
195    #[test]
196    fn phase_placeholder_is_substituted() {
197        assert!(stage_prompt(Stage::Code, 7).contains("/gsd-execute-phase 7"));
198        assert!(!stage_prompt(Stage::Code, 7).contains("{N}"));
199    }
200
201    #[test]
202    fn ship_prompt_sequences_code_review_before_ship() {
203        let prompt = stage_prompt(Stage::Ship, 13);
204        let review_pos = prompt
205            .find("/gsd-code-review 13")
206            .expect("Ship prompt must run /gsd-code-review {N}");
207        let ship_pos = prompt
208            .find("/gsd-ship 13")
209            .expect("Ship prompt must run /gsd-ship {N}");
210        assert!(
211            review_pos < ship_pos,
212            "code-review must be sequenced before ship"
213        );
214    }
215
216    #[test]
217    fn ship_prompt_defines_critical_gate_and_review_failed_contract() {
218        let prompt = stage_prompt(Stage::Ship, 13);
219        assert!(
220            prompt.contains("REVIEW.md"),
221            "Ship prompt must reference the REVIEW.md artifact"
222        );
223        assert!(
224            prompt.to_lowercase().contains("critical"),
225            "Ship prompt must name the Critical-severity gate"
226        );
227        assert!(
228            prompt.contains("do not run")
229                || prompt.contains("do NOT run")
230                || prompt.contains("DO NOT run"),
231            "Ship prompt must instruct the agent not to run /gsd-ship on Critical findings"
232        );
233        assert!(
234            prompt.contains("review:"),
235            "Ship prompt must define the review: ReviewFailed reason convention"
236        );
237        assert!(prompt.contains("DEVFLOW_RESULT"));
238    }
239
240    #[test]
241    fn code_stage_prompt_is_unchanged_single_command_template() {
242        // Validate is excluded here (Task 2, 13-05): it now gets its own
243        // dedicated prompt requiring a verdict — see
244        // `validate_stage_prompt_requires_verdict` below. Define and Plan
245        // are excluded too (13-06 dogfood): they carry the idempotency
246        // contract — see `define_and_plan_prompts_are_idempotent` below.
247        let prompt = stage_prompt(Stage::Code, 9);
248        assert!(prompt.contains("/gsd-execute-phase 9"));
249        assert!(prompt.contains("DEVFLOW_RESULT"));
250        assert!(
251            !prompt.contains("/gsd-code-review"),
252            "Code prompt should not carry Ship-specific code-review sequencing"
253        );
254        assert!(
255            !prompt.contains("already exists"),
256            "Code prompt should not carry the Define/Plan idempotency contract"
257        );
258    }
259
260    /// 13-06 dogfood regression (Codex leg): GSD's discuss-phase demands an
261    /// interactive decision when CONTEXT.md already exists, which headless
262    /// Codex can never answer — Define/Plan must no-op with success when
263    /// their deliverable pre-exists.
264    #[test]
265    fn define_and_plan_prompts_are_idempotent() {
266        let cases = [
267            (Stage::Define, "/gsd-discuss-phase 9", "09-*CONTEXT.md"),
268            (Stage::Plan, "/gsd-plan-phase 9", "09-*PLAN.md"),
269        ];
270        for (stage, command, artifact_glob) in cases {
271            let prompt = stage_prompt(stage, 9);
272            assert!(prompt.contains(command), "{stage} prompt missing {command}");
273            assert!(
274                prompt.contains(artifact_glob),
275                "{stage} prompt must check for its pre-existing artifact"
276            );
277            assert!(
278                prompt.contains("Do NOT run the GSD command"),
279                "{stage} prompt must no-op when the artifact exists"
280            );
281            assert!(
282                prompt.contains("do NOT ask for input"),
283                "{stage} prompt must forbid interactive input"
284            );
285            assert!(prompt.contains("DEVFLOW_RESULT"));
286        }
287    }
288
289    #[test]
290    fn validate_stage_prompt_requires_verdict() {
291        let prompt = stage_prompt(Stage::Validate, 13);
292        assert!(
293            prompt.contains("/gsd-validate-phase 13"),
294            "Validate prompt missing its GSD command"
295        );
296        assert!(
297            prompt.contains("\"verdict\": \"pass\""),
298            "Validate prompt must name the exact lowercase pass verdict"
299        );
300        assert!(
301            prompt.contains("\"verdict\": \"gaps\""),
302            "Validate prompt must name the exact lowercase gaps verdict"
303        );
304        assert!(prompt.contains("REQUIRED"));
305        assert!(prompt.contains("DEVFLOW_RESULT"));
306    }
307
308    #[test]
309    fn fix_prompts_select_the_right_command() {
310        assert!(fix_prompt(FixType::AuditFix, 11).contains("/gsd-audit-fix 11"));
311        assert!(fix_prompt(FixType::GapsOnly, 11).contains("--gaps-only"));
312        assert!(fix_prompt(FixType::AuditFix, 11).contains("DEVFLOW_RESULT"));
313    }
314}