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///
36/// `#[non_exhaustive]`: operator decision, 2026-08-04. This enum is public in
37/// the published `devflow-core` crate, so adding a variant is already a
38/// breaking change for any external crate matching on it exhaustively — this
39/// release (`FullExecute`, added for D-01) already pays that cost. Paying for
40/// `#[non_exhaustive]` at the same time makes every later variant addition
41/// additive instead of breaking again, the same reasoning `State` records for
42/// its own `#[non_exhaustive]` (`state.rs:30-31`). Verified empirically before
43/// applying: the only `match` over a `FixType` value anywhere in the
44/// workspace is `fix_prompt` below, which lives in this crate and is
45/// therefore unaffected by the attribute — no wildcard arm is needed, here or
46/// anywhere else in the workspace, and none should be added to `fix_prompt`
47/// itself (see its doc comment).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum FixType {
51    /// Run the GSD audit-fix pipeline over review findings.
52    AuditFix,
53    /// Re-run execution targeting only the gaps left by validation.
54    GapsOnly,
55    /// Re-run the phase's remaining plans with the plain, unflagged
56    /// `/gsd-execute-phase {N}` command, because the phase is mid-arc rather
57    /// than defective — D-01 (33-CONTEXT.md): a phase with no
58    /// `{N}-VERIFICATION.md` yet has not been judged, so `--gaps-only` would
59    /// match zero plans and gate unresolvably.
60    FullExecute,
61}
62
63/// Substitute the `{N}` phase placeholder in a GSD command string.
64fn gsd_command_for(stage: Stage, phase: u32) -> String {
65    stage.gsd_command().replace("{N}", &phase.to_string())
66}
67
68/// The Ship stage's dedicated prompt.
69///
70/// Headless-safety rationale: `/gsd-ship`'s own `optional_review` step is an
71/// interactive `AskUserQuestion` with undefined behavior under
72/// `--dangerously-skip-permissions` (RESEARCH Pitfall 2). Rather than relying
73/// on that step being skipped, this prompt sidesteps it entirely: the agent
74/// runs `/gsd-code-review {N}` first (non-interactive; writes `REVIEW.md`
75/// with severity-classified findings), and MUST NOT run `/gsd-ship {N}` at
76/// all if `REVIEW.md` contains any Critical-severity finding — instead it
77/// reports a `review:`-prefixed failure. Only a clean (no-Critical) review
78/// proceeds to `/gsd-ship {N}`. The `review:` reason prefix is the
79/// ReviewFailed contract that `handle_ship_failure` matches (trimmed,
80/// case-folded) to loop back to Code with `AuditFix`.
81fn ship_stage_prompt(phase: u32, review_angles: &[String]) -> String {
82    let code_review = format!("/gsd-code-review {phase}");
83    let ship = format!("/gsd-ship {phase}");
84    let review_angles = review_angles
85        .iter()
86        .map(|angle| format!("- {angle}"))
87        .collect::<Vec<_>>()
88        .join("\n");
89    format!(
90        "Run the Ship stage in two steps:\n\
91        \n\
92        1. Run `{code_review}` (non-interactive). This writes a `REVIEW.md` \
93        artifact with severity-classified findings. Review at high depth from \
94        every angle below:\n\
95        \n\
96        {review_angles}\n\
97        \n\
98        If your harness supports parallel finder subagents, dispatch one per \
99        angle; otherwise run each angle as a focused sequential pass. Merge \
100        and deduplicate every angle's findings into one `REVIEW.md`.\n\
101        2. Check `REVIEW.md` for the Critical-severity gate:\n\
102        \n\
103        - If `REVIEW.md` contains ANY finding at Critical severity: do NOT \
104        run `{ship}` at all. Your FINAL message must be exactly:\n\
105        \n\
106        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"review: <short summary of the Critical findings>\"}}\n\
107        \n\
108        - If `REVIEW.md` has NO Critical-severity findings: run `{ship}` and \
109        report the outcome via the normal completion protocol below.\n\
110        \n\
111        {COMPLETION_PROTOCOL}"
112    )
113}
114
115/// The Validate stage's dedicated prompt.
116///
117/// 13b verdict-vs-ran: `status` only reports whether the stage's task (running
118/// `/gsd-validate-phase {N}`) completed — it says nothing about whether
119/// validation itself passed. This prompt REQUIRES a distinct `verdict` field
120/// so `advance()`'s Validate arm can tell "the agent ran validation" apart
121/// from "validation passed," and never advances to Ship on a bare `status:
122/// success` for this stage.
123fn validate_stage_prompt(phase: u32) -> String {
124    let command = gsd_command_for(Stage::Validate, phase);
125    format!(
126        "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
127        ## Completion Protocol (REQUIRED)\n\
128        \n\
129        When all work is done, your FINAL message must be exactly one of:\n\
130        \n\
131        DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"pass\"}}\n\
132        \n\
133        if validation found NO gaps, or:\n\
134        \n\
135        DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"gaps\"}}\n\
136        \n\
137        if validation found gaps that still need fixing. The `verdict` field \
138        is REQUIRED for this stage — it is distinct from `status` (which only \
139        reports whether the validation task itself completed) and MUST be \
140        exactly the lowercase string `pass` or `gaps`.\n\
141        \n\
142        If something prevents completion:\n\
143        \n\
144        DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"specific explanation\"}}\n\
145        \n\
146        DevFlow reads this line to decide whether the stage succeeded. \
147        Output nothing after it."
148    )
149}
150
151/// The Plan stage's idempotency contract.
152///
153/// Headless-safety rationale (13-06 dogfood finding, Codex leg): GSD's
154/// plan-phase demands an interactive "Overwrite/Append/Cancel" decision
155/// when the phase's PLAN.md already exists, and headless Codex cannot
156/// answer it (`request_user_input is unavailable`) — the stage would fail on
157/// every retry, forever. When the stage's deliverable already exists, the
158/// stage's work is done: re-running it must be a no-op success, not an
159/// interactive dead end. This is idempotency for a completed stage, NOT the
160/// v1 skip-stage config flags removed by the 2026-06-19 architecture
161/// decision — a stage with no pre-existing artifact still runs in full.
162///
163/// D-14 update: Define used to share this branch (dispatched by a `stage`
164/// parameter), but its missing-artifact arm ran an interactive interview
165/// command that cannot be answered headlessly. That branch was deleted
166/// rather than made conditional — see [`define_stage_prompt`] for Define's
167/// actual (always-no-op) contract. This function now serves only Plan.
168fn idempotent_stage_prompt(phase: u32) -> String {
169    let artifact = "PLAN.md";
170    let command = gsd_command_for(Stage::Plan, phase);
171    let padded = format!("{phase:02}");
172    format!(
173        "First check whether this stage's deliverable already exists:\n\
174        \n\
175        ls .planning/phases/{padded}-*/{padded}-*{artifact} 2>/dev/null\n\
176        \n\
177        - If it EXISTS: the stage's work is already done. Do NOT run the GSD \
178        command, do NOT ask for input, and do NOT modify the existing \
179        artifacts. Your FINAL message must be exactly:\n\
180        \n\
181        DEVFLOW_RESULT: {{\"status\": \"success\"}}\n\
182        \n\
183        - If it does NOT exist: run the GSD workflow command for this stage:\n\
184        \n\
185        \x20   {command}\n\
186        \n\
187        {COMPLETION_PROTOCOL}"
188    )
189}
190
191/// The Define stage's dedicated prompt (D-14).
192///
193/// Headless-safety rationale: `idempotent_stage_prompt`'s missing-artifact
194/// arm used to run an interactive interview command for Define — one that
195/// hangs or errors under `claude -p` with no operator present to answer it
196/// (T-28-08). D-14 settles the fix as deletion, not disambiguation: the
197/// Define stage never runs that command in a DevFlow launch, whether or not
198/// CONTEXT.md already exists. The operator decides whether to run the
199/// interview before invoking `devflow start`; DevFlow makes no runtime
200/// accommodation for that choice.
201fn define_stage_prompt(phase: u32) -> String {
202    format!(
203        "This is the Define stage of a headless DevFlow run for phase {phase}.\n\
204        \n\
205        There is no agent work to perform here. Whether or not this phase's \
206        CONTEXT.md already exists, you must NOT run an interactive \
207        discuss-phase or interview command, and you must NOT ask for input \
208        — this run is headless and no operator is available to answer \
209        interactive questions. Do NOT modify any existing planning \
210        artifacts.\n\
211        \n\
212        {COMPLETION_PROTOCOL}"
213    )
214}
215
216/// Build the prompt for a stage of a phase.
217pub fn stage_prompt(stage: Stage, phase: u32) -> String {
218    stage_prompt_with_project(stage, phase, None)
219}
220
221/// Build a stage prompt with project-local configuration applied.
222///
223/// The CLI uses this entry point after resolving the canonical project root;
224/// library callers that have no project context keep using [`stage_prompt`]
225/// and receive built-in defaults.
226pub fn stage_prompt_for_project(stage: Stage, phase: u32, project_root: &Path) -> String {
227    stage_prompt_with_project(stage, phase, Some(project_root))
228}
229
230fn stage_prompt_with_project(stage: Stage, phase: u32, project_root: Option<&Path>) -> String {
231    if stage == Stage::Ship {
232        let review_angles = project_root
233            .and_then(crate::config::review_angles)
234            .unwrap_or_else(|| {
235                SHIP_REVIEW_ANGLES
236                    .iter()
237                    .map(|angle| (*angle).to_owned())
238                    .collect()
239            });
240        return ship_stage_prompt(phase, &review_angles);
241    }
242    if stage == Stage::Validate {
243        return validate_stage_prompt(phase);
244    }
245    if stage == Stage::Define {
246        return define_stage_prompt(phase);
247    }
248    if stage == Stage::Plan {
249        return idempotent_stage_prompt(phase);
250    }
251    let command = gsd_command_for(stage, phase);
252    if stage == Stage::Code {
253        return format!(
254            "Run the GSD workflow command for this stage:\n\n    {command}\n\n\
255            ## Advisory incremental self-review\n\
256            \n\
257            After each plan or wave lands, perform a quick, shallow self-check \
258            for doc accuracy, leaked data, CI/build correctness, and \
259            external-state claims. Record any drift in the working output and \
260            continue execution; the authoritative review happens during Ship. \
261            This check must not pause execution or request human input.\n\
262            \n\
263            {COMPLETION_PROTOCOL}"
264        );
265    }
266    format!(
267        "Run the GSD workflow command for this stage:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
268    )
269}
270
271/// The synthesized instruction sent into a resumed Claude session when a
272/// confirmed human-blocking checkpoint has nobody available to answer it
273/// (D-03, 28-CONTEXT.md): DevFlow's default, unconditional policy — no flag,
274/// no config toggle — is for the agent to resolve the checkpoint itself,
275/// using its own judgment, and record why.
276///
277/// Deliberately deterministic: no timestamp, no random content, no varying
278/// state. Two calls for the same `phase` produce byte-identical strings, so
279/// the `checkpoint_auto_decided` audit event (D-07, plan 28-03) can quote
280/// this exact instruction without churning on every resume. `phase` is
281/// included only for operator legibility in the captured stdout — the
282/// instruction's meaning does not depend on it.
283pub fn checkpoint_auto_decide_prompt(phase: u32) -> String {
284    format!(
285        "This is phase {phase} of a headless DevFlow run. You previously \
286        stopped at a human-blocking checkpoint, but no human operator is \
287        available to answer it — this run is unattended, and none is \
288        coming. DevFlow's policy is for you to resolve the checkpoint \
289        yourself, using your own best judgment, and continue the work. You \
290        MUST record your reasoning for the decision you made in your final \
291        message, so the decision is auditable after the fact.\n\
292        \n\
293        {COMPLETION_PROTOCOL}"
294    )
295}
296
297/// Build a fix prompt used on Code → Validate loop-backs.
298pub fn fix_prompt(fix_type: FixType, phase: u32) -> String {
299    let command = match fix_type {
300        FixType::AuditFix => format!("/gsd-audit-fix {phase}"),
301        FixType::GapsOnly => format!("/gsd-execute-phase {phase} --gaps-only"),
302        FixType::FullExecute => format!("/gsd-execute-phase {phase}"),
303    };
304    format!(
305        "Validation reported issues. Run the fix command for this loop:\n\n    {command}\n\n{COMPLETION_PROTOCOL}"
306    )
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn each_stage_prompt_carries_its_gsd_command_and_marker() {
315        // Define is excluded here (D-14): its prompt never contains its GSD
316        // command — see `define_prompt_never_invokes_discuss_phase` below.
317        let cases = [
318            (Stage::Plan, "/gsd-plan-phase 11"),
319            (Stage::Code, "/gsd-execute-phase 11"),
320            (Stage::Validate, "/gsd-validate-phase 11"),
321            (Stage::Ship, "/gsd-ship 11"),
322        ];
323        for (stage, command) in cases {
324            let prompt = stage_prompt(stage, 11);
325            assert!(prompt.contains(command), "{stage} prompt missing {command}");
326            assert!(prompt.contains("DEVFLOW_RESULT"));
327        }
328    }
329
330    #[test]
331    fn phase_placeholder_is_substituted() {
332        assert!(stage_prompt(Stage::Code, 7).contains("/gsd-execute-phase 7"));
333        assert!(!stage_prompt(Stage::Code, 7).contains("{N}"));
334    }
335
336    #[test]
337    fn ship_prompt_sequences_code_review_before_ship() {
338        let prompt = stage_prompt(Stage::Ship, 13);
339        let review_pos = prompt
340            .find("/gsd-code-review 13")
341            .expect("Ship prompt must run /gsd-code-review {N}");
342        let ship_pos = prompt
343            .find("/gsd-ship 13")
344            .expect("Ship prompt must run /gsd-ship {N}");
345        assert!(
346            review_pos < ship_pos,
347            "code-review must be sequenced before ship"
348        );
349    }
350
351    #[test]
352    fn ship_prompt_defines_critical_gate_and_review_failed_contract() {
353        let prompt = stage_prompt(Stage::Ship, 13);
354        assert!(
355            prompt.contains("REVIEW.md"),
356            "Ship prompt must reference the REVIEW.md artifact"
357        );
358        assert!(
359            prompt.to_lowercase().contains("critical"),
360            "Ship prompt must name the Critical-severity gate"
361        );
362        assert!(
363            prompt.contains("do not run")
364                || prompt.contains("do NOT run")
365                || prompt.contains("DO NOT run"),
366            "Ship prompt must instruct the agent not to run /gsd-ship on Critical findings"
367        );
368        assert!(
369            prompt.contains("review:"),
370            "Ship prompt must define the review: ReviewFailed reason convention"
371        );
372        assert!(prompt.contains("DEVFLOW_RESULT"));
373    }
374
375    #[test]
376    fn ship_prompt_includes_multi_angle_conditional_review() {
377        let prompt = stage_prompt(Stage::Ship, 13);
378        for angle in [
379            "doc-accuracy cross-reference",
380            "security / leaked-data",
381            "CI/build correctness",
382            "external-state claims",
383            "generalist deep pass",
384        ] {
385            assert!(prompt.contains(angle), "Ship prompt missing angle: {angle}");
386        }
387        assert!(prompt.contains("parallel finder subagents"));
388        assert!(prompt.contains("focused sequential pass"));
389        assert!(prompt.contains("Merge and deduplicate"));
390        assert!(prompt.contains("REVIEW.md"));
391    }
392
393    #[test]
394    fn ship_prompt_uses_project_review_angle_override() {
395        let dir = tempfile::tempdir().unwrap();
396        std::fs::write(
397            dir.path().join("devflow.toml"),
398            "review_angles = [\"custom release evidence\", \"custom threat boundary\"]\n",
399        )
400        .unwrap();
401
402        let prompt = stage_prompt_for_project(Stage::Ship, 13, dir.path());
403
404        assert!(prompt.contains("custom release evidence"));
405        assert!(prompt.contains("custom threat boundary"));
406        assert!(!prompt.contains("doc-accuracy cross-reference"));
407    }
408
409    #[test]
410    fn code_stage_prompt_is_unchanged_single_command_template() {
411        // Validate is excluded here (Task 2, 13-05): it now gets its own
412        // dedicated prompt requiring a verdict — see
413        // `validate_stage_prompt_requires_verdict` below. Define and Plan
414        // are excluded too: Plan carries the idempotency contract (see
415        // `plan_prompt_is_idempotent` below); Define carries its own D-14
416        // always-no-op contract (see `define_prompt_never_invokes_discuss_phase`
417        // below).
418        let prompt = stage_prompt(Stage::Code, 9);
419        assert!(prompt.contains("/gsd-execute-phase 9"));
420        assert!(prompt.contains("DEVFLOW_RESULT"));
421        assert!(
422            !prompt.contains("/gsd-code-review"),
423            "Code prompt should not carry Ship-specific code-review sequencing"
424        );
425        assert!(
426            !prompt.contains("already exists"),
427            "Code prompt should not carry the Define/Plan idempotency contract"
428        );
429        assert!(prompt.contains("Advisory incremental self-review"));
430        for angle in [
431            "doc accuracy",
432            "leaked data",
433            "CI/build correctness",
434            "external-state claims",
435        ] {
436            assert!(prompt.contains(angle), "Code prompt missing angle: {angle}");
437        }
438        assert!(!prompt.contains("AskUserQuestion"));
439        assert!(!prompt.contains("request_user_input"));
440    }
441
442    /// 13-06 dogfood regression (Codex leg), Plan half only after the D-14
443    /// split: GSD's plan-phase demands an interactive decision when PLAN.md
444    /// already exists, which headless Codex can never answer — Plan must
445    /// no-op with success when its deliverable pre-exists. See T-28-09.
446    #[test]
447    fn plan_prompt_is_idempotent() {
448        let prompt = stage_prompt(Stage::Plan, 9);
449        assert!(
450            prompt.contains("/gsd-plan-phase 9"),
451            "Plan prompt missing /gsd-plan-phase 9"
452        );
453        assert!(
454            prompt.contains("09-*PLAN.md"),
455            "Plan prompt must check for its pre-existing artifact"
456        );
457        assert!(
458            prompt.contains("Do NOT run the GSD command"),
459            "Plan prompt must no-op when the artifact exists"
460        );
461        assert!(
462            prompt.contains("do NOT ask for input"),
463            "Plan prompt must forbid interactive input"
464        );
465        assert!(prompt.contains("DEVFLOW_RESULT"));
466    }
467
468    /// D-14: the Define stage must never invoke the interactive
469    /// discuss-phase command, whether or not CONTEXT.md exists — the branch
470    /// that did so is deleted, not disambiguated. Regression guard for
471    /// T-28-08 (a headless run has no operator to answer it).
472    #[test]
473    fn define_prompt_never_invokes_discuss_phase() {
474        let prompt = stage_prompt(Stage::Define, 9);
475        assert!(
476            !prompt.contains("/gsd-discuss-phase"),
477            "Define prompt must never invoke the interactive discuss-phase command (D-14)"
478        );
479        assert!(
480            prompt.contains("must NOT run") || prompt.contains("do NOT run"),
481            "Define prompt must forbid running an interactive interview headlessly"
482        );
483        assert!(
484            prompt.contains("do NOT ask for input") || prompt.contains("must NOT ask for input"),
485            "Define prompt must forbid requesting input"
486        );
487        assert!(
488            prompt.to_lowercase().contains("modify"),
489            "Define prompt must forbid modifying existing planning artifacts"
490        );
491        assert!(prompt.contains("DEVFLOW_RESULT"));
492    }
493
494    #[test]
495    fn validate_stage_prompt_requires_verdict() {
496        let prompt = stage_prompt(Stage::Validate, 13);
497        assert!(
498            prompt.contains("/gsd-validate-phase 13"),
499            "Validate prompt missing its GSD command"
500        );
501        assert!(
502            prompt.contains("\"verdict\": \"pass\""),
503            "Validate prompt must name the exact lowercase pass verdict"
504        );
505        assert!(
506            prompt.contains("\"verdict\": \"gaps\""),
507            "Validate prompt must name the exact lowercase gaps verdict"
508        );
509        assert!(prompt.contains("REQUIRED"));
510        assert!(prompt.contains("DEVFLOW_RESULT"));
511    }
512
513    #[test]
514    fn fix_prompts_select_the_right_command() {
515        assert!(fix_prompt(FixType::AuditFix, 11).contains("/gsd-audit-fix 11"));
516        assert!(fix_prompt(FixType::GapsOnly, 11).contains("--gaps-only"));
517        assert!(fix_prompt(FixType::AuditFix, 11).contains("DEVFLOW_RESULT"));
518
519        // D-01: FullExecute renders the plain, unflagged execute command.
520        let full_execute_prompt = fix_prompt(FixType::FullExecute, 11);
521        assert!(full_execute_prompt.contains("/gsd-execute-phase 11"));
522        // Negative control: without this, FullExecute's command string would
523        // just be a substring of GapsOnly's — this proves the two are
524        // actually distinguishable, not that FullExecute merely contains
525        // GapsOnly's prefix.
526        assert!(!full_execute_prompt.contains("--gaps-only"));
527    }
528
529    /// D-03/D-07 (28-03): the audit event quotes this instruction verbatim,
530    /// so it must be byte-identical across calls for the same phase — no
531    /// timestamp, no random content that would churn the recorded string.
532    #[test]
533    fn checkpoint_auto_decide_prompt_is_deterministic() {
534        assert_eq!(
535            checkpoint_auto_decide_prompt(28),
536            checkpoint_auto_decide_prompt(28)
537        );
538    }
539
540    #[test]
541    fn checkpoint_auto_decide_prompt_terminates_with_completion_protocol() {
542        let prompt = checkpoint_auto_decide_prompt(28);
543        assert!(
544            prompt.ends_with(COMPLETION_PROTOCOL),
545            "the resumed session's exit must still be parseable by the same \
546             Layer 1 path as any other stage"
547        );
548        assert!(prompt.contains("DEVFLOW_RESULT"));
549    }
550
551    #[test]
552    fn checkpoint_auto_decide_prompt_states_no_operator_judgment_and_record_reasoning() {
553        let prompt = checkpoint_auto_decide_prompt(28).to_lowercase();
554        assert!(
555            prompt.contains("no human operator") || prompt.contains("nobody"),
556            "must state plainly that no operator is available"
557        );
558        assert!(
559            prompt.contains("judgment") || prompt.contains("judgement"),
560            "must instruct the agent to use its own judgment"
561        );
562        assert!(
563            prompt.contains("record") && prompt.contains("reasoning"),
564            "must require recording the reasoning in the final message, since \
565             this is the ONLY record of what was decided (D-07)"
566        );
567    }
568
569    #[test]
570    fn checkpoint_auto_decide_prompt_substitutes_phase_for_legibility() {
571        assert!(checkpoint_auto_decide_prompt(42).contains("phase 42"));
572    }
573}