pub const APPLY_SYSTEM_PROMPT: &str = "";
pub(crate) fn skill_prelude(skill: &str) -> String {
format!("load skills: {}\n\n${}", skill, skill)
}
pub fn append_optional_prompt(base_prompt: String, append_prompt: Option<&str>) -> String {
match append_prompt {
Some(append_prompt) if !append_prompt.trim().is_empty() => {
format!("{}\n\n{}", base_prompt, append_prompt)
}
_ => base_prompt,
}
}
fn apply_completion_contract(change_id: &str) -> String {
format!(
"Apply change id: {change_id}\n\n\
This is an implementation task, not a review or summary.\n\n\
Required outcome:\n\
1. Read openspec/changes/{change_id}/proposal.md and openspec/changes/{change_id}/tasks.md.\n\
2. Modify repository source, test, or config files to implement unchecked active tasks.\n\
3. After each real implementation and verification, update openspec/changes/{change_id}/tasks.md from [ ] to [x].\n\
4. Internal agent todos do not count as OpenSpec task completion.\n\n\
Forbidden:\n\
- Do not only summarize, inspect, or plan.\n\
- Do not treat reading files as implementation.\n\
- Do not report complete while openspec/changes/{change_id}/tasks.md still has unchecked active tasks.\n\
- Do not exit successfully when required implementation diff is empty.\n\
- Do not confuse internal TODO/TodoWrite completion with tasks.md completion.\n\n\
Before final response, verify:\n\
- git diff --stat shows real non-OpenSpec implementation, test, or config changes when code tasks exist.\n\
- openspec/changes/{change_id}/tasks.md has no unchecked [ ] items under active task sections.\n\n\
If either check fails, report APPLY_INCOMPLETE with exact remaining tasks and evidence instead of saying complete."
)
}
fn archive_completion_contract(change_id: &str) -> String {
format!(
"Archive change id: {change_id}\n\n\
This is an archive task, not implementation, acceptance, or summary.\n\n\
Required outcome:\n\
1. Read openspec/changes/{change_id}/proposal.md and openspec/changes/{change_id}/tasks.md.\n\
2. Confirm every active task in tasks.md is already [x].\n\
3. Archive the completed change and update canonical specs as required by the archive workflow.\n\
4. Preserve implementation commits; only make archive/spec bookkeeping changes.\n\n\
Forbidden:\n\
- Do not perform new feature implementation.\n\
- Do not archive while tasks.md still has unchecked [ ] items.\n\
- Do not report archived without an archive/spec bookkeeping diff or confirmed no-op reason.\n\
- Do not confuse acceptance PASS with archive completion.\n\n\
Before final response, verify:\n\
- openspec/changes/{change_id}/tasks.md has no unchecked [ ] items under active task sections.\n\
- git diff --stat or git status shows the expected archive/spec state.\n\
- The change is no longer left as an unarchived active change when archive work was required.\n\n\
If any check fails, report ARCHIVE_INCOMPLETE with exact evidence instead of saying archived."
)
}
fn cleanup_review_completion_contract(change_id: &str) -> String {
format!(
"Cleanup-review change id: {change_id}\n\n\
This is a post-apply dirty-worktree handoff task, not implementation, acceptance, or archive.\n\n\
Required outcome:\n\
1. Inspect the managed worktree for apply-generated dirty state for {change_id}.\n\
2. Keep only intentional handoff cleanup needed to make the worktree clean.\n\
3. Stage and commit only intentional cleanup files; never use blind staging.\n\
4. On success, output exactly one final marker line: CLEANUP_REVIEW: CLEAN.\n\n\
Forbidden:\n\
- Do not perform new implementation.\n\
- Do not use `git add -A` or `git add .`.\n\
- Do not ask for human input.\n\
- Do not emit CLEANUP_REVIEW: CLEAN unless the worktree is clean.\n\n\
Before final response, verify:\n\
- git status is clean.\n\
- Any staged/committed files were selected explicitly, not by blind staging.\n\
- Exactly one standalone CLEANUP_REVIEW: CLEAN marker will be emitted."
)
}
fn acceptance_completion_contract(change_id: &str) -> String {
format!(
"Acceptance id: {change_id}\n\n\
This is an acceptance review task, not implementation, archive, or summary.\n\n\
Required outcome:\n\
1. Review proposal.md, tasks.md, spec deltas, and implementation diff for {change_id}.\n\
2. Verify implemented behavior satisfies the OpenSpec requirements.\n\
3. Verify all active tasks in tasks.md are [x].\n\
4. Return a verdict using the acceptance skill's required format.\n\n\
Forbidden:\n\
- Do not perform new implementation during acceptance.\n\
- Do not return PASS while tasks.md has unchecked [ ] items.\n\
- Do not return PASS when diff/spec evidence is missing for required behavior.\n\
- Do not confuse cleanup or archive readiness with acceptance correctness.\n\n\
Before final response, verify:\n\
- tasks.md has no unchecked [ ] items under active task sections.\n\
- implementation diff and tests/evidence support every accepted requirement.\n\
- archive commitability blockers from the real commit path are considered.\n\n\
If checks fail, return a non-pass acceptance verdict with exact findings."
)
}
#[allow(dead_code)]
pub fn build_apply_prompt(
change_id: &str,
user_prompt: &str,
history_context: &str,
acceptance_tail_context: &str,
) -> String {
build_apply_prompt_with_skill(
crate::config::defaults::DEFAULT_APPLY_SKILL,
change_id,
user_prompt,
history_context,
acceptance_tail_context,
)
}
pub fn build_apply_prompt_with_skill(
apply_skill: &str,
change_id: &str,
user_prompt: &str,
history_context: &str,
acceptance_tail_context: &str,
) -> String {
let mut parts = Vec::new();
parts.push(skill_prelude(apply_skill));
parts.push(apply_completion_contract(change_id));
if !user_prompt.is_empty() {
parts.push(user_prompt.to_string());
}
parts.push(APPLY_SYSTEM_PROMPT.to_string());
if !acceptance_tail_context.is_empty() {
parts.push(acceptance_tail_context.to_string());
}
if !history_context.is_empty() {
parts.push(history_context.to_string());
}
parts.join("\n\n")
}
#[allow(dead_code)]
pub fn build_archive_prompt(change_id: &str, user_prompt: &str, history_context: &str) -> String {
build_archive_prompt_with_skill(
crate::config::defaults::DEFAULT_ARCHIVE_SKILL,
change_id,
user_prompt,
history_context,
)
}
pub fn build_archive_prompt_with_skill(
archive_skill: &str,
change_id: &str,
user_prompt: &str,
history_context: &str,
) -> String {
let mut parts = Vec::new();
parts.push(skill_prelude(archive_skill));
parts.push(archive_completion_contract(change_id));
if !user_prompt.is_empty() {
parts.push(user_prompt.to_string());
}
if !history_context.is_empty() {
parts.push(history_context.to_string());
}
parts.join("\n\n")
}
#[allow(dead_code)]
pub fn build_cleanup_review_prompt(change_id: &str) -> String {
build_cleanup_review_prompt_with_skill(
crate::config::defaults::DEFAULT_CLEANUP_REVIEW_SKILL,
change_id,
)
}
pub fn build_cleanup_review_prompt_with_skill(
cleanup_review_skill: &str,
change_id: &str,
) -> String {
let mut parts = Vec::new();
parts.push(skill_prelude(cleanup_review_skill));
parts.push(cleanup_review_completion_contract(change_id));
parts.push(format!(
"change_id: {}\nproposal_path: openspec/changes/{}/proposal.md\ntasks_path: openspec/changes/{}/tasks.md\nworkspace_path: .",
change_id, change_id, change_id
));
parts.push(
"Rules:\n- Perform only post-apply handoff cleanup for this managed worktree\n- NEVER use blind staging such as `git add -A` or `git add .`\n- Stage and commit only intentional cleanup files needed for clean handoff\n- Do not ask for human input; finish autonomously\n- On success, output exactly one final marker line: CLEANUP_REVIEW: CLEAN"
.to_string(),
);
parts.join("\n\n")
}
pub fn parse_cleanup_review_output(output: &str) -> bool {
let mut in_code_block = false;
let mut marker_count = 0_u32;
for line in output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
continue;
}
if trimmed == "CLEANUP_REVIEW: CLEAN" {
marker_count += 1;
}
}
marker_count == 1
}
#[allow(dead_code)]
pub fn build_acceptance_prompt(
change_id: &str,
user_prompt: &str,
history_context: &str,
last_output_context: &str,
diff_context: &str,
) -> String {
build_acceptance_prompt_context_only(
crate::config::defaults::DEFAULT_ACCEPT_SKILL,
change_id,
user_prompt,
history_context,
last_output_context,
diff_context,
)
}
pub fn build_acceptance_prompt_with_skill(
accept_skill: &str,
change_id: &str,
user_prompt: &str,
history_context: &str,
last_output_context: &str,
diff_context: &str,
) -> String {
build_acceptance_prompt_context_only_with_skill(
accept_skill,
change_id,
user_prompt,
history_context,
last_output_context,
diff_context,
)
}
const ARCHIVE_READINESS_CONTEXT: &str = "<archive_readiness_context>\n\
Before returning ACCEPTANCE: PASS, verify this workspace is ready for the real final archive commit on this repository's actual commit path.\n\
Focus only on blockers that would actually prevent the archive commit from succeeding.\n\
Do not assume that tests, linters, formatters, or pre-commit hooks exist unless they are part of the real commit path for this repository.\n\
If a normal commit in this repository runs hooks or other verification that would block the archive commit, treat that commit-path failure as relevant.\n\
If archive commitability is blocked, return a non-pass verdict and include actionable findings with:\n\
1) the blocking commit-path step or hook,\n\
2) the failing command or commit attempt when available,\n\
3) relevant file/path context when available.\n\
Do not defer commit-path blockers to archive.\n\
</archive_readiness_context>";
#[allow(dead_code)]
pub fn build_acceptance_prompt_context_only(
accept_skill: &str,
change_id: &str,
user_prompt: &str,
history_context: &str,
last_output_context: &str,
diff_context: &str,
) -> String {
build_acceptance_prompt_context_only_with_skill(
accept_skill,
change_id,
user_prompt,
history_context,
last_output_context,
diff_context,
)
}
pub fn build_acceptance_prompt_context_only_with_skill(
accept_skill: &str,
change_id: &str,
user_prompt: &str,
history_context: &str,
last_output_context: &str,
diff_context: &str,
) -> String {
let mut parts = Vec::new();
parts.push(skill_prelude(accept_skill));
parts.push(acceptance_completion_contract(change_id));
parts.push(format!("change_id: {}", change_id));
parts.push(format!(
"proposal_path: openspec/changes/{}/proposal.md\n\
tasks_path: openspec/changes/{}/tasks.md\n\
spec_deltas_path: openspec/changes/{}/specs/",
change_id, change_id, change_id
));
if !diff_context.is_empty() {
parts.push(diff_context.to_string());
}
parts.push(ARCHIVE_READINESS_CONTEXT.to_string());
if !last_output_context.is_empty() {
parts.push(last_output_context.to_string());
}
if !user_prompt.is_empty() {
parts.push(user_prompt.to_string());
}
if !history_context.is_empty() {
parts.push(history_context.to_string());
}
parts.join("\n\n")
}
pub fn build_acceptance_diff_context(
changed_files: &[String],
previous_findings: Option<&[String]>,
) -> String {
let mut lines = vec!["<acceptance_diff_context>".to_string()];
if !changed_files.is_empty() {
lines.push("Files changed since last acceptance check:".to_string());
for file in changed_files {
lines.push(format!("- {}", file));
}
lines.push(String::new());
}
if let Some(findings) = previous_findings {
if !findings.is_empty() {
lines.push("Previous acceptance findings:".to_string());
for finding in findings {
lines.push(format!("- {}", finding));
}
lines.push(String::new());
}
}
lines.push("Focus your verification on:".to_string());
lines.push("1. Whether the changed files address the previous findings".to_string());
lines.push("2. Whether the changes introduce new issues".to_string());
lines.push("3. Read relevant files if needed to confirm the fixes".to_string());
lines.push("</acceptance_diff_context>".to_string());
lines.join("\n")
}
pub fn build_last_acceptance_output_context(
stdout_tail: Option<&str>,
stderr_tail: Option<&str>,
) -> String {
if stdout_tail.is_none() && stderr_tail.is_none() {
return String::new();
}
let mut lines = vec!["<last_acceptance_output>".to_string()];
lines.push(
"Previous acceptance investigation output (for context - avoid repeating the same checks):"
.to_string(),
);
lines.push(String::new());
if let Some(stdout) = stdout_tail {
if !stdout.trim().is_empty() {
lines.push("stdout:".to_string());
lines.push(stdout.to_string());
lines.push(String::new());
}
}
if let Some(stderr) = stderr_tail {
if !stderr.trim().is_empty() {
lines.push("stderr:".to_string());
lines.push(stderr.to_string());
lines.push(String::new());
}
}
lines.push("</last_acceptance_output>".to_string());
lines.join("\n")
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn get_archive_readiness_context() -> &'static str {
ARCHIVE_READINESS_CONTEXT
}
#[test]
fn append_optional_prompt_noops_for_missing_empty_and_whitespace() {
assert_eq!(append_optional_prompt("base".to_string(), None), "base");
assert_eq!(append_optional_prompt("base".to_string(), Some("")), "base");
assert_eq!(
append_optional_prompt("base".to_string(), Some(" \n\t ")),
"base"
);
}
#[test]
fn archive_append_prompt_appends_raw_final_section() {
let prompt = append_optional_prompt(
build_archive_prompt("change-a", "", ""),
Some("archive tail"),
);
assert!(prompt.contains("Archive change id: change-a"));
assert!(prompt.ends_with("archive tail"));
}
#[test]
fn acceptance_append_prompt_appends_raw_final_section() {
let prompt = append_optional_prompt(
build_acceptance_prompt("change-a", "", "", "", ""),
Some("acceptance tail"),
);
assert!(prompt.contains("Acceptance id: change-a"));
assert!(prompt.ends_with("acceptance tail"));
}
#[test]
fn analyze_append_prompt_appends_raw_final_section() {
let prompt =
append_optional_prompt("generated analyze prompt".to_string(), Some("analyze tail"));
assert_eq!(prompt, "generated analyze prompt\n\nanalyze tail");
}
#[test]
fn resolve_append_prompt_appends_raw_final_section() {
let prompt =
append_optional_prompt("generated resolve prompt".to_string(), Some("resolve tail"));
assert_eq!(prompt, "generated resolve prompt\n\nresolve tail");
}
#[test]
fn append_optional_prompt_appends_raw_final_section() {
let prompt = append_optional_prompt("base {prompt}".to_string(), Some("tail {change_id}"));
assert_eq!(prompt, "base {prompt}\n\ntail {change_id}");
}
#[test]
fn test_build_acceptance_diff_context_with_files_and_findings() {
let changed_files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
let findings = vec![
"Task 1.1 not completed".to_string(),
"Missing integration test".to_string(),
];
let context = build_acceptance_diff_context(&changed_files, Some(&findings));
assert!(context.contains("<acceptance_diff_context>"));
assert!(context.contains("Files changed since last acceptance check:"));
assert!(context.contains("- src/main.rs"));
assert!(context.contains("- src/lib.rs"));
assert!(context.contains("Previous acceptance findings:"));
assert!(context.contains("- Task 1.1 not completed"));
assert!(context.contains("- Missing integration test"));
assert!(context.contains("Focus your verification on:"));
assert!(context.contains("</acceptance_diff_context>"));
}
#[test]
fn test_build_acceptance_diff_context_only_files() {
let changed_files = vec!["src/config.rs".to_string()];
let context = build_acceptance_diff_context(&changed_files, None);
assert!(context.contains("<acceptance_diff_context>"));
assert!(context.contains("Files changed since last acceptance check:"));
assert!(context.contains("- src/config.rs"));
assert!(!context.contains("Previous acceptance findings:"));
assert!(context.contains("Focus your verification on:"));
assert!(context.contains("</acceptance_diff_context>"));
}
#[test]
fn test_build_acceptance_diff_context_only_findings() {
let findings = vec!["Fix missing imports".to_string()];
let context = build_acceptance_diff_context(&[], Some(&findings));
assert!(context.contains("<acceptance_diff_context>"));
assert!(!context.contains("Files changed since last acceptance check:"));
assert!(context.contains("Previous acceptance findings:"));
assert!(context.contains("- Fix missing imports"));
assert!(context.contains("Focus your verification on:"));
assert!(context.contains("</acceptance_diff_context>"));
}
#[test]
fn test_build_acceptance_diff_context_empty() {
let context = build_acceptance_diff_context(&[], None);
assert!(context.contains("<acceptance_diff_context>"));
assert!(!context.contains("Files changed since last acceptance check:"));
assert!(!context.contains("Previous acceptance findings:"));
assert!(context.contains("Focus your verification on:"));
assert!(context.contains("</acceptance_diff_context>"));
}
#[test]
fn test_build_acceptance_prompt_insertion_order() {
let change_id = "test-change";
let user_prompt = "USER_PROMPT_MARKER";
let history_context = "HISTORY_CONTEXT_MARKER";
let last_output_context =
"<last_acceptance_output>\nLAST_OUTPUT_MARKER\n</last_acceptance_output>";
let diff_context =
"<acceptance_diff_context>\nDIFF_CONTEXT_MARKER\n</acceptance_diff_context>";
let result = build_acceptance_prompt(
change_id,
user_prompt,
history_context,
last_output_context,
diff_context,
);
let skill_pos = result
.find("load skills: cflx-accept")
.expect("Skill prelude should be present");
let acceptance_id_pos = result
.find("Acceptance id: test-change")
.expect("Acceptance id prelude should be present");
let metadata_pos = result
.find("change_id: test-change")
.expect("Change metadata should be present");
let diff_pos = result
.find("DIFF_CONTEXT_MARKER")
.expect("Diff context should be present");
let readiness_pos = result
.find("<archive_readiness_context>")
.expect("Archive readiness context should be present");
let last_output_pos = result
.find("LAST_OUTPUT_MARKER")
.expect("Last output context should be present");
let user_pos = result
.find("USER_PROMPT_MARKER")
.expect("User prompt should be present");
let history_pos = result
.find("HISTORY_CONTEXT_MARKER")
.expect("History context should be present");
assert!(
skill_pos < acceptance_id_pos,
"Skill prelude should come before acceptance id"
);
assert!(
acceptance_id_pos < metadata_pos,
"Acceptance id should come before change metadata"
);
assert!(
metadata_pos < diff_pos,
"Change metadata should come before diff context"
);
assert!(
diff_pos < readiness_pos,
"Diff context should come before archive readiness context"
);
assert!(
readiness_pos < last_output_pos,
"Archive readiness context should come before last output context"
);
assert!(
last_output_pos < user_pos,
"Last output context should come before user prompt"
);
assert!(
user_pos < history_pos,
"User prompt should come before history context"
);
}
#[test]
fn test_build_acceptance_prompt_context_only_uses_configured_accept_skill() {
let result = build_acceptance_prompt_context_only(
"cflx-accept-with-speca",
"test-change",
"",
"",
"",
"",
);
assert!(result.contains("$cflx-accept-with-speca"));
assert!(result.contains("load skills: cflx-accept-with-speca"));
assert!(!result.contains("$cflx-accept\n"));
assert!(result.contains("Acceptance id: test-change"));
}
#[test]
fn test_build_acceptance_prompt_empty_diff_context() {
let change_id = "test-change";
let user_prompt = "USER_PROMPT";
let history_context = "";
let last_output_context = "";
let diff_context = "";
let result = build_acceptance_prompt(
change_id,
user_prompt,
history_context,
last_output_context,
diff_context,
);
assert!(result.contains("$cflx-accept"));
assert!(result.contains("load skills: cflx-accept"));
assert!(result.contains("Acceptance id: test-change"));
assert!(result.contains("change_id: test-change"));
assert!(result.contains("proposal_path: openspec/changes/test-change/proposal.md"));
assert!(result.contains("USER_PROMPT"));
assert!(!result.contains("Files changed since last acceptance check:"));
assert!(!result.contains("Previous acceptance findings:"));
}
#[test]
fn test_build_archive_prompt_requires_completion_contract() {
let prompt = build_archive_prompt("change-123", "", "");
assert!(prompt.contains("This is an archive task, not implementation"));
assert!(prompt.contains("Do not archive while tasks.md still has unchecked [ ] items"));
assert!(prompt.contains("ARCHIVE_INCOMPLETE"));
assert!(prompt.contains("openspec/changes/change-123/tasks.md"));
}
#[test]
fn test_build_acceptance_prompt_requires_review_contract() {
let prompt = build_acceptance_prompt("change-123", "", "", "", "");
assert!(prompt.contains("This is an acceptance review task"));
assert!(prompt.contains("Do not return PASS while tasks.md has unchecked [ ] items"));
assert!(prompt.contains("archive commitability blockers"));
assert!(prompt.contains("Acceptance id: change-123"));
}
#[test]
fn test_build_cleanup_review_prompt_requires_handoff_contract() {
let prompt = build_cleanup_review_prompt("change-123");
assert!(prompt.contains("This is a post-apply dirty-worktree handoff task"));
assert!(prompt.contains("Do not perform new implementation"));
assert!(prompt.contains("Do not use `git add -A` or `git add .`"));
assert!(prompt.contains("git status is clean"));
}
#[test]
fn test_operation_prompt_builders_use_custom_skill_preludes() {
let apply =
build_apply_prompt_with_skill("team-apply", "change-123", "user", "history", "");
assert!(apply.contains("$team-apply"));
assert!(apply.contains("load skills: team-apply"));
assert!(!apply.contains("$cflx-apply"));
let archive =
build_archive_prompt_with_skill("team-archive", "change-123", "user", "history");
assert!(archive.contains("$team-archive"));
assert!(archive.contains("load skills: team-archive"));
assert!(!archive.contains("$cflx-archive"));
let cleanup = build_cleanup_review_prompt_with_skill("team-cleanup-review", "change-123");
assert!(cleanup.contains("$team-cleanup-review"));
assert!(cleanup.contains("load skills: team-cleanup-review"));
assert!(!cleanup.contains("$cflx-cleanup-review"));
let acceptance = build_acceptance_prompt_context_only_with_skill(
"cflx-accept-with-speca",
"change-123",
"user",
"history",
"last",
"diff",
);
assert!(acceptance.contains("$cflx-accept-with-speca"));
assert!(acceptance.contains("load skills: cflx-accept-with-speca"));
assert!(!acceptance.contains("$cflx-accept\n"));
assert!(acceptance.contains("change_id: change-123"));
}
#[test]
fn test_build_cleanup_review_prompt_contains_required_context() {
let prompt = build_cleanup_review_prompt("change-123");
assert!(prompt.contains("$cflx-cleanup-review"));
assert!(prompt.contains("load skills: cflx-cleanup-review"));
assert!(prompt.contains("Cleanup-review change id: change-123"));
assert!(prompt.contains("change_id: change-123"));
assert!(prompt.contains("proposal_path: openspec/changes/change-123/proposal.md"));
assert!(prompt.contains("tasks_path: openspec/changes/change-123/tasks.md"));
assert!(prompt.contains("workspace_path: ."));
assert!(prompt.contains("NEVER use blind staging"));
assert!(prompt.contains("CLEANUP_REVIEW: CLEAN"));
}
#[test]
fn test_parse_cleanup_review_output_accepts_single_marker() {
let output = "log line\nCLEANUP_REVIEW: CLEAN\nmore logs";
assert!(parse_cleanup_review_output(output));
}
#[test]
fn test_parse_cleanup_review_output_rejects_multiple_markers() {
let output = "CLEANUP_REVIEW: CLEAN\nCLEANUP_REVIEW: CLEAN\n";
assert!(!parse_cleanup_review_output(output));
}
#[test]
fn test_parse_cleanup_review_output_ignores_code_fence_markers() {
let output = "```\nCLEANUP_REVIEW: CLEAN\n```\n";
assert!(!parse_cleanup_review_output(output));
}
}