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 Plan stage's idempotency contract.
132///
133/// Headless-safety rationale (13-06 dogfood finding, Codex leg): GSD's
134/// plan-phase demands an interactive "Overwrite/Append/Cancel" decision
135/// when the phase's PLAN.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.
142///
143/// D-14 update: Define used to share this branch (dispatched by a `stage`
144/// parameter), but its missing-artifact arm ran an interactive interview
145/// command that cannot be answered headlessly. That branch was deleted
146/// rather than made conditional — see [`define_stage_prompt`] for Define's
147/// actual (always-no-op) contract. This function now serves only Plan.
148fn idempotent_stage_prompt(phase: u32) -> String {
149    let artifact = "PLAN.md";
150    let command = gsd_command_for(Stage::Plan, phase);
151    let padded = format!("{phase:02}");
152    format!(
153        "First check whether this stage's deliverable already exists:\n\
154        \n\
155        ls .planning/phases/{padded}-*/{padded}-*{artifact} 2>/dev/null\n\
156        \n\
157        - If it EXISTS: the stage's work is already done. Do NOT run the GSD \
158        command, do NOT ask for input, and do NOT modify the existing \
159        artifacts. Your FINAL message must be exactly:\n\
160        \n\
161        DEVFLOW_RESULT: {{\"status\": \"success\"}}\n\
162        \n\
163        - If it does NOT exist: run the GSD workflow command for this stage:\n\
164        \n\
165        \x20   {command}\n\
166        \n\
167        {COMPLETION_PROTOCOL}"
168    )
169}
170
171/// The Define stage's dedicated prompt (D-14).
172///
173/// Headless-safety rationale: `idempotent_stage_prompt`'s missing-artifact
174/// arm used to run an interactive interview command for Define — one that
175/// hangs or errors under `claude -p` with no operator present to answer it
176/// (T-28-08). D-14 settles the fix as deletion, not disambiguation: the
177/// Define stage never runs that command in a DevFlow launch, whether or not
178/// CONTEXT.md already exists. The operator decides whether to run the
179/// interview before invoking `devflow start`; DevFlow makes no runtime
180/// accommodation for that choice.
181fn define_stage_prompt(phase: u32) -> String {
182    format!(
183        "This is the Define stage of a headless DevFlow run for phase {phase}.\n\
184        \n\
185        There is no agent work to perform here. Whether or not this phase's \
186        CONTEXT.md already exists, you must NOT run an interactive \
187        discuss-phase or interview command, and you must NOT ask for input \
188        — this run is headless and no operator is available to answer \
189        interactive questions. Do NOT modify any existing planning \
190        artifacts.\n\
191        \n\
192        {COMPLETION_PROTOCOL}"
193    )
194}
195
196/// Build the prompt for a stage of a phase.
197pub fn stage_prompt(stage: Stage, phase: u32) -> String {
198    stage_prompt_with_project(stage, phase, None)
199}
200
201/// Build a stage prompt with project-local configuration applied.
202///
203/// The CLI uses this entry point after resolving the canonical project root;
204/// library callers that have no project context keep using [`stage_prompt`]
205/// and receive built-in defaults.
206pub fn stage_prompt_for_project(stage: Stage, phase: u32, project_root: &Path) -> String {
207    stage_prompt_with_project(stage, phase, Some(project_root))
208}
209
210fn stage_prompt_with_project(stage: Stage, phase: u32, project_root: Option<&Path>) -> String {
211    if stage == Stage::Ship {
212        let review_angles = project_root
213            .and_then(crate::config::review_angles)
214            .unwrap_or_else(|| {
215                SHIP_REVIEW_ANGLES
216                    .iter()
217                    .map(|angle| (*angle).to_owned())
218                    .collect()
219            });
220        return ship_stage_prompt(phase, &review_angles);
221    }
222    if stage == Stage::Validate {
223        return validate_stage_prompt(phase);
224    }
225    if stage == Stage::Define {
226        return define_stage_prompt(phase);
227    }
228    if stage == Stage::Plan {
229        return idempotent_stage_prompt(phase);
230    }
231    let command = gsd_command_for(stage, phase);
232    if stage == Stage::Code {
233        return format!(
234            "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
235            ## Advisory incremental self-review\n\
236            \n\
237            After each plan or wave lands, perform a quick, shallow self-check \
238            for doc accuracy, leaked data, CI/build correctness, and \
239            external-state claims. Record any drift in the working output and \
240            continue execution; the authoritative review happens during Ship. \
241            This check must not pause execution or request human input.\n\
242            \n\
243            {COMPLETION_PROTOCOL}"
244        );
245    }
246    format!(
247        "Run the GSD workflow command for this stage:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
248    )
249}
250
251/// The synthesized instruction sent into a resumed Claude session when a
252/// confirmed human-blocking checkpoint has nobody available to answer it
253/// (D-03, 28-CONTEXT.md): DevFlow's default, unconditional policy — no flag,
254/// no config toggle — is for the agent to resolve the checkpoint itself,
255/// using its own judgment, and record why.
256///
257/// Deliberately deterministic: no timestamp, no random content, no varying
258/// state. Two calls for the same `phase` produce byte-identical strings, so
259/// the `checkpoint_auto_decided` audit event (D-07, plan 28-03) can quote
260/// this exact instruction without churning on every resume. `phase` is
261/// included only for operator legibility in the captured stdout — the
262/// instruction's meaning does not depend on it.
263pub fn checkpoint_auto_decide_prompt(phase: u32) -> String {
264    format!(
265        "This is phase {phase} of a headless DevFlow run. You previously \
266        stopped at a human-blocking checkpoint, but no human operator is \
267        available to answer it — this run is unattended, and none is \
268        coming. DevFlow's policy is for you to resolve the checkpoint \
269        yourself, using your own best judgment, and continue the work. You \
270        MUST record your reasoning for the decision you made in your final \
271        message, so the decision is auditable after the fact.\n\
272        \n\
273        {COMPLETION_PROTOCOL}"
274    )
275}
276
277/// Build a fix prompt used on Code → Validate loop-backs.
278pub fn fix_prompt(fix_type: FixType, phase: u32) -> String {
279    let command = match fix_type {
280        FixType::AuditFix => format!("/gsd-audit-fix {phase}"),
281        FixType::GapsOnly => format!("/gsd-execute-phase {phase} --gaps-only"),
282    };
283    format!(
284        "Validation reported issues. Run the fix command for this loop:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
285    )
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn each_stage_prompt_carries_its_gsd_command_and_marker() {
294        // Define is excluded here (D-14): its prompt never contains its GSD
295        // command — see `define_prompt_never_invokes_discuss_phase` below.
296        let cases = [
297            (Stage::Plan, "/gsd-plan-phase 11"),
298            (Stage::Code, "/gsd-execute-phase 11"),
299            (Stage::Validate, "/gsd-validate-phase 11"),
300            (Stage::Ship, "/gsd-ship 11"),
301        ];
302        for (stage, command) in cases {
303            let prompt = stage_prompt(stage, 11);
304            assert!(prompt.contains(command), "{stage} prompt missing {command}");
305            assert!(prompt.contains("DEVFLOW_RESULT"));
306        }
307    }
308
309    #[test]
310    fn phase_placeholder_is_substituted() {
311        assert!(stage_prompt(Stage::Code, 7).contains("/gsd-execute-phase 7"));
312        assert!(!stage_prompt(Stage::Code, 7).contains("{N}"));
313    }
314
315    #[test]
316    fn ship_prompt_sequences_code_review_before_ship() {
317        let prompt = stage_prompt(Stage::Ship, 13);
318        let review_pos = prompt
319            .find("/gsd-code-review 13")
320            .expect("Ship prompt must run /gsd-code-review {N}");
321        let ship_pos = prompt
322            .find("/gsd-ship 13")
323            .expect("Ship prompt must run /gsd-ship {N}");
324        assert!(
325            review_pos < ship_pos,
326            "code-review must be sequenced before ship"
327        );
328    }
329
330    #[test]
331    fn ship_prompt_defines_critical_gate_and_review_failed_contract() {
332        let prompt = stage_prompt(Stage::Ship, 13);
333        assert!(
334            prompt.contains("REVIEW.md"),
335            "Ship prompt must reference the REVIEW.md artifact"
336        );
337        assert!(
338            prompt.to_lowercase().contains("critical"),
339            "Ship prompt must name the Critical-severity gate"
340        );
341        assert!(
342            prompt.contains("do not run")
343                || prompt.contains("do NOT run")
344                || prompt.contains("DO NOT run"),
345            "Ship prompt must instruct the agent not to run /gsd-ship on Critical findings"
346        );
347        assert!(
348            prompt.contains("review:"),
349            "Ship prompt must define the review: ReviewFailed reason convention"
350        );
351        assert!(prompt.contains("DEVFLOW_RESULT"));
352    }
353
354    #[test]
355    fn ship_prompt_includes_multi_angle_conditional_review() {
356        let prompt = stage_prompt(Stage::Ship, 13);
357        for angle in [
358            "doc-accuracy cross-reference",
359            "security / leaked-data",
360            "CI/build correctness",
361            "external-state claims",
362            "generalist deep pass",
363        ] {
364            assert!(prompt.contains(angle), "Ship prompt missing angle: {angle}");
365        }
366        assert!(prompt.contains("parallel finder subagents"));
367        assert!(prompt.contains("focused sequential pass"));
368        assert!(prompt.contains("Merge and deduplicate"));
369        assert!(prompt.contains("REVIEW.md"));
370    }
371
372    #[test]
373    fn ship_prompt_uses_project_review_angle_override() {
374        let dir = tempfile::tempdir().unwrap();
375        std::fs::write(
376            dir.path().join("devflow.toml"),
377            "review_angles = [\"custom release evidence\", \"custom threat boundary\"]\n",
378        )
379        .unwrap();
380
381        let prompt = stage_prompt_for_project(Stage::Ship, 13, dir.path());
382
383        assert!(prompt.contains("custom release evidence"));
384        assert!(prompt.contains("custom threat boundary"));
385        assert!(!prompt.contains("doc-accuracy cross-reference"));
386    }
387
388    #[test]
389    fn code_stage_prompt_is_unchanged_single_command_template() {
390        // Validate is excluded here (Task 2, 13-05): it now gets its own
391        // dedicated prompt requiring a verdict — see
392        // `validate_stage_prompt_requires_verdict` below. Define and Plan
393        // are excluded too: Plan carries the idempotency contract (see
394        // `plan_prompt_is_idempotent` below); Define carries its own D-14
395        // always-no-op contract (see `define_prompt_never_invokes_discuss_phase`
396        // below).
397        let prompt = stage_prompt(Stage::Code, 9);
398        assert!(prompt.contains("/gsd-execute-phase 9"));
399        assert!(prompt.contains("DEVFLOW_RESULT"));
400        assert!(
401            !prompt.contains("/gsd-code-review"),
402            "Code prompt should not carry Ship-specific code-review sequencing"
403        );
404        assert!(
405            !prompt.contains("already exists"),
406            "Code prompt should not carry the Define/Plan idempotency contract"
407        );
408        assert!(prompt.contains("Advisory incremental self-review"));
409        for angle in [
410            "doc accuracy",
411            "leaked data",
412            "CI/build correctness",
413            "external-state claims",
414        ] {
415            assert!(prompt.contains(angle), "Code prompt missing angle: {angle}");
416        }
417        assert!(!prompt.contains("AskUserQuestion"));
418        assert!(!prompt.contains("request_user_input"));
419    }
420
421    /// 13-06 dogfood regression (Codex leg), Plan half only after the D-14
422    /// split: GSD's plan-phase demands an interactive decision when PLAN.md
423    /// already exists, which headless Codex can never answer — Plan must
424    /// no-op with success when its deliverable pre-exists. See T-28-09.
425    #[test]
426    fn plan_prompt_is_idempotent() {
427        let prompt = stage_prompt(Stage::Plan, 9);
428        assert!(
429            prompt.contains("/gsd-plan-phase 9"),
430            "Plan prompt missing /gsd-plan-phase 9"
431        );
432        assert!(
433            prompt.contains("09-*PLAN.md"),
434            "Plan prompt must check for its pre-existing artifact"
435        );
436        assert!(
437            prompt.contains("Do NOT run the GSD command"),
438            "Plan prompt must no-op when the artifact exists"
439        );
440        assert!(
441            prompt.contains("do NOT ask for input"),
442            "Plan prompt must forbid interactive input"
443        );
444        assert!(prompt.contains("DEVFLOW_RESULT"));
445    }
446
447    /// D-14: the Define stage must never invoke the interactive
448    /// discuss-phase command, whether or not CONTEXT.md exists — the branch
449    /// that did so is deleted, not disambiguated. Regression guard for
450    /// T-28-08 (a headless run has no operator to answer it).
451    #[test]
452    fn define_prompt_never_invokes_discuss_phase() {
453        let prompt = stage_prompt(Stage::Define, 9);
454        assert!(
455            !prompt.contains("/gsd-discuss-phase"),
456            "Define prompt must never invoke the interactive discuss-phase command (D-14)"
457        );
458        assert!(
459            prompt.contains("must NOT run") || prompt.contains("do NOT run"),
460            "Define prompt must forbid running an interactive interview headlessly"
461        );
462        assert!(
463            prompt.contains("do NOT ask for input") || prompt.contains("must NOT ask for input"),
464            "Define prompt must forbid requesting input"
465        );
466        assert!(
467            prompt.to_lowercase().contains("modify"),
468            "Define prompt must forbid modifying existing planning artifacts"
469        );
470        assert!(prompt.contains("DEVFLOW_RESULT"));
471    }
472
473    #[test]
474    fn validate_stage_prompt_requires_verdict() {
475        let prompt = stage_prompt(Stage::Validate, 13);
476        assert!(
477            prompt.contains("/gsd-validate-phase 13"),
478            "Validate prompt missing its GSD command"
479        );
480        assert!(
481            prompt.contains("\"verdict\": \"pass\""),
482            "Validate prompt must name the exact lowercase pass verdict"
483        );
484        assert!(
485            prompt.contains("\"verdict\": \"gaps\""),
486            "Validate prompt must name the exact lowercase gaps verdict"
487        );
488        assert!(prompt.contains("REQUIRED"));
489        assert!(prompt.contains("DEVFLOW_RESULT"));
490    }
491
492    #[test]
493    fn fix_prompts_select_the_right_command() {
494        assert!(fix_prompt(FixType::AuditFix, 11).contains("/gsd-audit-fix 11"));
495        assert!(fix_prompt(FixType::GapsOnly, 11).contains("--gaps-only"));
496        assert!(fix_prompt(FixType::AuditFix, 11).contains("DEVFLOW_RESULT"));
497    }
498
499    /// D-03/D-07 (28-03): the audit event quotes this instruction verbatim,
500    /// so it must be byte-identical across calls for the same phase — no
501    /// timestamp, no random content that would churn the recorded string.
502    #[test]
503    fn checkpoint_auto_decide_prompt_is_deterministic() {
504        assert_eq!(
505            checkpoint_auto_decide_prompt(28),
506            checkpoint_auto_decide_prompt(28)
507        );
508    }
509
510    #[test]
511    fn checkpoint_auto_decide_prompt_terminates_with_completion_protocol() {
512        let prompt = checkpoint_auto_decide_prompt(28);
513        assert!(
514            prompt.ends_with(COMPLETION_PROTOCOL),
515            "the resumed session's exit must still be parseable by the same \
516             Layer 1 path as any other stage"
517        );
518        assert!(prompt.contains("DEVFLOW_RESULT"));
519    }
520
521    #[test]
522    fn checkpoint_auto_decide_prompt_states_no_operator_judgment_and_record_reasoning() {
523        let prompt = checkpoint_auto_decide_prompt(28).to_lowercase();
524        assert!(
525            prompt.contains("no human operator") || prompt.contains("nobody"),
526            "must state plainly that no operator is available"
527        );
528        assert!(
529            prompt.contains("judgment") || prompt.contains("judgement"),
530            "must instruct the agent to use its own judgment"
531        );
532        assert!(
533            prompt.contains("record") && prompt.contains("reasoning"),
534            "must require recording the reasoning in the final message, since \
535             this is the ONLY record of what was decided (D-07)"
536        );
537    }
538
539    #[test]
540    fn checkpoint_auto_decide_prompt_substitutes_phase_for_legibility() {
541        assert!(checkpoint_auto_decide_prompt(42).contains("phase 42"));
542    }
543}