use crate::stage::Stage;
use std::path::Path;
const SHIP_REVIEW_ANGLES: &[&str] = &[
"doc-accuracy cross-reference (do documented claims match source?)",
"security / leaked-data (does anything commit secrets, session data, or telemetry?)",
"CI/build correctness (can a failing step still report green?)",
"external-state claims (does the diff claim merges, tags, or deletions that are not actually true?)",
"one generalist deep pass",
];
const COMPLETION_PROTOCOL: &str = "\
## Completion Protocol (REQUIRED)\n\
\n\
When all work is done, your FINAL message must be exactly:\n\
\n\
DEVFLOW_RESULT: {\"status\": \"success\"}\n\
\n\
If something prevents completion:\n\
\n\
DEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"specific explanation\"}\n\
\n\
DevFlow reads this line to decide whether the stage succeeded. \
Output nothing after it.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FixType {
AuditFix,
GapsOnly,
FullExecute,
}
fn gsd_command_for(stage: Stage, phase: u32) -> String {
stage.gsd_command().replace("{N}", &phase.to_string())
}
fn ship_stage_prompt(phase: u32, review_angles: &[String]) -> String {
let code_review = format!("/gsd-code-review {phase}");
let ship = format!("/gsd-ship {phase}");
let review_angles = review_angles
.iter()
.map(|angle| format!("- {angle}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"Run the Ship stage in two steps:\n\
\n\
1. Run `{code_review}` (non-interactive). This writes a `REVIEW.md` \
artifact with severity-classified findings. Review at high depth from \
every angle below:\n\
\n\
{review_angles}\n\
\n\
If your harness supports parallel finder subagents, dispatch one per \
angle; otherwise run each angle as a focused sequential pass. Merge \
and deduplicate every angle's findings into one `REVIEW.md`.\n\
2. Check `REVIEW.md` for the Critical-severity gate:\n\
\n\
- If `REVIEW.md` contains ANY finding at Critical severity: do NOT \
run `{ship}` at all. Your FINAL message must be exactly:\n\
\n\
DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"review: <short summary of the Critical findings>\"}}\n\
\n\
- If `REVIEW.md` has NO Critical-severity findings: run `{ship}` and \
report the outcome via the normal completion protocol below.\n\
\n\
{COMPLETION_PROTOCOL}"
)
}
fn validate_stage_prompt(phase: u32) -> String {
let command = gsd_command_for(Stage::Validate, phase);
format!(
"Run the GSD workflow command for this stage:\n\n {command}\n\n\
## Completion Protocol (REQUIRED)\n\
\n\
When all work is done, your FINAL message must be exactly one of:\n\
\n\
DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"pass\"}}\n\
\n\
if validation found NO gaps, or:\n\
\n\
DEVFLOW_RESULT: {{\"status\": \"success\", \"verdict\": \"gaps\"}}\n\
\n\
if validation found gaps that still need fixing. The `verdict` field \
is REQUIRED for this stage — it is distinct from `status` (which only \
reports whether the validation task itself completed) and MUST be \
exactly the lowercase string `pass` or `gaps`.\n\
\n\
If something prevents completion:\n\
\n\
DEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"specific explanation\"}}\n\
\n\
DevFlow reads this line to decide whether the stage succeeded. \
Output nothing after it."
)
}
fn idempotent_stage_prompt(phase: u32) -> String {
let artifact = "PLAN.md";
let command = gsd_command_for(Stage::Plan, phase);
let padded = format!("{phase:02}");
format!(
"First check whether this stage's deliverable already exists:\n\
\n\
ls .planning/phases/{padded}-*/{padded}-*{artifact} 2>/dev/null\n\
\n\
- If it EXISTS: the stage's work is already done. Do NOT run the GSD \
command, do NOT ask for input, and do NOT modify the existing \
artifacts. Your FINAL message must be exactly:\n\
\n\
DEVFLOW_RESULT: {{\"status\": \"success\"}}\n\
\n\
- If it does NOT exist: run the GSD workflow command for this stage:\n\
\n\
\x20 {command}\n\
\n\
{COMPLETION_PROTOCOL}"
)
}
fn define_stage_prompt(phase: u32) -> String {
format!(
"This is the Define stage of a headless DevFlow run for phase {phase}.\n\
\n\
There is no agent work to perform here. Whether or not this phase's \
CONTEXT.md already exists, you must NOT run an interactive \
discuss-phase or interview command, and you must NOT ask for input \
— this run is headless and no operator is available to answer \
interactive questions. Do NOT modify any existing planning \
artifacts.\n\
\n\
{COMPLETION_PROTOCOL}"
)
}
pub fn stage_prompt(stage: Stage, phase: u32) -> String {
stage_prompt_with_project(stage, phase, None)
}
pub fn stage_prompt_for_project(stage: Stage, phase: u32, project_root: &Path) -> String {
stage_prompt_with_project(stage, phase, Some(project_root))
}
fn stage_prompt_with_project(stage: Stage, phase: u32, project_root: Option<&Path>) -> String {
if stage == Stage::Ship {
let review_angles = project_root
.and_then(crate::config::review_angles)
.unwrap_or_else(|| {
SHIP_REVIEW_ANGLES
.iter()
.map(|angle| (*angle).to_owned())
.collect()
});
return ship_stage_prompt(phase, &review_angles);
}
if stage == Stage::Validate {
return validate_stage_prompt(phase);
}
if stage == Stage::Define {
return define_stage_prompt(phase);
}
if stage == Stage::Plan {
return idempotent_stage_prompt(phase);
}
let command = gsd_command_for(stage, phase);
if stage == Stage::Code {
return format!(
"Run the GSD workflow command for this stage:\n\n {command}\n\n\
## Advisory incremental self-review\n\
\n\
After each plan or wave lands, perform a quick, shallow self-check \
for doc accuracy, leaked data, CI/build correctness, and \
external-state claims. Record any drift in the working output and \
continue execution; the authoritative review happens during Ship. \
This check must not pause execution or request human input.\n\
\n\
{COMPLETION_PROTOCOL}"
);
}
format!(
"Run the GSD workflow command for this stage:\n\n {command}\n\n{COMPLETION_PROTOCOL}"
)
}
pub fn checkpoint_auto_decide_prompt(phase: u32) -> String {
format!(
"This is phase {phase} of a headless DevFlow run. You previously \
stopped at a human-blocking checkpoint, but no human operator is \
available to answer it — this run is unattended, and none is \
coming. DevFlow's policy is for you to resolve the checkpoint \
yourself, using your own best judgment, and continue the work. You \
MUST record your reasoning for the decision you made in your final \
message, so the decision is auditable after the fact.\n\
\n\
{COMPLETION_PROTOCOL}"
)
}
pub fn fix_prompt(fix_type: FixType, phase: u32) -> String {
let command = match fix_type {
FixType::AuditFix => format!("/gsd-audit-fix {phase}"),
FixType::GapsOnly => format!("/gsd-execute-phase {phase} --gaps-only"),
FixType::FullExecute => format!("/gsd-execute-phase {phase}"),
};
format!(
"Validation reported issues. Run the fix command for this loop:\n\n {command}\n\n{COMPLETION_PROTOCOL}"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_stage_prompt_carries_its_gsd_command_and_marker() {
let cases = [
(Stage::Plan, "/gsd-plan-phase 11"),
(Stage::Code, "/gsd-execute-phase 11"),
(Stage::Validate, "/gsd-validate-phase 11"),
(Stage::Ship, "/gsd-ship 11"),
];
for (stage, command) in cases {
let prompt = stage_prompt(stage, 11);
assert!(prompt.contains(command), "{stage} prompt missing {command}");
assert!(prompt.contains("DEVFLOW_RESULT"));
}
}
#[test]
fn phase_placeholder_is_substituted() {
assert!(stage_prompt(Stage::Code, 7).contains("/gsd-execute-phase 7"));
assert!(!stage_prompt(Stage::Code, 7).contains("{N}"));
}
#[test]
fn ship_prompt_sequences_code_review_before_ship() {
let prompt = stage_prompt(Stage::Ship, 13);
let review_pos = prompt
.find("/gsd-code-review 13")
.expect("Ship prompt must run /gsd-code-review {N}");
let ship_pos = prompt
.find("/gsd-ship 13")
.expect("Ship prompt must run /gsd-ship {N}");
assert!(
review_pos < ship_pos,
"code-review must be sequenced before ship"
);
}
#[test]
fn ship_prompt_defines_critical_gate_and_review_failed_contract() {
let prompt = stage_prompt(Stage::Ship, 13);
assert!(
prompt.contains("REVIEW.md"),
"Ship prompt must reference the REVIEW.md artifact"
);
assert!(
prompt.to_lowercase().contains("critical"),
"Ship prompt must name the Critical-severity gate"
);
assert!(
prompt.contains("do not run")
|| prompt.contains("do NOT run")
|| prompt.contains("DO NOT run"),
"Ship prompt must instruct the agent not to run /gsd-ship on Critical findings"
);
assert!(
prompt.contains("review:"),
"Ship prompt must define the review: ReviewFailed reason convention"
);
assert!(prompt.contains("DEVFLOW_RESULT"));
}
#[test]
fn ship_prompt_includes_multi_angle_conditional_review() {
let prompt = stage_prompt(Stage::Ship, 13);
for angle in [
"doc-accuracy cross-reference",
"security / leaked-data",
"CI/build correctness",
"external-state claims",
"generalist deep pass",
] {
assert!(prompt.contains(angle), "Ship prompt missing angle: {angle}");
}
assert!(prompt.contains("parallel finder subagents"));
assert!(prompt.contains("focused sequential pass"));
assert!(prompt.contains("Merge and deduplicate"));
assert!(prompt.contains("REVIEW.md"));
}
#[test]
fn ship_prompt_uses_project_review_angle_override() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("devflow.toml"),
"review_angles = [\"custom release evidence\", \"custom threat boundary\"]\n",
)
.unwrap();
let prompt = stage_prompt_for_project(Stage::Ship, 13, dir.path());
assert!(prompt.contains("custom release evidence"));
assert!(prompt.contains("custom threat boundary"));
assert!(!prompt.contains("doc-accuracy cross-reference"));
}
#[test]
fn code_stage_prompt_is_unchanged_single_command_template() {
let prompt = stage_prompt(Stage::Code, 9);
assert!(prompt.contains("/gsd-execute-phase 9"));
assert!(prompt.contains("DEVFLOW_RESULT"));
assert!(
!prompt.contains("/gsd-code-review"),
"Code prompt should not carry Ship-specific code-review sequencing"
);
assert!(
!prompt.contains("already exists"),
"Code prompt should not carry the Define/Plan idempotency contract"
);
assert!(prompt.contains("Advisory incremental self-review"));
for angle in [
"doc accuracy",
"leaked data",
"CI/build correctness",
"external-state claims",
] {
assert!(prompt.contains(angle), "Code prompt missing angle: {angle}");
}
assert!(!prompt.contains("AskUserQuestion"));
assert!(!prompt.contains("request_user_input"));
}
#[test]
fn plan_prompt_is_idempotent() {
let prompt = stage_prompt(Stage::Plan, 9);
assert!(
prompt.contains("/gsd-plan-phase 9"),
"Plan prompt missing /gsd-plan-phase 9"
);
assert!(
prompt.contains("09-*PLAN.md"),
"Plan prompt must check for its pre-existing artifact"
);
assert!(
prompt.contains("Do NOT run the GSD command"),
"Plan prompt must no-op when the artifact exists"
);
assert!(
prompt.contains("do NOT ask for input"),
"Plan prompt must forbid interactive input"
);
assert!(prompt.contains("DEVFLOW_RESULT"));
}
#[test]
fn define_prompt_never_invokes_discuss_phase() {
let prompt = stage_prompt(Stage::Define, 9);
assert!(
!prompt.contains("/gsd-discuss-phase"),
"Define prompt must never invoke the interactive discuss-phase command (D-14)"
);
assert!(
prompt.contains("must NOT run") || prompt.contains("do NOT run"),
"Define prompt must forbid running an interactive interview headlessly"
);
assert!(
prompt.contains("do NOT ask for input") || prompt.contains("must NOT ask for input"),
"Define prompt must forbid requesting input"
);
assert!(
prompt.to_lowercase().contains("modify"),
"Define prompt must forbid modifying existing planning artifacts"
);
assert!(prompt.contains("DEVFLOW_RESULT"));
}
#[test]
fn validate_stage_prompt_requires_verdict() {
let prompt = stage_prompt(Stage::Validate, 13);
assert!(
prompt.contains("/gsd-validate-phase 13"),
"Validate prompt missing its GSD command"
);
assert!(
prompt.contains("\"verdict\": \"pass\""),
"Validate prompt must name the exact lowercase pass verdict"
);
assert!(
prompt.contains("\"verdict\": \"gaps\""),
"Validate prompt must name the exact lowercase gaps verdict"
);
assert!(prompt.contains("REQUIRED"));
assert!(prompt.contains("DEVFLOW_RESULT"));
}
#[test]
fn fix_prompts_select_the_right_command() {
assert!(fix_prompt(FixType::AuditFix, 11).contains("/gsd-audit-fix 11"));
assert!(fix_prompt(FixType::GapsOnly, 11).contains("--gaps-only"));
assert!(fix_prompt(FixType::AuditFix, 11).contains("DEVFLOW_RESULT"));
let full_execute_prompt = fix_prompt(FixType::FullExecute, 11);
assert!(full_execute_prompt.contains("/gsd-execute-phase 11"));
assert!(!full_execute_prompt.contains("--gaps-only"));
}
#[test]
fn checkpoint_auto_decide_prompt_is_deterministic() {
assert_eq!(
checkpoint_auto_decide_prompt(28),
checkpoint_auto_decide_prompt(28)
);
}
#[test]
fn checkpoint_auto_decide_prompt_terminates_with_completion_protocol() {
let prompt = checkpoint_auto_decide_prompt(28);
assert!(
prompt.ends_with(COMPLETION_PROTOCOL),
"the resumed session's exit must still be parseable by the same \
Layer 1 path as any other stage"
);
assert!(prompt.contains("DEVFLOW_RESULT"));
}
#[test]
fn checkpoint_auto_decide_prompt_states_no_operator_judgment_and_record_reasoning() {
let prompt = checkpoint_auto_decide_prompt(28).to_lowercase();
assert!(
prompt.contains("no human operator") || prompt.contains("nobody"),
"must state plainly that no operator is available"
);
assert!(
prompt.contains("judgment") || prompt.contains("judgement"),
"must instruct the agent to use its own judgment"
);
assert!(
prompt.contains("record") && prompt.contains("reasoning"),
"must require recording the reasoning in the final message, since \
this is the ONLY record of what was decided (D-07)"
);
}
#[test]
fn checkpoint_auto_decide_prompt_substitutes_phase_for_legibility() {
assert!(checkpoint_auto_decide_prompt(42).contains("phase 42"));
}
}