diff --git a/README.md b/README.md
index 05af6f4..b2bf34c 100644
--- a/README.md
+++ b/README.md
@@ -5,10 +5,10 @@
The MVP is Codex-first:
- reads Codex hook JSON from stdin;
-- captures only explicit plan blocks such as `... ` or `## Accepted Plan`;
+- captures only explicit plan blocks such as `... `, `... `, or `## Accepted Plan`;
- stores captured plans and planning Q/A decisions in `.agent-plan.json`;
-- posts a new PR comment with newly captured current-branch items when a PR exists;
-- leaves the local stack queued when no PR exists yet.
+- posts a new PR comment with newly captured current-branch items when an open PR exists;
+- leaves the local stack queued when no open PR exists yet.
## CLI
@@ -40,11 +40,13 @@ type = "command"
command = "plan-to-git hook --source codex"
```
-Exact hook configuration shape can vary by Codex release. The hook command itself expects the release behavior documented by Codex hooks: `Stop` includes `last_assistant_message`, and `UserPromptSubmit` includes `prompt`.
+Exact hook configuration shape can vary by Codex release. The hook command itself expects the release behavior documented by Codex hooks: `Stop` includes the final agent message (`last_agent_message`, with `last_assistant_message` still accepted for older payloads), and `UserPromptSubmit` includes `prompt`.
+
+If an agent emits known XML-style plan sections (`summary`, `flow`, `test_plan`, or `assumptions`) inside a proposed plan, `plan-to-git` normalizes them to Markdown headings before storage and PR sync.
## Pull Request Comments
-When `gh pr view` finds a PR for the current branch, `plan-to-git` creates a new issue comment on that PR containing items that have not been posted before:
+When `gh pr view` finds an open PR for the current branch, `plan-to-git` creates a new issue comment on that PR containing items that have not been posted before:
```markdown
## Agent Plan Update
@@ -52,10 +54,10 @@ When `gh pr view` finds a PR for the current branch, `plan-to-git` creates a new
...
```
-The PR description is not edited. After a comment is created, `.agent-plan.json` records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, or `import-codex` runs do not post the same plan again, including on a later PR.
+The PR description is not edited. Closed or merged pull requests are not commented on; new items stay queued until an open PR exists. After a comment is created, `.agent-plan.json` records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, or `import-codex` runs do not post the same plan again, including on a later PR.
## Safety
-The hook path only uses stable hook payload fields and explicitly marked plan text. `import-codex` can backfill previous plans from `~/.codex/sessions`, but it only reads assistant message events from sessions that match the current repository and branch, and it still imports only explicit markers such as `... ` or `## Accepted Plan`.
+The hook path only uses stable hook payload fields and explicitly marked plan text. `import-codex` can backfill previous plans from `~/.codex/sessions`, but it only reads assistant message events from sessions that match the current repository and branch, and it still imports only explicit markers such as `... `, `... `, or `## Accepted Plan`.
Captured content is redacted before local storage and PR sync. `.agent-plan.json` also acts as the sent-plan registry: content hashes prevent the same plan from being added and commented again.
diff --git a/changelog.d/20260603_195500_guard_plan_sync_closed_prs.md b/changelog.d/20260603_195500_guard_plan_sync_closed_prs.md
new file mode 100644
index 0000000..1029ee3
--- /dev/null
+++ b/changelog.d/20260603_195500_guard_plan_sync_closed_prs.md
@@ -0,0 +1,9 @@
+---
+bump: patch
+---
+
+### Fixed
+- Kept captured plan updates queued instead of posting comments to closed or merged pull requests.
+
+### Changed
+- Accepted current Codex `last_agent_message` hook payloads and normalized known XML-style plan sections before PR sync.
diff --git a/src/capture.rs b/src/capture.rs
index 61f6088..e0a5205 100644
--- a/src/capture.rs
+++ b/src/capture.rs
@@ -30,7 +30,7 @@ struct CodexHookInput {
turn_id: Option,
#[serde(default)]
prompt: Option,
- #[serde(default)]
+ #[serde(default, alias = "last_agent_message")]
last_assistant_message: Option,
}
diff --git a/src/github.rs b/src/github.rs
index 9df1617..bcda595 100644
--- a/src/github.rs
+++ b/src/github.rs
@@ -14,6 +14,10 @@ use crate::store::AgentPlanState;
pub enum SyncStatus {
NoItems,
NoPullRequest,
+ ClosedPullRequest {
+ number: u64,
+ state: String,
+ },
Unchanged {
number: u64,
},
@@ -27,6 +31,7 @@ pub enum SyncStatus {
#[derive(Debug, Deserialize)]
struct PullRequest {
number: u64,
+ state: String,
}
#[derive(Debug, Deserialize)]
@@ -42,6 +47,12 @@ pub fn sync_state(context: &GitContext, state: &mut AgentPlanState) -> AppResult
let Some(pull_request) = view_current_pr(&context.repo_root)? else {
return Ok(SyncStatus::NoPullRequest);
};
+ if !pull_request.state.eq_ignore_ascii_case("OPEN") {
+ return Ok(SyncStatus::ClosedPullRequest {
+ number: pull_request.number,
+ state: pull_request.state,
+ });
+ }
let (comment_body, item_ids, item_count) = {
let items = state.unposted_items_for_pr(pull_request.number);
@@ -67,7 +78,7 @@ pub fn sync_state(context: &GitContext, state: &mut AgentPlanState) -> AppResult
fn view_current_pr(repo_root: &Path) -> AppResult> {
let output = Command::new("gh")
.current_dir(repo_root)
- .args(["pr", "view", "--json", "number"])
+ .args(["pr", "view", "--json", "number,state,url"])
.output()?;
if output.status.success() {
diff --git a/src/main.rs b/src/main.rs
index c4d75a0..dceeed7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -187,6 +187,9 @@ fn print_sync_status(status: &SyncStatus) {
match status {
SyncStatus::NoItems => println!("no captured plan items to sync"),
SyncStatus::NoPullRequest => println!("no pull request found for the current branch"),
+ SyncStatus::ClosedPullRequest { number, state } => {
+ println!("pull request #{number} is {state}; leaving plan items queued");
+ }
SyncStatus::Unchanged { number } => {
println!("no new plan items to comment on pull request #{number}");
}
diff --git a/src/normalize.rs b/src/normalize.rs
index 4a10e24..431cc70 100644
--- a/src/normalize.rs
+++ b/src/normalize.rs
@@ -40,21 +40,20 @@ pub fn extract_questions(message: &str) -> Vec {
}
fn extract_tagged_plans(message: &str) -> Vec {
- let lower = message.to_lowercase();
- let open_tag = "";
let close_tag = " ";
let mut cursor = 0;
let mut plans = Vec::new();
- while let Some(relative_start) = lower[cursor..].find(open_tag) {
- let content_start = cursor + relative_start + open_tag.len();
+ while let Some(open_tag) = find_proposed_plan_open_tag(message, cursor) {
+ let content_start = open_tag.end;
let mut close_cursor = content_start;
let Some(content_end) = (loop {
- let Some(relative_end) = lower[close_cursor..].find(close_tag) else {
+ let Some(candidate_start) =
+ find_ascii_case_insensitive(message, close_tag, close_cursor)
+ else {
break None;
};
- let candidate_start = close_cursor + relative_end;
let candidate_end = candidate_start + close_tag.len();
if closes_plan_block(message, candidate_end) {
break Some(candidate_start);
@@ -66,10 +65,19 @@ fn extract_tagged_plans(message: &str) -> Vec {
let content = message[content_start..content_end].trim();
if !content.is_empty() {
- plans.push(CapturedPlan {
- title: first_heading(content),
- content: content.to_owned(),
- });
+ let content_heading = first_heading(content);
+ let has_content_heading = content_heading.is_some();
+ let title = content_heading.or(open_tag.title);
+ let content = if has_content_heading {
+ content.to_owned()
+ } else {
+ title.as_deref().map_or_else(
+ || content.to_owned(),
+ |title| format!("# {title}\n\n{content}"),
+ )
+ };
+ let content = normalize_xml_plan_sections(&content);
+ plans.push(CapturedPlan { title, content });
}
cursor = content_end + close_tag.len();
}
@@ -77,6 +85,157 @@ fn extract_tagged_plans(message: &str) -> Vec {
plans
}
+struct ProposedPlanOpenTag {
+ end: usize,
+ title: Option,
+}
+
+fn find_proposed_plan_open_tag(message: &str, cursor: usize) -> Option {
+ const OPEN_TAG_START: &str = "' && !next_character.is_ascii_whitespace() {
+ search_start = after_name;
+ continue;
+ }
+
+ let tag_end = after_name + message[after_name..].find('>')? + 1;
+ let tag = &message[tag_start..tag_end];
+ let title = attribute_value(tag, "title")
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned);
+
+ return Some(ProposedPlanOpenTag {
+ end: tag_end,
+ title,
+ });
+ }
+
+ None
+}
+
+fn attribute_value<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
+ let mut index = "' {
+ break;
+ }
+
+ if character.is_ascii_whitespace() {
+ index += character.len_utf8();
+ continue;
+ }
+
+ let attribute_name_start = index;
+ while index < tag.len() {
+ let Some(character) = tag[index..].chars().next() else {
+ break;
+ };
+ if !matches!(character, '-' | '_' | ':') && !character.is_ascii_alphanumeric() {
+ break;
+ }
+ index += character.len_utf8();
+ }
+ if attribute_name_start == index {
+ if let Some(character) = tag[index..].chars().next() {
+ index += character.len_utf8();
+ }
+ continue;
+ }
+
+ let attribute_name = &tag[attribute_name_start..index];
+ while index < tag.len() {
+ let Some(character) = tag[index..].chars().next() else {
+ break;
+ };
+ if !character.is_ascii_whitespace() {
+ break;
+ }
+ index += character.len_utf8();
+ }
+
+ if !tag[index..].starts_with('=') {
+ continue;
+ }
+ index += 1;
+
+ while index < tag.len() {
+ let Some(character) = tag[index..].chars().next() else {
+ break;
+ };
+ if !character.is_ascii_whitespace() {
+ break;
+ }
+ index += character.len_utf8();
+ }
+
+ let Some(quote) = tag[index..].chars().next() else {
+ break;
+ };
+ if quote != '"' && quote != '\'' {
+ let value_start = index;
+ while index < tag.len() {
+ let Some(character) = tag[index..].chars().next() else {
+ break;
+ };
+ if character == '>' || character.is_ascii_whitespace() {
+ break;
+ }
+ index += character.len_utf8();
+ }
+ if attribute_name.eq_ignore_ascii_case(name) {
+ return Some(&tag[value_start..index]);
+ }
+ continue;
+ }
+
+ index += quote.len_utf8();
+ let value_start = index;
+ let Some(value_end_relative) = tag[index..].find(quote) else {
+ break;
+ };
+ let value_end = index + value_end_relative;
+ index = value_end + quote.len_utf8();
+
+ if attribute_name.eq_ignore_ascii_case(name) {
+ return Some(&tag[value_start..value_end]);
+ }
+ }
+
+ None
+}
+
+fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option {
+ if needle.is_empty() || from > haystack.len() || needle.len() > haystack.len() {
+ return None;
+ }
+
+ let haystack = haystack.as_bytes();
+ let needle = needle.as_bytes();
+ for index in from..=haystack.len().saturating_sub(needle.len()) {
+ if haystack[index..index + needle.len()]
+ .iter()
+ .zip(needle.iter())
+ .all(|(candidate, expected)| candidate.eq_ignore_ascii_case(expected))
+ {
+ return Some(index);
+ }
+ }
+
+ None
+}
+
fn closes_plan_block(message: &str, close_tag_end: usize) -> bool {
message[close_tag_end..]
.lines()
@@ -84,6 +243,178 @@ fn closes_plan_block(message: &str, close_tag_end: usize) -> bool {
.is_none_or(|rest_of_line| rest_of_line.trim().is_empty())
}
+fn normalize_xml_plan_sections(content: &str) -> String {
+ let lines = content.lines().collect::>();
+ let mut output = Vec::new();
+ let mut index = 0;
+ let mut changed = false;
+ let mut in_code_fence = false;
+ let mut details_depth = 0usize;
+
+ while index < lines.len() {
+ if is_code_fence_line(lines[index]) {
+ in_code_fence = !in_code_fence;
+ output.push(lines[index].to_owned());
+ index += 1;
+ continue;
+ }
+
+ if in_code_fence {
+ output.push(lines[index].to_owned());
+ index += 1;
+ continue;
+ }
+
+ if is_open_details_line(lines[index]) {
+ details_depth += 1;
+ output.push(lines[index].to_owned());
+ index += 1;
+ continue;
+ }
+
+ if is_close_details_line(lines[index]) {
+ details_depth = details_depth.saturating_sub(1);
+ output.push(lines[index].to_owned());
+ index += 1;
+ continue;
+ }
+
+ if details_depth > 0 {
+ output.push(lines[index].to_owned());
+ index += 1;
+ continue;
+ }
+
+ let Some(section) = xml_plan_section_opening(lines[index]) else {
+ output.push(lines[index].to_owned());
+ index += 1;
+ continue;
+ };
+
+ let Some(close_index) = lines[index + 1..]
+ .iter()
+ .position(|line| xml_plan_section_closing(line, section.tag))
+ .map(|relative_index| index + 1 + relative_index)
+ else {
+ output.push(lines[index].to_owned());
+ index += 1;
+ continue;
+ };
+
+ trim_trailing_blank_lines(&mut output);
+ if !output.is_empty() {
+ output.push(String::new());
+ }
+ output.push(format!("## {}", section.heading));
+
+ let body = dedent_lines(&lines[index + 1..close_index]);
+ if !body.is_empty() {
+ output.push(String::new());
+ output.extend(body);
+ }
+
+ index = close_index + 1;
+ changed = true;
+ }
+
+ if changed {
+ trim_trailing_blank_lines(&mut output);
+ output.join("\n")
+ } else {
+ content.to_owned()
+ }
+}
+
+struct XmlPlanSection {
+ tag: &'static str,
+ heading: &'static str,
+}
+
+fn xml_plan_section_opening(line: &str) -> Option {
+ const SECTIONS: &[XmlPlanSection] = &[
+ XmlPlanSection {
+ tag: "summary",
+ heading: "Summary",
+ },
+ XmlPlanSection {
+ tag: "flow",
+ heading: "Flow",
+ },
+ XmlPlanSection {
+ tag: "test_plan",
+ heading: "Test Plan",
+ },
+ XmlPlanSection {
+ tag: "assumptions",
+ heading: "Assumptions",
+ },
+ ];
+
+ let trimmed = line.trim();
+ SECTIONS
+ .iter()
+ .find(|section| trimmed.eq_ignore_ascii_case(&format!("<{}>", section.tag)))
+ .map(|section| XmlPlanSection {
+ tag: section.tag,
+ heading: section.heading,
+ })
+}
+
+fn xml_plan_section_closing(line: &str, tag: &str) -> bool {
+ line.trim().eq_ignore_ascii_case(&format!("{tag}>"))
+}
+
+fn dedent_lines(lines: &[&str]) -> Vec {
+ let minimum_indent = lines
+ .iter()
+ .filter(|line| !line.trim().is_empty())
+ .map(|line| line.len() - line.trim_start().len())
+ .min()
+ .unwrap_or(0);
+
+ let mut dedented = lines
+ .iter()
+ .map(|line| {
+ if line.trim().is_empty() {
+ String::new()
+ } else {
+ line.get(minimum_indent..).unwrap_or(line).to_owned()
+ }
+ })
+ .collect::>();
+
+ while dedented.first().is_some_and(String::is_empty) {
+ dedented.remove(0);
+ }
+ trim_trailing_blank_lines(&mut dedented);
+ dedented
+}
+
+fn trim_trailing_blank_lines(lines: &mut Vec) {
+ while lines.last().is_some_and(|line| line.trim().is_empty()) {
+ lines.pop();
+ }
+}
+
+fn is_code_fence_line(line: &str) -> bool {
+ let trimmed = line.trim_start();
+ trimmed.starts_with("```") || trimmed.starts_with("~~~")
+}
+
+fn is_open_details_line(line: &str) -> bool {
+ let trimmed = line.trim();
+ trimmed.eq_ignore_ascii_case("")
+ || (trimmed.len() > "".len()
+ && trimmed
+ .get(.."'))
+}
+
+fn is_close_details_line(line: &str) -> bool {
+ line.trim().eq_ignore_ascii_case(" ")
+}
+
fn extract_accepted_plan_headings(message: &str) -> Vec {
let lines: Vec<&str> = message.lines().collect();
let mut plans = Vec::new();
diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs
index ee15256..1e32d4a 100644
--- a/tests/integration/cli.rs
+++ b/tests/integration/cli.rs
@@ -55,6 +55,101 @@ mod unix {
assert!(state.contains("Capture plan"));
}
+ #[test]
+ fn hook_accepts_current_codex_last_agent_message() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let bin_dir = temp_dir.path().join("bin");
+ let repo_dir = temp_dir.path().join("repo");
+ fs::create_dir_all(&bin_dir).expect("bin dir");
+ fs::create_dir_all(&repo_dir).expect("repo dir");
+ write_fake_git(&bin_dir, &repo_dir);
+ write_fake_gh_no_pr(&bin_dir);
+
+ run_hook(
+ &repo_dir,
+ &bin_dir,
+ &format!(
+ r#"{{
+ "session_id":"session",
+ "cwd":"{}",
+ "hook_event_name":"Stop",
+ "turn_id":"turn",
+ "last_agent_message":"\n# Current Codex\n\n- Capture current payload\n "
+ }}"#,
+ repo_dir.display()
+ ),
+ );
+
+ let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file");
+ assert!(state.contains("Capture current payload"));
+ }
+
+ #[test]
+ fn hook_accepts_proposed_plan_title_attribute() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let bin_dir = temp_dir.path().join("bin");
+ let repo_dir = temp_dir.path().join("repo");
+ fs::create_dir_all(&bin_dir).expect("bin dir");
+ fs::create_dir_all(&repo_dir).expect("repo dir");
+ write_fake_git(&bin_dir, &repo_dir);
+ write_fake_gh_no_pr(&bin_dir);
+
+ run_hook(
+ &repo_dir,
+ &bin_dir,
+ &format!(
+ r#"{{
+ "session_id":"session",
+ "cwd":"{}",
+ "hook_event_name":"Stop",
+ "turn_id":"turn",
+ "last_agent_message":"\n- Capture title attribute\n "
+ }}"#,
+ repo_dir.display()
+ ),
+ );
+
+ let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file");
+ assert!(state.contains("Attribute Hook Plan"));
+ assert!(state.contains("# Attribute Hook Plan"));
+ assert!(state.contains("Capture title attribute"));
+ }
+
+ #[test]
+ fn hook_normalizes_xml_plan_sections() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let bin_dir = temp_dir.path().join("bin");
+ let repo_dir = temp_dir.path().join("repo");
+ fs::create_dir_all(&bin_dir).expect("bin dir");
+ fs::create_dir_all(&repo_dir).expect("repo dir");
+ write_fake_git(&bin_dir, &repo_dir);
+ write_fake_gh_no_pr(&bin_dir);
+
+ run_hook(
+ &repo_dir,
+ &bin_dir,
+ &format!(
+ r#"{{
+ "session_id":"session",
+ "cwd":"{}",
+ "hook_event_name":"Stop",
+ "turn_id":"turn",
+ "last_agent_message":"\n \n Verify production capture.\n \n\n \n 1. Check GitHub comment.\n \n "
+ }}"#,
+ repo_dir.display()
+ ),
+ );
+
+ let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file");
+ assert!(state.contains("# XML Plan"));
+ assert!(state.contains("## Summary"));
+ assert!(state.contains("Verify production capture."));
+ assert!(state.contains("## Test Plan"));
+ assert!(state.contains("1. Check GitHub comment."));
+ assert!(!state.contains(""));
+ assert!(!state.contains(""));
+ }
+
#[test]
fn hook_records_question_answer_decision() {
let temp_dir = tempfile::tempdir().expect("temp dir");
@@ -137,6 +232,109 @@ mod unix {
assert!(state.contains("\"comment_id\": 12345"));
}
+ #[test]
+ fn hook_leaves_plans_queued_when_pr_is_merged() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let bin_dir = temp_dir.path().join("bin");
+ let repo_dir = temp_dir.path().join("repo");
+ let captured_request = temp_dir.path().join("request.json");
+ fs::create_dir_all(&bin_dir).expect("bin dir");
+ fs::create_dir_all(&repo_dir).expect("repo dir");
+ write_fake_git(&bin_dir, &repo_dir);
+ write_fake_gh_closed_pr(&bin_dir, "MERGED", &captured_request);
+
+ run_hook(
+ &repo_dir,
+ &bin_dir,
+ &format!(
+ r#"{{
+ "session_id":"session",
+ "cwd":"{}",
+ "hook_event_name":"Stop",
+ "turn_id":"turn",
+ "last_assistant_message":"\n# Queued\n\n- Wait for an open PR\n "
+ }}"#,
+ repo_dir.display()
+ ),
+ );
+
+ let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file");
+ assert!(state.contains("Wait for an open PR"));
+ assert!(state.contains("\"posted_comments\": []"));
+ assert!(!captured_request.exists());
+ }
+
+ #[test]
+ fn hook_leaves_plans_queued_when_pr_is_closed() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let bin_dir = temp_dir.path().join("bin");
+ let repo_dir = temp_dir.path().join("repo");
+ let captured_request = temp_dir.path().join("request.json");
+ fs::create_dir_all(&bin_dir).expect("bin dir");
+ fs::create_dir_all(&repo_dir).expect("repo dir");
+ write_fake_git(&bin_dir, &repo_dir);
+ write_fake_gh_closed_pr(&bin_dir, "CLOSED", &captured_request);
+
+ run_hook(
+ &repo_dir,
+ &bin_dir,
+ &format!(
+ r#"{{
+ "session_id":"session",
+ "cwd":"{}",
+ "hook_event_name":"Stop",
+ "turn_id":"turn",
+ "last_assistant_message":"\n# Queued\n\n- Wait for an open PR\n "
+ }}"#,
+ repo_dir.display()
+ ),
+ );
+
+ let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file");
+ assert!(state.contains("Wait for an open PR"));
+ assert!(state.contains("\"posted_comments\": []"));
+ assert!(!captured_request.exists());
+ }
+
+ #[test]
+ fn sync_reports_merged_pr_and_does_not_comment() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let bin_dir = temp_dir.path().join("bin");
+ let repo_dir = temp_dir.path().join("repo");
+ let captured_request = temp_dir.path().join("request.json");
+ fs::create_dir_all(&bin_dir).expect("bin dir");
+ fs::create_dir_all(&repo_dir).expect("repo dir");
+ write_fake_git(&bin_dir, &repo_dir);
+ write_fake_gh_closed_pr(&bin_dir, "MERGED", &captured_request);
+
+ run_hook(
+ &repo_dir,
+ &bin_dir,
+ &format!(
+ r#"{{
+ "session_id":"session",
+ "cwd":"{}",
+ "hook_event_name":"Stop",
+ "turn_id":"turn",
+ "last_assistant_message":"\n# Queued\n\n- Wait for an open PR\n "
+ }}"#,
+ repo_dir.display()
+ ),
+ );
+
+ let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git"))
+ .arg("sync")
+ .current_dir(&repo_dir)
+ .env("PATH", path_with_fake_bin(&bin_dir))
+ .output()
+ .expect("run sync");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("stdout");
+ assert!(stdout.contains("pull request #17 is MERGED; leaving plan items queued"));
+ assert!(!captured_request.exists());
+ }
+
#[test]
fn import_codex_backfills_history_once_without_syncing() {
let temp_dir = tempfile::tempdir().expect("temp dir");
@@ -243,7 +441,7 @@ esac
fn write_fake_gh_no_pr(bin_dir: &Path) {
let script = r#"#!/usr/bin/env bash
set -euo pipefail
-if [[ "$*" == "pr view --json number" ]]; then
+if [[ "$*" == "pr view --json number,state,url" ]]; then
echo 'no pull requests found for branch "feature/test"' >&2
exit 1
fi
@@ -257,8 +455,8 @@ exit 1
let script = format!(
r#"#!/usr/bin/env bash
set -euo pipefail
-if [[ "$*" == "pr view --json number" ]]; then
- printf '%s\n' '{{"number":17}}'
+if [[ "$*" == "pr view --json number,state,url" ]]; then
+ printf '%s\n' '{{"number":17,"state":"OPEN","url":"https://github.com/example/repo/pull/17"}}'
exit 0
fi
if [[ "$1 $2 $3" == "api --method POST" && "$4" == "repos/example/repo/issues/17/comments" && "$5" == "--input" ]]; then
@@ -274,6 +472,27 @@ exit 1
write_executable(&bin_dir.join("gh"), &script);
}
+ fn write_fake_gh_closed_pr(bin_dir: &Path, state: &str, captured_request: &Path) {
+ let script = format!(
+ r#"#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$*" == "pr view --json number,state,url" ]]; then
+ printf '%s\n' '{{"number":17,"state":"{state}","url":"https://github.com/example/repo/pull/17"}}'
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ printf '%s\n' "$*" > "{}"
+ echo "comment API should not be called for closed PR" >&2
+ exit 1
+fi
+echo "unexpected gh args: $*" >&2
+exit 1
+"#,
+ captured_request.display()
+ );
+ write_executable(&bin_dir.join("gh"), &script);
+ }
+
fn write_executable(path: &Path, content: &str) {
fs::write(path, content).expect("write script");
let mut permissions = fs::metadata(path).expect("metadata").permissions();
diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs
index cb693ac..f00bd8b 100644
--- a/tests/unit/plan_capture.rs
+++ b/tests/unit/plan_capture.rs
@@ -26,6 +26,239 @@ after
assert!(plans[0].content.contains("- Add capture."));
}
+#[test]
+fn extracts_tagged_plan_with_title_attribute() {
+ let message = r#"
+
+- Capture attribute title.
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert_eq!(plans[0].title.as_deref(), Some("Attribute Plan"));
+ assert!(plans[0].content.starts_with("# Attribute Plan"));
+ assert!(plans[0].content.contains("- Capture attribute title."));
+}
+
+#[test]
+fn normalizes_xml_plan_sections_to_markdown() {
+ let message = r#"
+
+
+ Verify end-to-end capture.
+
+
+
+ 1. Inspect hooks.
+ 2. Confirm GitHub comment.
+
+
+
+ 1. Run import.
+ 2. Check sync.
+
+
+
+ 1. gh is authenticated.
+
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert_eq!(
+ plans[0].content,
+ r"# Production Hook Verification Plan
+
+## Summary
+
+Verify end-to-end capture.
+
+## Flow
+
+1. Inspect hooks.
+2. Confirm GitHub comment.
+
+## Test Plan
+
+1. Run import.
+2. Check sync.
+
+## Assumptions
+
+1. gh is authenticated."
+ );
+}
+
+#[test]
+fn leaves_unknown_xml_plan_content_unchanged() {
+ let message = r#"
+
+
+ Keep unknown tags literal.
+
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert!(plans[0].content.contains(""));
+ assert!(plans[0].content.contains(" "));
+}
+
+#[test]
+fn leaves_xml_section_examples_in_code_fences_unchanged() {
+ let message = r#"
+
+```xml
+
+ Keep this example literal.
+
+```
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert!(plans[0].content.contains("```xml\n"));
+ assert!(plans[0].content.contains(" \n```"));
+ assert!(!plans[0].content.contains("## Summary"));
+}
+
+#[test]
+fn leaves_details_summary_html_unchanged() {
+ let message = r#"
+
+
+
+Click to expand.
+
+
+Body.
+
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert!(plans[0].content.contains(""));
+ assert!(plans[0].content.contains(""));
+ assert!(plans[0].content.contains(" "));
+ assert!(!plans[0].content.contains("## Summary"));
+}
+
+#[test]
+fn extracts_tagged_plan_with_multiple_attributes() {
+ let message = r#"
+
+- Keep the exact title attribute.
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert_eq!(plans[0].title.as_deref(), Some("Right Plan"));
+ assert!(plans[0].content.starts_with("# Right Plan"));
+ assert!(plans[0]
+ .content
+ .contains("- Keep the exact title attribute."));
+}
+
+#[test]
+fn extracts_multiple_attributed_plans() {
+ let message = r#"
+
+- First body.
+
+
+- Second body.
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 2);
+ assert_eq!(plans[0].title.as_deref(), Some("First Plan"));
+ assert_eq!(plans[1].title.as_deref(), Some("Second Plan"));
+ assert!(plans[0].content.contains("- First body."));
+ assert!(plans[1].content.contains("- Second body."));
+}
+
+#[test]
+fn does_not_confuse_data_title_with_title() {
+ let message = r#"
+
+- No visible attribute title.
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert_eq!(plans[0].title, None);
+ assert!(!plans[0].content.contains("Wrong"));
+ assert!(plans[0]
+ .content
+ .starts_with("- No visible attribute title."));
+}
+
+#[test]
+fn ignores_blank_title_attribute() {
+ let message = r#"
+
+- No empty heading.
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert_eq!(plans[0].title, None);
+ assert!(!plans[0].content.starts_with("# "));
+ assert!(plans[0].content.starts_with("- No empty heading."));
+}
+
+#[test]
+fn content_heading_takes_precedence_over_title_attribute() {
+ let message = r#"
+
+# Content Plan
+
+- Keep content heading.
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert_eq!(plans.len(), 1);
+ assert_eq!(plans[0].title.as_deref(), Some("Content Plan"));
+ assert!(plans[0].content.starts_with("# Content Plan"));
+ assert!(!plans[0].content.contains("# Attribute Plan"));
+}
+
+#[test]
+fn rejects_proposed_plan_prefix_tags() {
+ let message = r#"
+
+- Do not capture.
+
+
+- Do not capture either.
+
+"#;
+
+ let plans = extract_marked_plans(message);
+
+ assert!(plans.is_empty());
+}
+
#[test]
fn tagged_plan_ignores_inline_tag_examples() {
let message = r"