diff --git a/README.md b/README.md
index 4ce5ed0..496e5d0 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@
`plan-to-git` captures explicit plans produced by coding agents and posts new pull request comments for plan updates.
-The MVP is Codex-first:
+The MVP supports Codex and Claude Code:
-- reads Codex hook JSON from stdin;
+- reads Codex or Claude Code hook JSON from stdin;
- captures only explicit plan blocks such as `...`, `...`, or `## Accepted Plan`;
-- stores captured plans and planning Q/A decisions in `.agent-plan.json`;
+- 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;
- leaves the local stack queued when no valid PR exists yet.
@@ -14,15 +14,29 @@ The MVP is Codex-first:
```bash
plan-to-git hook --source codex < hook-payload.json
+plan-to-git hook --source claude < hook-payload.json
plan-to-git show
plan-to-git render
plan-to-git sync
+plan-to-git sync --pr 7
plan-to-git import-codex --dry-run
plan-to-git import-codex
+plan-to-git import-claude --dry-run
+plan-to-git import-claude
plan-to-git clear --yes
```
-`hook` is intentionally quiet on stdout, because Codex hook stdout is interpreted by Codex. Operational messages go to stderr.
+`hook` is intentionally quiet on stdout, because agent hook stdout can be interpreted by the agent. Operational messages go to stderr.
+
+## State Storage
+
+By default, `plan-to-git` stores its state outside the repository under the system temp directory:
+
+```text
+/tmp/plan-to-git//.agent-plan.json
+```
+
+Set `PLAN_TO_GIT_STATE_DIR` to choose another state root, or `PLAN_TO_GIT_STATE_PATH` to choose an exact state file path. This keeps hook-generated state from dirtying the working tree while preserving a stable queue for the current repository.
## Codex Hook Example
@@ -42,6 +56,39 @@ 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 the final agent message (`last_agent_message`, with `last_assistant_message` still accepted for older payloads), and `UserPromptSubmit` includes `prompt`.
+## Claude Code Hook Example
+
+Add the command to Claude Code hook configuration for `Stop` and `UserPromptSubmit` events:
+
+```json
+{
+ "hooks": {
+ "UserPromptSubmit": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "plan-to-git hook --source claude"
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "plan-to-git hook --source claude"
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Claude Code `Stop` hooks provide `last_assistant_message`, `session_id`, `transcript_path`, `cwd`, and `hook_event_name`; `UserPromptSubmit` hooks provide `prompt`. `import-claude` backfills explicitly marked plans and Claude Plan Mode artifacts from `CLAUDE_CONFIG_DIR/projects/**/*.jsonl`, `CLAUDE_HOME/projects/**/*.jsonl`, or `~/.claude/projects/**/*.jsonl`.
+
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
@@ -54,10 +101,12 @@ When `gh pr view` finds an open, non-draft PR for the current branch, `plan-to-g
...
```
-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, `.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.
+Use `plan-to-git sync --pr 7` to post queued current-branch items to a specific pull request instead of relying on branch-based PR discovery. `sync` is source-agnostic: one run posts all unposted current-branch items in the state file, whether they came from Codex, Claude Code, or another supported agent.
+
+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.
## 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, 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 `...`, `...`, `## Accepted Plan`, or Claude Code's native Plan Mode output.
-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.
+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.
diff --git a/changelog.d/20260611_000000_claude_code_plan_capture.md b/changelog.d/20260611_000000_claude_code_plan_capture.md
new file mode 100644
index 0000000..def63c8
--- /dev/null
+++ b/changelog.d/20260611_000000_claude_code_plan_capture.md
@@ -0,0 +1,8 @@
+---
+bump: minor
+---
+
+### Added
+- Added Claude Code hook capture and `import-claude` backfill support for explicitly marked plans and native Claude Plan Mode artifacts from Claude transcript files.
+- Added temp-directory state storage with `PLAN_TO_GIT_STATE_DIR` and `PLAN_TO_GIT_STATE_PATH` overrides so hook state no longer has to dirty the repository.
+- Added `plan-to-git sync --pr ` for source-agnostic syncing of queued Codex, Claude Code, and other supported agent plans to an explicit pull request.
diff --git a/src/capture.rs b/src/capture.rs
index e0a5205..1164084 100644
--- a/src/capture.rs
+++ b/src/capture.rs
@@ -1,13 +1,17 @@
use serde::Deserialize;
+use serde_json::Value;
+use std::fs::File;
+use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use crate::error::AppResult;
use crate::git;
use crate::github::{self, SyncStatus};
-use crate::normalize::{extract_marked_plans, extract_questions};
+use crate::normalize::{extract_marked_plans, extract_questions, CapturedPlan};
+use crate::state_path;
use crate::store::{
load_state, save_state, AgentSource, NewDecision, NewPendingQuestion, NewPlanItem,
- PendingQuestion, STATE_FILE_NAME,
+ PendingQuestion,
};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -34,11 +38,64 @@ struct CodexHookInput {
last_assistant_message: Option,
}
+#[derive(Debug, Deserialize)]
+struct ClaudeHookInput {
+ #[serde(default)]
+ session_id: Option,
+ #[serde(default)]
+ transcript_path: Option,
+ #[serde(default)]
+ cwd: Option,
+ hook_event_name: String,
+ #[serde(default)]
+ prompt: Option,
+ #[serde(default, alias = "last_agent_message")]
+ last_assistant_message: Option,
+}
+
pub fn process_codex_hook(input: &str) -> AppResult {
let hook_input: CodexHookInput = serde_json::from_str(input)?;
+ process_agent_hook(&AgentHookInput {
+ source: AgentSource::Codex,
+ session_id: hook_input.session_id,
+ cwd: hook_input.cwd,
+ hook_event_name: hook_input.hook_event_name,
+ turn_id: hook_input.turn_id,
+ prompt: hook_input.prompt,
+ last_assistant_message: hook_input.last_assistant_message,
+ transcript_path: None,
+ })
+}
+
+pub fn process_claude_hook(input: &str) -> AppResult {
+ let hook_input: ClaudeHookInput = serde_json::from_str(input)?;
+ process_agent_hook(&AgentHookInput {
+ source: AgentSource::Claude,
+ session_id: hook_input.session_id,
+ cwd: hook_input.cwd,
+ hook_event_name: hook_input.hook_event_name,
+ turn_id: None,
+ prompt: hook_input.prompt,
+ last_assistant_message: hook_input.last_assistant_message,
+ transcript_path: hook_input.transcript_path,
+ })
+}
+
+struct AgentHookInput {
+ source: AgentSource,
+ session_id: Option,
+ cwd: Option,
+ hook_event_name: String,
+ turn_id: Option,
+ prompt: Option,
+ last_assistant_message: Option,
+ transcript_path: Option,
+}
+
+fn process_agent_hook(hook_input: &AgentHookInput) -> AppResult {
let start_dir = hook_input.cwd.as_deref().unwrap_or_else(|| Path::new("."));
let context = git::discover(start_dir)?;
- let state_path = context.repo_root.join(STATE_FILE_NAME);
+ let state_path = state_path::state_path(&context);
let mut state = load_state(&state_path)?;
state.set_context(
context.repo_slug.clone(),
@@ -52,10 +109,40 @@ pub fn process_codex_hook(input: &str) -> AppResult {
match hook_input.hook_event_name.as_str() {
"Stop" => {
- if let Some(message) = hook_input.last_assistant_message.as_deref() {
+ let message = hook_input.last_assistant_message.clone().or_else(|| {
+ hook_input
+ .transcript_path
+ .as_deref()
+ .and_then(last_assistant_message_from_transcript)
+ });
+
+ if let Some(message) = message.as_deref() {
for plan in extract_marked_plans(message) {
let added = state.add_plan(NewPlanItem {
- source: AgentSource::Codex,
+ source: hook_input.source,
+ title: plan.title,
+ content: plan.content,
+ branch: context.branch.clone(),
+ head_sha: context.head_sha.clone(),
+ session_id: hook_input.session_id.clone(),
+ turn_id: hook_input.turn_id.clone(),
+ created_at: None,
+ });
+ if added {
+ captured_plans += 1;
+ changed = true;
+ }
+ }
+ }
+
+ if hook_input.source == AgentSource::Claude && captured_plans == 0 {
+ if let Some(plan) = hook_input
+ .transcript_path
+ .as_deref()
+ .and_then(last_claude_plan_mode_plan_from_transcript)
+ {
+ let added = state.add_plan(NewPlanItem {
+ source: hook_input.source,
title: plan.title,
content: plan.content,
branch: context.branch.clone(),
@@ -69,11 +156,13 @@ pub fn process_codex_hook(input: &str) -> AppResult {
changed = true;
}
}
+ }
- if captured_plans == 0 {
+ if captured_plans == 0 {
+ if let Some(message) = message.as_deref() {
let questions = extract_questions(message);
if state.add_pending_question(NewPendingQuestion {
- source: AgentSource::Codex,
+ source: hook_input.source,
questions,
branch: context.branch.clone(),
head_sha: context.head_sha.clone(),
@@ -90,7 +179,7 @@ pub fn process_codex_hook(input: &str) -> AppResult {
if !prompt.trim().is_empty() {
let questions = drain_relevant_questions(&mut state.pending_questions);
if state.answer_pending_questions(NewDecision {
- source: AgentSource::Codex,
+ source: hook_input.source,
questions,
answer: prompt.to_owned(),
branch: context.branch.clone(),
@@ -125,6 +214,103 @@ pub fn process_codex_hook(input: &str) -> AppResult {
})
}
+fn last_assistant_message_from_transcript(path: &Path) -> Option {
+ let file = File::open(path).ok()?;
+ let reader = BufReader::new(file);
+ let mut last_message = None;
+
+ for line in reader.lines().map_while(Result::ok) {
+ let Ok(event) = serde_json::from_str::(&line) else {
+ continue;
+ };
+ let Some(message) = claude_assistant_message_text(&event) else {
+ continue;
+ };
+ last_message = Some(message);
+ }
+
+ last_message
+}
+
+fn last_claude_plan_mode_plan_from_transcript(path: &Path) -> Option {
+ let file = File::open(path).ok()?;
+ let reader = BufReader::new(file);
+ let mut last_plan = None;
+
+ for line in reader.lines().map_while(Result::ok) {
+ let Ok(event) = serde_json::from_str::(&line) else {
+ continue;
+ };
+ let Some(plan) = claude_plan_mode_plan(&event) else {
+ continue;
+ };
+ last_plan = Some(plan);
+ }
+
+ last_plan
+}
+
+fn claude_assistant_message_text(event: &Value) -> Option {
+ if event.get("type").and_then(Value::as_str) != Some("assistant") {
+ return None;
+ }
+ if event
+ .get("message")
+ .and_then(|message| message.get("role"))
+ .and_then(Value::as_str)
+ != Some("assistant")
+ {
+ return None;
+ }
+
+ claude_content_text(event.get("message")?.get("content")?)
+}
+
+fn claude_content_text(content: &Value) -> Option {
+ if let Some(text) = content.as_str() {
+ return (!text.trim().is_empty()).then_some(text.to_owned());
+ }
+
+ let text = content
+ .as_array()?
+ .iter()
+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
+ .filter_map(|block| block.get("text").and_then(Value::as_str))
+ .collect::>()
+ .join("\n");
+
+ (!text.trim().is_empty()).then_some(text)
+}
+
+fn claude_plan_mode_plan(event: &Value) -> Option {
+ event
+ .get("toolUseResult")
+ .and_then(|result| result.get("plan"))
+ .and_then(Value::as_str)
+ .and_then(captured_plan_from_markdown)
+}
+
+fn captured_plan_from_markdown(content: &str) -> Option {
+ let content = content.trim();
+ if content.is_empty() {
+ return None;
+ }
+
+ Some(CapturedPlan {
+ title: markdown_title(content),
+ content: content.to_owned(),
+ })
+}
+
+fn markdown_title(content: &str) -> Option {
+ content
+ .lines()
+ .find_map(|line| line.trim().strip_prefix("# "))
+ .map(str::trim)
+ .filter(|title| !title.is_empty())
+ .map(ToOwned::to_owned)
+}
+
fn drain_relevant_questions(pending_questions: &mut Vec) -> Vec {
let mut questions = Vec::new();
for pending_question in pending_questions.drain(..) {
diff --git a/src/claude_history.rs b/src/claude_history.rs
new file mode 100644
index 0000000..c9a1c51
--- /dev/null
+++ b/src/claude_history.rs
@@ -0,0 +1,567 @@
+use serde_json::Value;
+use std::collections::HashSet;
+use std::fs;
+use std::fs::File;
+use std::io::{BufRead, BufReader};
+use std::path::{Path, PathBuf};
+
+use crate::error::AppResult;
+use crate::git::GitContext;
+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, CapturedPlan};
+use crate::store::{AgentPlanState, AgentSource, NewPlanItem};
+
+pub fn import_claude_history(
+ claude_home: &Path,
+ context: &GitContext,
+ state: &mut AgentPlanState,
+) -> AppResult {
+ let mut outcome = HistoryImportOutcome::default();
+ let mut files = claude_project_files(claude_home)?;
+ files.sort();
+
+ for path in files {
+ outcome.files_scanned += 1;
+ import_session_file(&path, claude_home, context, state, &mut outcome)?;
+ }
+
+ Ok(outcome)
+}
+
+fn import_session_file(
+ path: &Path,
+ claude_home: &Path,
+ context: &GitContext,
+ state: &mut AgentPlanState,
+ outcome: &mut HistoryImportOutcome,
+) -> AppResult<()> {
+ let file = File::open(path)?;
+ let reader = BufReader::new(file);
+ let mut file_matches = false;
+ let mut imported_plan_paths = HashSet::new();
+
+ for (line_index, line) in reader.lines().enumerate() {
+ outcome.lines_scanned += 1;
+ let line = line?;
+ let Ok(event) = serde_json::from_str::(&line) else {
+ outcome.parse_errors += 1;
+ continue;
+ };
+
+ if !event_matches_context(&event, context) {
+ continue;
+ }
+ if !file_matches {
+ file_matches = true;
+ outcome.files_matched += 1;
+ }
+
+ let session_id = event
+ .get("sessionId")
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned)
+ .or_else(|| session_id_from_path(path));
+ let turn_id = event
+ .get("uuid")
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned)
+ .or_else(|| line_turn_id(path, line_index + 1));
+ let created_at = event
+ .get("timestamp")
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned);
+
+ if let Some(message) = assistant_message_text(&event) {
+ for plan in extract_marked_plans(&message) {
+ import_plan(
+ plan,
+ context,
+ state,
+ outcome,
+ session_id.clone(),
+ turn_id.clone(),
+ created_at.clone(),
+ );
+ }
+ }
+
+ if let Some(plan) = claude_plan_mode_plan(&event) {
+ if let Some(plan_path) = claude_plan_mode_path(&event) {
+ imported_plan_paths.insert(plan_path);
+ }
+ import_plan(
+ plan,
+ context,
+ state,
+ outcome,
+ session_id.clone(),
+ turn_id.clone(),
+ created_at.clone(),
+ );
+ } else if let Some(plan_path) = claude_plan_mode_path(&event) {
+ if imported_plan_paths.insert(plan_path.clone()) {
+ if let Some(plan) = claude_plan_mode_file(&plan_path, claude_home) {
+ import_plan(
+ plan,
+ context,
+ state,
+ outcome,
+ session_id.clone(),
+ turn_id.clone(),
+ created_at.clone(),
+ );
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn import_plan(
+ plan: CapturedPlan,
+ context: &GitContext,
+ state: &mut AgentPlanState,
+ outcome: &mut HistoryImportOutcome,
+ session_id: Option,
+ turn_id: Option,
+ created_at: Option,
+) {
+ outcome.plans_found += 1;
+ if looks_like_rendered_plan_stack(&plan.content) {
+ outcome.rendered_stacks_skipped += 1;
+ return;
+ }
+ let added = state.add_plan(NewPlanItem {
+ source: AgentSource::Claude,
+ title: plan.title,
+ content: plan.content,
+ branch: context.branch.clone(),
+ head_sha: context.head_sha.clone(),
+ session_id,
+ turn_id,
+ created_at,
+ });
+
+ if added {
+ outcome.plans_added += 1;
+ } else {
+ outcome.duplicates += 1;
+ }
+}
+
+fn claude_project_files(claude_home: &Path) -> AppResult> {
+ let projects_dir = claude_home.join("projects");
+ if !projects_dir.exists() {
+ return Ok(Vec::new());
+ }
+
+ let mut files = Vec::new();
+ collect_jsonl_files(&projects_dir, &mut files)?;
+ Ok(files)
+}
+
+fn event_matches_context(event: &Value, context: &GitContext) -> bool {
+ let Some(cwd) = event.get("cwd").and_then(Value::as_str).map(PathBuf::from) else {
+ return false;
+ };
+ if !cwd.starts_with(&context.repo_root) {
+ return false;
+ }
+
+ match (
+ context.branch.as_deref(),
+ event.get("gitBranch").and_then(Value::as_str),
+ ) {
+ (Some(current), Some(history)) => current == history,
+ _ => true,
+ }
+}
+
+fn assistant_message_text(event: &Value) -> Option {
+ if event.get("type").and_then(Value::as_str) != Some("assistant") {
+ return None;
+ }
+ let message = event.get("message")?;
+ if message.get("role").and_then(Value::as_str) != Some("assistant") {
+ return None;
+ }
+ content_text(message.get("content")?)
+}
+
+fn content_text(content: &Value) -> Option {
+ if let Some(text) = content.as_str() {
+ return (!text.trim().is_empty()).then_some(text.to_owned());
+ }
+
+ let text = content
+ .as_array()?
+ .iter()
+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
+ .filter_map(|block| block.get("text").and_then(Value::as_str))
+ .collect::>()
+ .join("\n");
+
+ (!text.trim().is_empty()).then_some(text)
+}
+
+fn claude_plan_mode_plan(event: &Value) -> Option {
+ event
+ .get("toolUseResult")
+ .and_then(|result| result.get("plan"))
+ .and_then(Value::as_str)
+ .and_then(captured_plan_from_markdown)
+}
+
+fn claude_plan_mode_path(event: &Value) -> Option {
+ event
+ .get("toolUseResult")
+ .and_then(|result| result.get("filePath"))
+ .and_then(Value::as_str)
+ .or_else(|| {
+ (event
+ .get("attachment")
+ .and_then(|attachment| attachment.get("type"))
+ .and_then(Value::as_str)
+ == Some("plan_mode_exit"))
+ .then(|| {
+ event
+ .get("attachment")
+ .and_then(|attachment| attachment.get("planFilePath"))
+ .and_then(Value::as_str)
+ })
+ .flatten()
+ })
+ .map(PathBuf::from)
+}
+
+fn claude_plan_mode_file(path: &Path, claude_home: &Path) -> Option {
+ if !path.starts_with(claude_home) {
+ return None;
+ }
+
+ fs::read_to_string(path)
+ .ok()
+ .and_then(|content| captured_plan_from_markdown(&content))
+}
+
+fn captured_plan_from_markdown(content: &str) -> Option {
+ let content = content.trim();
+ if content.is_empty() {
+ return None;
+ }
+
+ Some(CapturedPlan {
+ title: markdown_title(content),
+ content: content.to_owned(),
+ })
+}
+
+fn markdown_title(content: &str) -> Option {
+ content
+ .lines()
+ .find_map(|line| line.trim().strip_prefix("# "))
+ .map(str::trim)
+ .filter(|title| !title.is_empty())
+ .map(ToOwned::to_owned)
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+ use std::{fs, path::Path};
+
+ use tempfile::tempdir;
+
+ use crate::git::GitContext;
+ use crate::store::{AgentPlanState, AgentSource};
+
+ use super::import_claude_history;
+
+ fn json_line(value: &serde_json::Value) -> String {
+ serde_json::to_string(value).expect("serialize jsonl event")
+ }
+
+ fn assistant_line(cwd: &Path, branch: Option<&str>, uuid: &str, text: &str) -> String {
+ let mut event = json!({
+ "type": "assistant",
+ "uuid": uuid,
+ "sessionId": "session",
+ "cwd": cwd.to_string_lossy().into_owned(),
+ "timestamp": "2026-06-11T12:34:56Z",
+ "message": {
+ "role": "assistant",
+ "content": [{ "type": "text", "text": text }]
+ }
+ });
+ if let Some(branch) = branch {
+ event["gitBranch"] = json!(branch);
+ }
+ json_line(&event)
+ }
+
+ fn user_line(cwd: &Path, text: &str) -> String {
+ json_line(&json!({
+ "type": "user",
+ "uuid": "user-turn",
+ "sessionId": "session",
+ "cwd": cwd.to_string_lossy().into_owned(),
+ "message": {
+ "role": "user",
+ "content": text
+ }
+ }))
+ }
+
+ fn plan_mode_line(
+ cwd: &Path,
+ branch: Option<&str>,
+ uuid: &str,
+ plan: &str,
+ plan_path: &Path,
+ ) -> String {
+ let mut event = json!({
+ "type": "user",
+ "uuid": uuid,
+ "sessionId": "session",
+ "cwd": cwd.to_string_lossy().into_owned(),
+ "timestamp": "2026-06-11T12:34:56Z",
+ "toolUseResult": {
+ "plan": plan,
+ "filePath": plan_path.to_string_lossy().into_owned()
+ }
+ });
+ if let Some(branch) = branch {
+ event["gitBranch"] = json!(branch);
+ }
+ json_line(&event)
+ }
+
+ fn plan_mode_exit_line(
+ cwd: &Path,
+ branch: Option<&str>,
+ uuid: &str,
+ plan_path: &Path,
+ ) -> String {
+ let mut event = json!({
+ "type": "attachment",
+ "uuid": uuid,
+ "sessionId": "session",
+ "cwd": cwd.to_string_lossy().into_owned(),
+ "timestamp": "2026-06-11T12:34:57Z",
+ "attachment": {
+ "type": "plan_mode_exit",
+ "planFilePath": plan_path.to_string_lossy().into_owned(),
+ "planExists": true
+ }
+ });
+ if let Some(branch) = branch {
+ event["gitBranch"] = json!(branch);
+ }
+ json_line(&event)
+ }
+
+ fn write_jsonl(path: &Path, lines: &[String]) {
+ fs::write(path, format!("{}\n", lines.join("\n"))).expect("write session");
+ }
+
+ fn context(repo_root: std::path::PathBuf) -> GitContext {
+ GitContext {
+ repo_root,
+ repo_slug: Some("example/repo".to_owned()),
+ branch: Some("feature/test".to_owned()),
+ head_sha: Some("abcdef".to_owned()),
+ }
+ }
+
+ #[test]
+ fn imports_marked_assistant_plans_and_skips_duplicates() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let claude_home = temp_dir.path().join("claude");
+ let session_dir = claude_home.join("projects/-tmp-repo");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ write_jsonl(
+ &session_dir.join("session.jsonl"),
+ &[
+ user_line(&repo_root, "ignore user text"),
+ assistant_line(&repo_root, Some("feature/test"), "turn-1", "not a marked plan"),
+ assistant_line(
+ &repo_root,
+ Some("feature/test"),
+ "turn-2",
+ "\n# Claude Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n",
+ ),
+ assistant_line(
+ &repo_root,
+ Some("feature/test"),
+ "turn-3",
+ "\n# Claude Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n",
+ ),
+ ],
+ );
+
+ let mut state = AgentPlanState::default();
+ let outcome =
+ import_claude_history(&claude_home, &context(repo_root), &mut state).expect("import");
+
+ assert_eq!(outcome.files_scanned, 1);
+ assert_eq!(outcome.files_matched, 1);
+ assert_eq!(outcome.plans_found, 2);
+ assert_eq!(outcome.plans_added, 1);
+ assert_eq!(outcome.duplicates, 1);
+ assert_eq!(state.items.len(), 1);
+ assert_eq!(state.items[0].source, AgentSource::Claude);
+ assert!(state.items[0].content.contains("Import old plans"));
+ assert!(state.items[0].content.contains("api_key=[REDACTED]"));
+ assert!(!state.items[0].content.contains("secret-value"));
+ assert_eq!(state.items[0].turn_id.as_deref(), Some("turn-2"));
+ }
+
+ #[test]
+ fn imports_claude_plan_mode_artifacts_once() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let claude_home = temp_dir.path().join("claude");
+ let session_dir = claude_home.join("projects/-tmp-repo");
+ let plan_path = claude_home.join("plans/native-plan.md");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+ fs::create_dir_all(plan_path.parent().expect("plan parent")).expect("plan dir");
+ fs::write(
+ &plan_path,
+ "# Plan: Claude Plan Mode\n\n- Capture native plan files\n",
+ )
+ .expect("write plan file");
+
+ write_jsonl(
+ &session_dir.join("session.jsonl"),
+ &[
+ plan_mode_line(
+ &repo_root,
+ Some("feature/test"),
+ "plan-turn",
+ "# Plan: Claude Plan Mode\n\n- Capture native plan files\n",
+ &plan_path,
+ ),
+ plan_mode_exit_line(&repo_root, Some("feature/test"), "exit-turn", &plan_path),
+ ],
+ );
+
+ let mut state = AgentPlanState::default();
+ let outcome =
+ import_claude_history(&claude_home, &context(repo_root), &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!(outcome.duplicates, 0);
+ assert_eq!(state.items.len(), 1);
+ assert_eq!(state.items[0].source, AgentSource::Claude);
+ assert_eq!(
+ state.items[0].title.as_deref(),
+ Some("Plan: Claude Plan Mode")
+ );
+ assert!(state.items[0].content.contains("Capture native plan files"));
+ assert_eq!(state.items[0].turn_id.as_deref(), Some("plan-turn"));
+ }
+
+ #[test]
+ fn skips_unrelated_cwd_and_wrong_branch() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let other_root = temp_dir.path().join("other");
+ let claude_home = temp_dir.path().join("claude");
+ let session_dir = claude_home.join("projects/-tmp-repo");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&other_root).expect("other root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ write_jsonl(
+ &session_dir.join("session.jsonl"),
+ &[
+ assistant_line(
+ &other_root,
+ Some("feature/test"),
+ "turn-1",
+ "\n# Other Cwd\n\n- Do not import\n",
+ ),
+ assistant_line(
+ &repo_root,
+ Some("feature/other"),
+ "turn-2",
+ "\n# Other Branch\n\n- Do not import\n",
+ ),
+ ],
+ );
+
+ let mut state = AgentPlanState::default();
+ let outcome =
+ import_claude_history(&claude_home, &context(repo_root), &mut state).expect("import");
+
+ assert_eq!(outcome.files_matched, 0);
+ assert_eq!(outcome.plans_found, 0);
+ assert!(state.items.is_empty());
+ }
+
+ #[test]
+ fn imports_when_branch_is_missing() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let claude_home = temp_dir.path().join("claude");
+ let session_dir = claude_home.join("projects/-tmp-repo");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ write_jsonl(
+ &session_dir.join("session.jsonl"),
+ &[assistant_line(
+ &repo_root,
+ None,
+ "turn-1",
+ "\n# No Branch\n\n- Import anyway\n",
+ )],
+ );
+
+ let mut state = AgentPlanState::default();
+ let outcome =
+ import_claude_history(&claude_home, &context(repo_root), &mut state).expect("import");
+
+ assert_eq!(outcome.plans_added, 1);
+ assert!(state.items[0].content.contains("Import anyway"));
+ }
+
+ #[test]
+ fn skips_rendered_plan_stack_blocks() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let claude_home = temp_dir.path().join("claude");
+ let session_dir = claude_home.join("projects/-tmp-repo");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ write_jsonl(
+ &session_dir.join("session.jsonl"),
+ &[assistant_line(
+ &repo_root,
+ Some("feature/test"),
+ "turn-1",
+ "\n\n## Agent Plan Stack\n\n",
+ )],
+ );
+
+ let mut state = AgentPlanState::default();
+ let outcome =
+ import_claude_history(&claude_home, &context(repo_root), &mut state).expect("import");
+
+ assert_eq!(outcome.plans_found, 1);
+ assert_eq!(outcome.rendered_stacks_skipped, 1);
+ assert!(state.items.is_empty());
+ }
+}
diff --git a/src/codex_history.rs b/src/codex_history.rs
index da897c8..d67ff8a 100644
--- a/src/codex_history.rs
+++ b/src/codex_history.rs
@@ -1,26 +1,17 @@
use serde_json::Value;
-use std::fs::{self, File};
+use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use crate::error::AppResult;
use crate::git::{parse_github_slug, GitContext};
+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::pr_body::{END_MARKER, START_MARKER};
use crate::store::{AgentPlanState, AgentSource, NewPlanItem};
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CodexHistoryImportOutcome {
- pub files_scanned: usize,
- pub files_matched: usize,
- pub lines_scanned: usize,
- pub parse_errors: usize,
- pub plans_found: usize,
- pub plans_added: usize,
- pub duplicates: usize,
- pub rendered_stacks_skipped: usize,
-}
-
#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionMetadata {
id: Option,
@@ -33,18 +24,8 @@ pub fn import_codex_history(
codex_home: &Path,
context: &GitContext,
state: &mut AgentPlanState,
-) -> AppResult {
- let mut outcome = CodexHistoryImportOutcome {
- files_scanned: 0,
- files_matched: 0,
- lines_scanned: 0,
- parse_errors: 0,
- plans_found: 0,
- plans_added: 0,
- duplicates: 0,
- rendered_stacks_skipped: 0,
- };
-
+) -> AppResult {
+ let mut outcome = HistoryImportOutcome::default();
let mut files = codex_session_files(codex_home)?;
files.sort();
@@ -60,7 +41,7 @@ fn import_session_file(
path: &Path,
context: &GitContext,
state: &mut AgentPlanState,
- outcome: &mut CodexHistoryImportOutcome,
+ outcome: &mut HistoryImportOutcome,
) -> AppResult<()> {
let file = File::open(path)?;
let reader = BufReader::new(file);
@@ -148,28 +129,6 @@ fn codex_session_files(codex_home: &Path) -> AppResult> {
Ok(files)
}
-fn collect_jsonl_files(dir: &Path, files: &mut Vec) -> AppResult<()> {
- for entry in fs::read_dir(dir)? {
- let entry = entry?;
- let path = entry.path();
- if path.is_dir() {
- collect_jsonl_files(&path, files)?;
- } else if path
- .extension()
- .is_some_and(|extension| extension == "jsonl")
- {
- files.push(path);
- }
- }
- Ok(())
-}
-
-fn looks_like_rendered_plan_stack(content: &str) -> bool {
- content.contains("## Agent Plan Stack")
- && content.contains(START_MARKER)
- && content.contains(END_MARKER)
-}
-
fn parse_session_metadata(event: &Value) -> SessionMetadata {
let payload = event.get("payload");
let id = payload
@@ -261,17 +220,6 @@ fn task_complete_message_text(event: &Value) -> Option {
(!text.trim().is_empty()).then_some(text.to_owned())
}
-fn session_id_from_path(path: &Path) -> Option {
- path.file_stem()
- .and_then(|stem| stem.to_str())
- .map(ToOwned::to_owned)
-}
-
-fn line_turn_id(path: &Path, line_number: usize) -> Option {
- let stem = path.file_stem()?.to_str()?;
- Some(format!("{stem}:{line_number}"))
-}
-
#[cfg(test)]
mod tests {
use serde_json::json;
diff --git a/src/github.rs b/src/github.rs
index 8e2fe9f..157072e 100644
--- a/src/github.rs
+++ b/src/github.rs
@@ -52,6 +52,28 @@ 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);
};
+
+ sync_to_pull_request(context, state, pull_request)
+}
+
+pub fn sync_state_to_pr(
+ context: &GitContext,
+ state: &mut AgentPlanState,
+ number: u64,
+) -> AppResult {
+ if !state.has_current_branch_items() {
+ return Ok(SyncStatus::NoItems);
+ }
+
+ let pull_request = view_pr(&context.repo_root, number)?;
+ sync_to_pull_request(context, state, pull_request)
+}
+
+fn sync_to_pull_request(
+ context: &GitContext,
+ state: &mut AgentPlanState,
+ pull_request: PullRequest,
+) -> AppResult {
if !pull_request.state.eq_ignore_ascii_case("OPEN") {
return Ok(SyncStatus::ClosedPullRequest {
number: pull_request.number,
@@ -103,6 +125,22 @@ fn view_current_pr(repo_root: &Path) -> AppResult