use crate::phase_id::PhaseId;
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 AUTO_CHAIN_PRESERVING_FLAG: &str = "--auto";
pub 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, PartialEq, Eq)]
pub enum StageIntent {
Define {
phase: PhaseId,
},
Plan {
phase: PhaseId,
},
Code {
phase: PhaseId,
fix: Option<FixType>,
},
Validate {
phase: PhaseId,
},
Ship {
phase: PhaseId,
review_angles: Vec<String>,
},
}
impl StageIntent {
pub fn stage(&self) -> Stage {
match self {
StageIntent::Define { .. } => Stage::Define,
StageIntent::Plan { .. } => Stage::Plan,
StageIntent::Code { .. } => Stage::Code,
StageIntent::Validate { .. } => Stage::Validate,
StageIntent::Ship { .. } => Stage::Ship,
}
}
pub fn for_stage(stage: Stage, phase: PhaseId) -> Self {
Self::for_stage_in_project(stage, phase, None)
}
pub fn for_stage_in_project(stage: Stage, phase: PhaseId, project_root: Option<&Path>) -> Self {
match stage {
Stage::Define => StageIntent::Define { phase },
Stage::Plan => StageIntent::Plan { phase },
Stage::Code => StageIntent::Code { phase, fix: None },
Stage::Validate => StageIntent::Validate { phase },
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()
});
StageIntent::Ship {
phase,
review_angles,
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FixType {
AuditFix,
GapsOnly,
FullExecute,
}
fn gsd_command_for(stage: Stage, phase: PhaseId) -> String {
stage.gsd_command().replace("{N}", &phase.to_string())
}
fn ship_stage_prompt(phase: PhaseId, 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}"
)
}
const VALIDATE_VERDICT_CONTRACT: &str = "\
## 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 validate_stage_prompt(phase: PhaseId) -> String {
let command = gsd_command_for(Stage::Validate, phase);
format!(
"Run the GSD workflow command for this stage:\n\n {command}\n\n{VALIDATE_VERDICT_CONTRACT}"
)
}
fn idempotent_stage_prompt(phase: PhaseId) -> String {
let artifact = "PLAN.md";
let command = gsd_command_for(Stage::Plan, phase);
let padded = phase.padded();
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: PhaseId) -> 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: PhaseId) -> String {
stage_prompt_with_project(stage, phase, None)
}
pub fn stage_prompt_for_project(stage: Stage, phase: PhaseId, project_root: &Path) -> String {
stage_prompt_with_project(stage, phase, Some(project_root))
}
fn code_stage_prompt(phase: PhaseId) -> String {
let command = format!(
"{} {AUTO_CHAIN_PRESERVING_FLAG}",
gsd_command_for(Stage::Code, phase)
);
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}"
)
}
pub fn render_claude_style(intent: &StageIntent) -> String {
match intent {
StageIntent::Define { phase } => define_stage_prompt(*phase),
StageIntent::Plan { phase } => idempotent_stage_prompt(*phase),
StageIntent::Code { phase, fix: None } => code_stage_prompt(*phase),
StageIntent::Code {
phase,
fix: Some(fix),
} => fix_prompt(*fix, *phase),
StageIntent::Validate { phase } => validate_stage_prompt(*phase),
StageIntent::Ship {
phase,
review_angles,
} => ship_stage_prompt(*phase, review_angles),
}
}
pub fn render_workflow_style(intent: &StageIntent, workflow_root: &str) -> String {
match intent {
StageIntent::Define { phase } => define_stage_prompt(*phase),
StageIntent::Plan { phase } => workflow_plan_prompt(*phase, workflow_root),
StageIntent::Code { phase, fix } => workflow_code_prompt(*phase, *fix, workflow_root),
StageIntent::Validate { phase } => workflow_validate_prompt(*phase, workflow_root),
StageIntent::Ship {
phase,
review_angles,
} => workflow_ship_prompt(*phase, review_angles, workflow_root),
}
}
fn workflow_plan_prompt(phase: PhaseId, workflow_root: &str) -> String {
let artifact = "PLAN.md";
let padded = phase.padded();
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 \
workflow, 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: read and follow the GSD workflow file at \
{workflow_root}/plan-phase.md for phase {phase}.\n\
\n\
{COMPLETION_PROTOCOL}"
)
}
fn workflow_code_prompt(phase: PhaseId, fix: Option<FixType>, workflow_root: &str) -> String {
match fix {
Some(FixType::AuditFix) => format!(
"Read and follow the GSD workflow file at {workflow_root}/audit-fix.md for \
phase {phase}.\n\n{COMPLETION_PROTOCOL}"
),
Some(FixType::GapsOnly) => format!(
"Read and follow the GSD workflow file at {workflow_root}/execute-phase.md for \
phase {phase} --auto --gaps-only. The `--auto` and `--gaps-only` flags are part \
of the workflow invocation and must be preserved verbatim.\n\n{COMPLETION_PROTOCOL}"
),
Some(FixType::FullExecute) | None => format!(
"Read and follow the GSD workflow file at {workflow_root}/execute-phase.md for \
phase {phase} --auto. The `--auto` flag is part of the workflow invocation and \
must be preserved verbatim.\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}"
),
}
}
fn workflow_validate_prompt(phase: PhaseId, workflow_root: &str) -> String {
format!(
"Read and follow the GSD workflow file at {workflow_root}/validate-phase.md for \
phase {phase}.\n\n{VALIDATE_VERDICT_CONTRACT}"
)
}
fn workflow_ship_prompt(phase: PhaseId, review_angles: &[String], workflow_root: &str) -> String {
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. Read and follow the GSD workflow file at {workflow_root}/code-review.md for \
phase {phase}. 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 the ship workflow \
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: read and follow the GSD workflow file \
at {workflow_root}/ship.md for phase {phase} and report the outcome via the normal \
completion protocol below.\n\
\n\
{COMPLETION_PROTOCOL}"
)
}
fn stage_prompt_with_project(stage: Stage, phase: PhaseId, project_root: Option<&Path>) -> String {
render_claude_style(&StageIntent::for_stage_in_project(
stage,
phase,
project_root,
))
}
pub fn checkpoint_auto_decide_prompt(phase: PhaseId) -> 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: PhaseId) -> String {
let command = match fix_type {
FixType::AuditFix => format!("/gsd-audit-fix {phase}"),
FixType::GapsOnly => {
format!("/gsd-execute-phase {phase} --gaps-only {AUTO_CHAIN_PRESERVING_FLAG}")
}
FixType::FullExecute => {
format!("/gsd-execute-phase {phase} {AUTO_CHAIN_PRESERVING_FLAG}")
}
};
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, PhaseId::new(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, PhaseId::new(7)).contains("/gsd-execute-phase 7"));
assert!(!stage_prompt(Stage::Code, PhaseId::new(7)).contains("{N}"));
}
#[test]
fn ship_prompt_sequences_code_review_before_ship() {
let prompt = stage_prompt(Stage::Ship, PhaseId::new(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, PhaseId::new(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, PhaseId::new(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, PhaseId::new(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, PhaseId::new(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, PhaseId::new(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, PhaseId::new(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, PhaseId::new(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, PhaseId::new(11)).contains("/gsd-audit-fix 11"));
assert!(fix_prompt(FixType::GapsOnly, PhaseId::new(11)).contains("--gaps-only"));
assert!(fix_prompt(FixType::AuditFix, PhaseId::new(11)).contains("DEVFLOW_RESULT"));
let full_execute_prompt = fix_prompt(FixType::FullExecute, PhaseId::new(11));
assert!(full_execute_prompt.contains("/gsd-execute-phase 11"));
assert!(!full_execute_prompt.contains("--gaps-only"));
}
#[test]
fn fix_prompts_carry_the_chain_flag_token_only_where_it_reaches_execute_phase() {
let phase = PhaseId::new(11);
assert!(
fix_prompt(FixType::GapsOnly, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
"the --gaps-only fix loop reaches execute-phase.md, so it meets the \
sync-clear step and needs the token exactly as the first Code pass does"
);
assert!(
fix_prompt(FixType::FullExecute, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
"the full-execute loop-back reaches execute-phase.md too"
);
assert!(
!fix_prompt(FixType::AuditFix, phase).contains(AUTO_CHAIN_PRESERVING_FLAG),
"audit-fix routes to /gsd-audit-fix and never reaches execute-phase.md, \
so it never meets the sync-clear step the token exists to skip"
);
}
#[test]
fn the_code_prompt_carries_the_chain_flag_token() {
let prompt = stage_prompt(Stage::Code, PhaseId::new(11));
assert!(prompt.contains(&format!(
"/gsd-execute-phase 11 {AUTO_CHAIN_PRESERVING_FLAG}"
)));
}
#[test]
fn the_plan_prompt_never_carries_the_chain_flag_token() {
let plan = stage_prompt(Stage::Plan, PhaseId::new(11));
assert!(
!plan.contains(AUTO_CHAIN_PRESERVING_FLAG),
"the Plan prompt must not chain into execute-phase (D-04)"
);
assert!(plan.contains("/gsd-plan-phase 11"));
}
#[test]
fn checkpoint_auto_decide_prompt_is_deterministic() {
assert_eq!(
checkpoint_auto_decide_prompt(PhaseId::new(28)),
checkpoint_auto_decide_prompt(PhaseId::new(28))
);
}
#[test]
fn checkpoint_auto_decide_prompt_terminates_with_completion_protocol() {
let prompt = checkpoint_auto_decide_prompt(PhaseId::new(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(PhaseId::new(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(PhaseId::new(42)).contains("phase 42"));
}
}