Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
The MVP supports Codex and Claude Code:

- reads Codex or Claude Code hook JSON from stdin;
- captures only explicit plan blocks such as `<proposed_plan>...</proposed_plan>`, `<proposed_plan title="...">...</proposed_plan>`, or `## Accepted Plan`;
- captures explicit plan blocks such as `<proposed_plan>...</proposed_plan>`, `<proposed_plan title="...">...</proposed_plan>`, or `## Accepted Plan`, plus structured Codex planner updates from `import-codex`;
- stores captured plans and planning Q/A decisions in a per-repository state file;
- posts a new PR comment with newly captured current-branch items when a valid (open, non-draft) PR exists;
- posts a new PR comment with newly captured current-branch items when an open PR exists, including draft PRs;
- leaves the local stack queued when no valid PR exists yet.

## CLI
Expand Down Expand Up @@ -94,7 +94,7 @@ If an agent emits known XML-style plan sections (`summary`, `flow`, `test_plan`,

## Pull Request Comments

When `gh pr view` finds an open, non-draft 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, including a draft PR, `plan-to-git` creates a new issue comment on that PR containing items that have not been posted before:

```markdown
## Agent Plan Update
Expand All @@ -106,10 +106,10 @@ Use `plan-to-git sync --pr 7` to post queued current-branch items to a specific

Use `--repo owner/repo` or `PLAN_TO_GIT_REPO=owner/repo` when the local `origin` remote is not the pull request target repository, for example in fork-origin workflows. The explicit repository only selects the GitHub PR/comment target; local state and history matching remain tied to the current checkout.

The PR description is not edited. Closed, merged, or still-draft pull requests are not commented on; new items stay queued until the PR is valid (open and marked ready for review). After a comment is created, the local state file records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, `import-codex`, or `import-claude` 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, the local state file records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, `import-codex`, or `import-claude` runs do not post the same plan again, including on a later PR.

## Safety

The hook path only uses stable hook payload fields, explicitly marked plan text, and Claude Plan Mode transcript artifacts. `import-codex` can backfill previous plans from `~/.codex/sessions`; `import-claude` can backfill from Claude Code transcript files under the active Claude config directory. Both importers only read sessions that match the current repository and branch when branch metadata is available, and they still import only explicit markers such as `<proposed_plan>...</proposed_plan>`, `<proposed_plan title="...">...</proposed_plan>`, `## Accepted Plan`, or Claude Code's native Plan Mode output.
The hook path only uses stable hook payload fields, explicitly marked plan text, and Claude Plan Mode transcript artifacts. `import-codex` can backfill previous plans from `~/.codex/sessions`, including structured Codex `update_plan` planner calls; `import-claude` can backfill from Claude Code transcript files under the active Claude config directory. Both importers only read sessions that match the current repository and branch when branch metadata is available, and they still import only explicit markers such as `<proposed_plan>...</proposed_plan>`, `<proposed_plan title="...">...</proposed_plan>`, `## Accepted Plan`, Codex planner calls, or Claude Code's native Plan Mode output.

Captured content is redacted before local storage and PR sync. The local state file also acts as the sent-plan registry: content hashes prevent the same plan from being added and commented again.
1 change: 1 addition & 0 deletions changelog.d/20260617_codex_update_plan_import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed Codex history import so structured `update_plan` planner calls from `~/.codex/sessions` are captured and queued for PR sync instead of reporting matched session files with zero plans.
1 change: 1 addition & 0 deletions changelog.d/20260617_sync_draft_prs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Changed plan sync to post comments to open draft pull requests instead of leaving captured plan items queued until the PR is marked ready for review.
164 changes: 160 additions & 4 deletions src/codex_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::history::{
collect_jsonl_files, line_turn_id, looks_like_rendered_plan_stack, session_id_from_path,
HistoryImportOutcome,
};
use crate::normalize::extract_marked_plans;
use crate::normalize::{extract_marked_plans, CapturedPlan};
use crate::store::{AgentPlanState, AgentSource, NewPlanItem};

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -71,9 +71,10 @@ fn import_session_file(
continue;
}

let Some(message) = plan_message_text(&event) else {
let plans = event_plans(&event);
if plans.is_empty() {
continue;
};
}

let session_id = metadata
.as_ref()
Expand All @@ -90,7 +91,7 @@ fn import_session_file(
.and_then(Value::as_str)
.map(ToOwned::to_owned);

for plan in extract_marked_plans(&message) {
for plan in plans {
outcome.plans_found += 1;
if looks_like_rendered_plan_stack(&plan.content) {
outcome.rendered_stacks_skipped += 1;
Expand Down Expand Up @@ -178,6 +179,84 @@ fn plan_message_text(event: &Value) -> Option<String> {
assistant_message_text(event).or_else(|| task_complete_message_text(event))
}

fn event_plans(event: &Value) -> Vec<CapturedPlan> {
if let Some(message) = plan_message_text(event) {
return extract_marked_plans(&message);
}

codex_update_plan(event).into_iter().collect()
}

fn codex_update_plan(event: &Value) -> Option<CapturedPlan> {
if event.get("type").and_then(Value::as_str) != Some("response_item") {
return None;
}

let payload = event.get("payload")?;
if payload.get("type").and_then(Value::as_str) != Some("function_call") {
return None;
}
if payload.get("name").and_then(Value::as_str) != Some("update_plan") {
return None;
}

let arguments = payload.get("arguments").and_then(Value::as_str)?;
let arguments = serde_json::from_str::<Value>(arguments).ok()?;
captured_plan_from_update_plan_arguments(&arguments)
}

fn captured_plan_from_update_plan_arguments(arguments: &Value) -> Option<CapturedPlan> {
let steps = arguments
.get("plan")?
.as_array()?
.iter()
.filter_map(update_plan_step)
.collect::<Vec<_>>();
if steps.is_empty() {
return None;
}

let mut content = String::from("# Codex Plan\n");
if let Some(explanation) = arguments
.get("explanation")
.and_then(Value::as_str)
.map(str::trim)
.filter(|explanation| !explanation.is_empty())
{
content.push('\n');
content.push_str(explanation);
content.push('\n');
}

content.push_str("\n## Steps\n\n");
for (status, step) in steps {
content.push_str("- ");
content.push_str(status);
content.push_str(": ");
content.push_str(step);
content.push('\n');
}

Some(CapturedPlan {
title: Some(String::from("Codex Plan")),
content: content.trim_end().to_owned(),
})
}

fn update_plan_step(item: &Value) -> Option<(&str, &str)> {
let step = item.get("step").and_then(Value::as_str)?.trim();
if step.is_empty() {
return None;
}
let status = item
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.filter(|status| !status.is_empty())
.unwrap_or("pending");
Some((status, step))
}

fn assistant_message_text(event: &Value) -> Option<String> {
if event.get("type").and_then(Value::as_str) != Some("response_item") {
return None;
Expand Down Expand Up @@ -275,6 +354,35 @@ mod tests {
}))
}

fn update_plan_line(timestamp: &str) -> String {
json_line(&json!({
"timestamp": timestamp,
"type": "response_item",
"payload": {
"type": "function_call",
"name": "update_plan",
"arguments": serde_json::to_string(&json!({
"explanation": "Structured Codex planning event.",
"plan": [
{
"step": "Inspect failing import output",
"status": "completed"
},
{
"step": "Import structured plan calls",
"status": "in_progress"
},
{
"step": "Run regression tests",
"status": "pending"
}
]
})).expect("serialize update_plan arguments"),
"call_id": "call-plan"
}
}))
}

fn write_jsonl(path: &Path, lines: &[String]) {
fs::write(path, format!("{}\n", lines.join("\n"))).expect("write session");
}
Expand Down Expand Up @@ -423,6 +531,54 @@ mod tests {
assert_eq!(state.items[0].created_at, "2026-05-31T12:34:56Z");
}

#[test]
fn imports_structured_update_plan_function_calls() {
let temp_dir = tempdir().expect("temp dir");
let repo_root = temp_dir.path().join("repo");
let codex_home = temp_dir.path().join("codex");
let session_dir = codex_home.join("sessions/2026/06/17");
fs::create_dir_all(&repo_root).expect("repo root");
fs::create_dir_all(&session_dir).expect("session dir");

write_jsonl(
&session_dir.join("rollout-2026-06-17T12-00-00-plan.jsonl"),
&[
session_meta_line(&repo_root, "feature/test"),
update_plan_line("2026-06-17T12:34:56Z"),
],
);

let context = GitContext {
repo_root,
repo_slug: Some("example/repo".to_owned()),
branch: Some("feature/test".to_owned()),
head_sha: Some("abcdef".to_owned()),
};
let mut state = AgentPlanState::default();

let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import");

assert_eq!(outcome.files_scanned, 1);
assert_eq!(outcome.files_matched, 1);
assert_eq!(outcome.plans_found, 1);
assert_eq!(outcome.plans_added, 1);
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0].title.as_deref(), Some("Codex Plan"));
assert!(state.items[0]
.content
.contains("Structured Codex planning event."));
assert!(state.items[0]
.content
.contains("- completed: Inspect failing import output"));
assert!(state.items[0]
.content
.contains("- in_progress: Import structured plan calls"));
assert!(state.items[0]
.content
.contains("- pending: Run regression tests"));
assert_eq!(state.items[0].created_at, "2026-06-17T12:34:56Z");
}

#[test]
fn skips_sessions_without_positive_repo_or_cwd_match() {
let temp_dir = tempdir().expect("temp dir");
Expand Down
11 changes: 0 additions & 11 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ pub enum SyncStatus {
number: u64,
state: String,
},
DraftPullRequest {
number: u64,
},
Unchanged {
number: u64,
},
Expand All @@ -35,8 +32,6 @@ pub enum SyncStatus {
struct PullRequest {
number: u64,
state: String,
#[serde(default, rename = "isDraft")]
is_draft: bool,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -86,12 +81,6 @@ fn sync_to_pull_request(
state: pull_request.state,
});
}
if pull_request.is_draft {
return Ok(SyncStatus::DraftPullRequest {
number: pull_request.number,
});
}

let (comment_body, item_ids, item_count) = {
let items = state.unposted_items_for_pr(pull_request.number);
if items.is_empty() {
Expand Down
3 changes: 0 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,6 @@ fn print_sync_status(status: &SyncStatus) {
SyncStatus::ClosedPullRequest { number, state } => {
println!("pull request #{number} is {state}; leaving plan items queued");
}
SyncStatus::DraftPullRequest { number } => {
println!("pull request #{number} is a draft; leaving plan items queued");
}
SyncStatus::Unchanged { number } => {
println!("no new plan items to comment on pull request #{number}");
}
Expand Down
Loading
Loading