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;
9use std::path::Path;
10
11const SHIP_REVIEW_ANGLES: &[&str] = &[
12    "doc-accuracy cross-reference (do documented claims match source?)",
13    "security / leaked-data (does anything commit secrets, session data, or telemetry?)",
14    "CI/build correctness (can a failing step still report green?)",
15    "external-state claims (does the diff claim merges, tags, or deletions that are not actually true?)",
16    "one generalist deep pass",
17];
18
19/// The completion contract every agent must honor as its final message.
20const COMPLETION_PROTOCOL: &str = "\
21## Completion Protocol (REQUIRED)\n\
22\n\
23When all work is done, your FINAL message must be exactly:\n\
24\n\
25DEVFLOW_RESULT: {\"status\": \"success\"}\n\
26\n\
27If something prevents completion:\n\
28\n\
29DEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"specific explanation\"}\n\
30\n\
31DevFlow reads this line to decide whether the stage succeeded. \
32Output nothing after it.";
33
34/// A fix variant used when looping Code ↔ Validate.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FixType {
37    /// Run the GSD audit-fix pipeline over review findings.
38    AuditFix,
39    /// Re-run execution targeting only the gaps left by validation.
40    GapsOnly,
41}
42
43/// Substitute the `{N}` phase placeholder in a GSD command string.
44fn gsd_command_for(stage: Stage, phase: u32) -> String {
45    stage.gsd_command().replace("{N}", &phase.to_string())
46}
47
48/// The Ship stage's dedicated prompt.
49///
50/// Headless-safety rationale: `/gsd-ship`'s own `optional_review` step is an
51/// interactive `AskUserQuestion` with undefined behavior under
52/// `--dangerously-skip-permissions` (RESEARCH Pitfall 2). Rather than relying
53/// on that step being skipped, this prompt sidesteps it entirely: the agent
54/// runs `/gsd-code-review {N}` first (non-interactive; writes `REVIEW.md`
55/// with severity-classified findings), and MUST NOT run `/gsd-ship {N}` at
56/// all if `REVIEW.md` contains any Critical-severity finding — instead it
57/// reports a `review:`-prefixed failure. Only a clean (no-Critical) review
58/// proceeds to `/gsd-ship {N}`. The `review:` reason prefix is the
59/// ReviewFailed contract that `handle_ship_failure` matches (trimmed,
60/// case-folded) to loop back to Code with `AuditFix`.
61fn ship_stage_prompt(phase: u32, review_angles: &[String]) -> String {
62    let code_review = format!("/gsd-code-review {phase}");
63    let ship = format!("/gsd-ship {phase}");
64    let review_angles = review_angles
65        .iter()
66        .map(|angle| format!("- {angle}"))
67        .collect::<Vec<_>>()
68        .join("\n");
69    format!(
70        "Run the Ship stage in two steps:\n\
71        \n\
72        1. Run `{code_review}` (non-interactive). This writes a `REVIEW.md` \
73        artifact with severity-classified findings. Review at high depth from \
74        every angle below:\n\
75        \n\
76        {review_angles}\n\
77        \n\
78        If your harness supports parallel finder subagents, dispatch one per \
79        angle; otherwise run each angle as a focused sequential pass. Merge \
80        and deduplicate every angle's findings into one `REVIEW.md`.\n\
81        2. Check `REVIEW.md` for the Critical-severity gate:\n\
82        \n\
83        - If `REVIEW.md` contains ANY finding at Critical severity: do NOT \
84        run `{ship}` at all. Your FINAL message must be exactly:\n\
85        \n\
86        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"review: <short summary of the Critical findings>\"}}\n\
87        \n\
88        - If `REVIEW.md` has NO Critical-severity findings: run `{ship}` and \
89        report the outcome via the normal completion protocol below.\n\
90        \n\
91        {COMPLETION_PROTOCOL}"
92    )
93}
94
95/// The Validate stage's dedicated prompt.
96///
97/// 13b verdict-vs-ran: `status` only reports whether the stage's task (running
98/// `/gsd-validate-phase {N}`) completed — it says nothing about whether
99/// validation itself passed. This prompt REQUIRES a distinct `verdict` field
100/// so `advance()`'s Validate arm can tell "the agent ran validation" apart
101/// from "validation passed," and never advances to Ship on a bare `status:
102/// success` for this stage.
103fn validate_stage_prompt(phase: u32) -> String {
104    let command = gsd_command_for(Stage::Validate, phase);
105    format!(
106        "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
107        ## Completion Protocol (REQUIRED)\n\
108        \n\
109        When all work is done, your FINAL message must be exactly one of:\n\
110        \n\
111        DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"pass\"}}\n\
112        \n\
113        if validation found NO gaps, or:\n\
114        \n\
115        DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"gaps\"}}\n\
116        \n\
117        if validation found gaps that still need fixing. The `verdict` field \
118        is REQUIRED for this stage — it is distinct from `status` (which only \
119        reports whether the validation task itself completed) and MUST be \
120        exactly the lowercase string `pass` or `gaps`.\n\
121        \n\
122        If something prevents completion:\n\
123        \n\
124        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"specific explanation\"}}\n\
125        \n\
126        DevFlow reads this line to decide whether the stage succeeded. \
127        Output nothing after it."
128    )
129}
130
131/// The Define and Plan stages' idempotency contract.
132///
133/// Headless-safety rationale (13-06 dogfood finding, Codex leg): GSD's
134/// discuss-phase demands an interactive "Overwrite/Append/Cancel" decision
135/// when the phase's CONTEXT.md already exists, and headless Codex cannot
136/// answer it (`request_user_input is unavailable`) — the stage would fail on
137/// every retry, forever. When the stage's deliverable already exists, the
138/// stage's work is done: re-running it must be a no-op success, not an
139/// interactive dead end. This is idempotency for a completed stage, NOT the
140/// v1 skip-stage config flags removed by the 2026-06-19 architecture
141/// decision — a stage with no pre-existing artifact still runs in full.
142fn idempotent_stage_prompt(stage: Stage, phase: u32) -> String {
143    let artifact = match stage {
144        Stage::Define => "CONTEXT.md",
145        _ => "PLAN.md",
146    };
147    let command = gsd_command_for(stage, phase);
148    let padded = format!("{phase:02}");
149    format!(
150        "First check whether this stage's deliverable already exists:\n\
151        \n\
152        ls .planning/phases/{padded}-*/{padded}-*{artifact} 2>/dev/null\n\
153        \n\
154        - If it EXISTS: the stage's work is already done. Do NOT run the GSD \
155        command, do NOT ask for input, and do NOT modify the existing \
156        artifacts. Your FINAL message must be exactly:\n\
157        \n\
158        DEVFLOW_RESULT: {{\"status\": \"success\"}}\n\
159        \n\
160        - If it does NOT exist: run the GSD workflow command for this stage:\n\
161        \n\
162        \x20   {command}\n\
163        \n\
164        {COMPLETION_PROTOCOL}"
165    )
166}
167
168/// Build the prompt for a stage of a phase.
169pub fn stage_prompt(stage: Stage, phase: u32) -> String {
170    stage_prompt_with_project(stage, phase, None)
171}
172
173/// Build a stage prompt with project-local configuration applied.
174///
175/// The CLI uses this entry point after resolving the canonical project root;
176/// library callers that have no project context keep using [`stage_prompt`]
177/// and receive built-in defaults.
178pub fn stage_prompt_for_project(stage: Stage, phase: u32, project_root: &Path) -> String {
179    stage_prompt_with_project(stage, phase, Some(project_root))
180}
181
182fn stage_prompt_with_project(stage: Stage, phase: u32, project_root: Option<&Path>) -> String {
183    if stage == Stage::Ship {
184        let review_angles = project_root
185            .and_then(crate::config::review_angles)
186            .unwrap_or_else(|| {
187                SHIP_REVIEW_ANGLES
188                    .iter()
189                    .map(|angle| (*angle).to_owned())
190                    .collect()
191            });
192        return ship_stage_prompt(phase, &review_angles);
193    }
194    if stage == Stage::Validate {
195        return validate_stage_prompt(phase);
196    }
197    if matches!(stage, Stage::Define | Stage::Plan) {
198        return idempotent_stage_prompt(stage, phase);
199    }
200    let command = gsd_command_for(stage, phase);
201    if stage == Stage::Code {
202        return format!(
203            "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
204            ## Advisory incremental self-review\n\
205            \n\
206            After each plan or wave lands, perform a quick, shallow self-check \
207            for doc accuracy, leaked data, CI/build correctness, and \
208            external-state claims. Record any drift in the working output and \
209            continue execution; the authoritative review happens during Ship. \
210            This check must not pause execution or request human input.\n\
211            \n\
212            {COMPLETION_PROTOCOL}"
213        );
214    }
215    format!(
216        "Run the GSD workflow command for this stage:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
217    )
218}
219
220/// Build a fix prompt used on Code → Validate loop-backs.
221pub fn fix_prompt(fix_type: FixType, phase: u32) -> String {
222    let command = match fix_type {
223        FixType::AuditFix => format!("/gsd-audit-fix {phase}"),
224        FixType::GapsOnly => format!("/gsd-execute-phase {phase} --gaps-only"),
225    };
226    format!(
227        "Validation reported issues. Run the fix command for this loop:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
228    )
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn each_stage_prompt_carries_its_gsd_command_and_marker() {
237        let cases = [
238            (Stage::Define, "/gsd-discuss-phase 11"),
239            (Stage::Plan, "/gsd-plan-phase 11"),
240            (Stage::Code, "/gsd-execute-phase 11"),
241            (Stage::Validate, "/gsd-validate-phase 11"),
242            (Stage::Ship, "/gsd-ship 11"),
243        ];
244        for (stage, command) in cases {
245            let prompt = stage_prompt(stage, 11);
246            assert!(prompt.contains(command), "{stage} prompt missing {command}");
247            assert!(prompt.contains("DEVFLOW_RESULT"));
248        }
249    }
250
251    #[test]
252    fn phase_placeholder_is_substituted() {
253        assert!(stage_prompt(Stage::Code, 7).contains("/gsd-execute-phase 7"));
254        assert!(!stage_prompt(Stage::Code, 7).contains("{N}"));
255    }
256
257    #[test]
258    fn ship_prompt_sequences_code_review_before_ship() {
259        let prompt = stage_prompt(Stage::Ship, 13);
260        let review_pos = prompt
261            .find("/gsd-code-review 13")
262            .expect("Ship prompt must run /gsd-code-review {N}");
263        let ship_pos = prompt
264            .find("/gsd-ship 13")
265            .expect("Ship prompt must run /gsd-ship {N}");
266        assert!(
267            review_pos < ship_pos,
268            "code-review must be sequenced before ship"
269        );
270    }
271
272    #[test]
273    fn ship_prompt_defines_critical_gate_and_review_failed_contract() {
274        let prompt = stage_prompt(Stage::Ship, 13);
275        assert!(
276            prompt.contains("REVIEW.md"),
277            "Ship prompt must reference the REVIEW.md artifact"
278        );
279        assert!(
280            prompt.to_lowercase().contains("critical"),
281            "Ship prompt must name the Critical-severity gate"
282        );
283        assert!(
284            prompt.contains("do not run")
285                || prompt.contains("do NOT run")
286                || prompt.contains("DO NOT run"),
287            "Ship prompt must instruct the agent not to run /gsd-ship on Critical findings"
288        );
289        assert!(
290            prompt.contains("review:"),
291            "Ship prompt must define the review: ReviewFailed reason convention"
292        );
293        assert!(prompt.contains("DEVFLOW_RESULT"));
294    }
295
296    #[test]
297    fn ship_prompt_includes_multi_angle_conditional_review() {
298        let prompt = stage_prompt(Stage::Ship, 13);
299        for angle in [
300            "doc-accuracy cross-reference",
301            "security / leaked-data",
302            "CI/build correctness",
303            "external-state claims",
304            "generalist deep pass",
305        ] {
306            assert!(prompt.contains(angle), "Ship prompt missing angle: {angle}");
307        }
308        assert!(prompt.contains("parallel finder subagents"));
309        assert!(prompt.contains("focused sequential pass"));
310        assert!(prompt.contains("Merge and deduplicate"));
311        assert!(prompt.contains("REVIEW.md"));
312    }
313
314    #[test]
315    fn ship_prompt_uses_project_review_angle_override() {
316        let dir = tempfile::tempdir().unwrap();
317        std::fs::write(
318            dir.path().join("devflow.toml"),
319            "review_angles = [\"custom release evidence\", \"custom threat boundary\"]\n",
320        )
321        .unwrap();
322
323        let prompt = stage_prompt_for_project(Stage::Ship, 13, dir.path());
324
325        assert!(prompt.contains("custom release evidence"));
326        assert!(prompt.contains("custom threat boundary"));
327        assert!(!prompt.contains("doc-accuracy cross-reference"));
328    }
329
330    #[test]
331    fn code_stage_prompt_is_unchanged_single_command_template() {
332        // Validate is excluded here (Task 2, 13-05): it now gets its own
333        // dedicated prompt requiring a verdict — see
334        // `validate_stage_prompt_requires_verdict` below. Define and Plan
335        // are excluded too (13-06 dogfood): they carry the idempotency
336        // contract — see `define_and_plan_prompts_are_idempotent` below.
337        let prompt = stage_prompt(Stage::Code, 9);
338        assert!(prompt.contains("/gsd-execute-phase 9"));
339        assert!(prompt.contains("DEVFLOW_RESULT"));
340        assert!(
341            !prompt.contains("/gsd-code-review"),
342            "Code prompt should not carry Ship-specific code-review sequencing"
343        );
344        assert!(
345            !prompt.contains("already exists"),
346            "Code prompt should not carry the Define/Plan idempotency contract"
347        );
348        assert!(prompt.contains("Advisory incremental self-review"));
349        for angle in [
350            "doc accuracy",
351            "leaked data",
352            "CI/build correctness",
353            "external-state claims",
354        ] {
355            assert!(prompt.contains(angle), "Code prompt missing angle: {angle}");
356        }
357        assert!(!prompt.contains("AskUserQuestion"));
358        assert!(!prompt.contains("request_user_input"));
359    }
360
361    /// 13-06 dogfood regression (Codex leg): GSD's discuss-phase demands an
362    /// interactive decision when CONTEXT.md already exists, which headless
363    /// Codex can never answer — Define/Plan must no-op with success when
364    /// their deliverable pre-exists.
365    #[test]
366    fn define_and_plan_prompts_are_idempotent() {
367        let cases = [
368            (Stage::Define, "/gsd-discuss-phase 9", "09-*CONTEXT.md"),
369            (Stage::Plan, "/gsd-plan-phase 9", "09-*PLAN.md"),
370        ];
371        for (stage, command, artifact_glob) in cases {
372            let prompt = stage_prompt(stage, 9);
373            assert!(prompt.contains(command), "{stage} prompt missing {command}");
374            assert!(
375                prompt.contains(artifact_glob),
376                "{stage} prompt must check for its pre-existing artifact"
377            );
378            assert!(
379                prompt.contains("Do NOT run the GSD command"),
380                "{stage} prompt must no-op when the artifact exists"
381            );
382            assert!(
383                prompt.contains("do NOT ask for input"),
384                "{stage} prompt must forbid interactive input"
385            );
386            assert!(prompt.contains("DEVFLOW_RESULT"));
387        }
388    }
389
390    #[test]
391    fn validate_stage_prompt_requires_verdict() {
392        let prompt = stage_prompt(Stage::Validate, 13);
393        assert!(
394            prompt.contains("/gsd-validate-phase 13"),
395            "Validate prompt missing its GSD command"
396        );
397        assert!(
398            prompt.contains("\"verdict\": \"pass\""),
399            "Validate prompt must name the exact lowercase pass verdict"
400        );
401        assert!(
402            prompt.contains("\"verdict\": \"gaps\""),
403            "Validate prompt must name the exact lowercase gaps verdict"
404        );
405        assert!(prompt.contains("REQUIRED"));
406        assert!(prompt.contains("DEVFLOW_RESULT"));
407    }
408
409    #[test]
410    fn fix_prompts_select_the_right_command() {
411        assert!(fix_prompt(FixType::AuditFix, 11).contains("/gsd-audit-fix 11"));
412        assert!(fix_prompt(FixType::GapsOnly, 11).contains("--gaps-only"));
413        assert!(fix_prompt(FixType::AuditFix, 11).contains("DEVFLOW_RESULT"));
414    }
415}