From f60fbe71131854be4c6c1d9fb79abafd2dd6949b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:47:02 +0000 Subject: [PATCH] feat: add claude plan capture and explicit pr sync --- README.md | 63 +- ...0260611_000000_claude_code_plan_capture.md | 8 + src/capture.rs | 202 ++++++- src/claude_history.rs | 567 ++++++++++++++++++ src/codex_history.rs | 68 +-- src/github.rs | 38 ++ src/history.rs | 75 +++ src/lib.rs | 3 + src/main.rs | 89 ++- src/state_path.rs | 64 ++ src/store.rs | 4 + tests/integration/cli.rs | 397 +++++++++++- 12 files changed, 1495 insertions(+), 83 deletions(-) create mode 100644 changelog.d/20260611_000000_claude_code_plan_capture.md create mode 100644 src/claude_history.rs create mode 100644 src/history.rs create mode 100644 src/state_path.rs 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> { Err(AppError::new(format!("gh pr view failed: {stderr}")).into()) } +fn view_pr(repo_root: &Path, number: u64) -> AppResult { + let output = Command::new("gh") + .current_dir(repo_root) + .args(["pr", "view"]) + .arg(number.to_string()) + .args(["--json", "number,state,url,isDraft"]) + .output()?; + + if output.status.success() { + return Ok(serde_json::from_slice(&output.stdout)?); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + Err(AppError::new(format!("gh pr view {number} failed: {stderr}")).into()) +} + fn create_issue_comment(context: &GitContext, number: u64, body: &str) -> AppResult { let repo_slug = context .repo_slug diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..df16611 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,75 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::AppResult; +use crate::pr_body::{END_MARKER, START_MARKER}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryImportOutcome { + 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, +} + +impl HistoryImportOutcome { + #[must_use] + pub const fn new() -> Self { + Self { + files_scanned: 0, + files_matched: 0, + lines_scanned: 0, + parse_errors: 0, + plans_found: 0, + plans_added: 0, + duplicates: 0, + rendered_stacks_skipped: 0, + } + } +} + +impl Default for HistoryImportOutcome { + fn default() -> Self { + Self::new() + } +} + +pub 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(()) +} + +#[must_use] +pub fn looks_like_rendered_plan_stack(content: &str) -> bool { + content.contains("## Agent Plan Stack") + && content.contains(START_MARKER) + && content.contains(END_MARKER) +} + +#[must_use] +pub fn session_id_from_path(path: &Path) -> Option { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(ToOwned::to_owned) +} + +#[must_use] +pub fn line_turn_id(path: &Path, line_number: usize) -> Option { + let stem = path.file_stem()?.to_str()?; + Some(format!("{stem}:{line_number}")) +} diff --git a/src/lib.rs b/src/lib.rs index 17b3357..6b1fb21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,13 @@ pub mod capture; +pub mod claude_history; pub mod codex_history; pub mod error; pub mod git; pub mod github; +pub mod history; pub mod normalize; pub mod pr_body; pub mod redact; pub mod render; +pub mod state_path; pub mod store; diff --git a/src/main.rs b/src/main.rs index 656b2da..9e8ef14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,14 @@ use std::io::{self, Read}; use std::path::PathBuf; use plan_to_git::capture; -use plan_to_git::codex_history::{self, CodexHistoryImportOutcome}; +use plan_to_git::claude_history; +use plan_to_git::codex_history; use plan_to_git::error::{AppError, AppResult}; use plan_to_git::git; use plan_to_git::github::{self, SyncStatus}; +use plan_to_git::history::HistoryImportOutcome; use plan_to_git::render::render_plan_comment; +use plan_to_git::state_path; use plan_to_git::store::{load_state, save_state, STATE_FILE_NAME}; #[derive(Parser, Debug)] @@ -41,8 +44,25 @@ enum Commands { #[arg(long)] no_sync: bool, }, - /// Post newly captured plan items to the current branch pull request. - Sync, + /// Import explicitly marked plans from previous Claude Code transcript files. + #[command(visible_alias = "backfill-claude")] + ImportClaude { + /// Claude config directory. Defaults to `CLAUDE_CONFIG_DIR`, `CLAUDE_HOME`, or `~/.claude`. + #[arg(long)] + claude_home: Option, + /// Scan and report what would be imported without writing or syncing. + #[arg(long)] + dry_run: bool, + /// Save imported plans locally without posting a pull request comment. + #[arg(long)] + no_sync: bool, + }, + /// Post newly captured plan items to the current branch or selected pull request. + Sync { + /// Pull request number to post to instead of auto-detecting the current branch PR. + #[arg(long)] + pr: Option, + }, /// Print the local plan stack JSON. Show, /// Render the local plan stack markdown. @@ -57,6 +77,7 @@ enum Commands { #[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] enum HookSource { Codex, + Claude, } fn main() { @@ -114,7 +135,11 @@ fn run(command: &Commands) -> AppResult<()> { print_sync_status(&sync_status); Ok(()) } - Commands::Sync => { + Commands::ImportClaude { + claude_home, + dry_run, + no_sync, + } => { let (context, state_path) = state_context()?; let mut state = load_state(&state_path)?; state.set_context( @@ -122,11 +147,46 @@ fn run(command: &Commands) -> AppResult<()> { context.branch.clone(), context.head_sha.clone(), ); + + let claude_home = claude_home + .clone() + .or_else(default_claude_home) + .ok_or_else(|| AppError::new("cannot locate Claude config directory"))?; + let outcome = + claude_history::import_claude_history(&claude_home, &context, &mut state)?; + + if !*dry_run && outcome.plans_added > 0 { + save_state(&state_path, &state)?; + } + + print_import_outcome(&outcome, *dry_run); + + if *dry_run || *no_sync || outcome.plans_found == 0 { + return Ok(()); + } + let sync_status = github::sync_state(&context, &mut state)?; save_state(&state_path, &state)?; print_sync_status(&sync_status); Ok(()) } + Commands::Sync { pr } => { + let (context, state_path) = state_context()?; + let mut state = load_state(&state_path)?; + state.set_context( + context.repo_slug.clone(), + context.branch.clone(), + context.head_sha.clone(), + ); + let sync_status = if let Some(pr_number) = pr { + github::sync_state_to_pr(&context, &mut state, *pr_number)? + } else { + github::sync_state(&context, &mut state)? + }; + save_state(&state_path, &state)?; + print_sync_status(&sync_status); + Ok(()) + } Commands::Show => { let (_, state_path) = state_context()?; let state = load_state(&state_path)?; @@ -171,6 +231,16 @@ fn run_hook(source: HookSource) -> AppResult<()> { outcome.sync_status ); } + HookSource::Claude => { + let outcome = capture::process_claude_hook(&input)?; + eprintln!( + "plan-to-git: captured {} plan(s), {} decision(s), {} pending question set(s), sync={:?}", + outcome.captured_plans, + outcome.captured_decisions, + outcome.pending_questions, + outcome.sync_status + ); + } } Ok(()) @@ -179,7 +249,7 @@ fn run_hook(source: HookSource) -> AppResult<()> { fn state_context() -> AppResult<(git::GitContext, std::path::PathBuf)> { let cwd = std::env::current_dir()?; let context = git::discover(&cwd)?; - let state_path = context.repo_root.join(STATE_FILE_NAME); + let state_path = state_path::state_path(&context); Ok((context, state_path)) } @@ -212,7 +282,14 @@ fn default_codex_home() -> Option { .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) } -fn print_import_outcome(outcome: &CodexHistoryImportOutcome, dry_run: bool) { +fn default_claude_home() -> Option { + std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .or_else(|| std::env::var_os("CLAUDE_HOME").map(PathBuf::from)) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) +} + +fn print_import_outcome(outcome: &HistoryImportOutcome, dry_run: bool) { let mode = if dry_run { "dry-run" } else { "import" }; println!( "{mode}: scanned {} file(s), matched {} current repo/branch file(s), found {} plan(s), added {}, skipped {} duplicate(s), skipped {} rendered stack(s), parse errors {}", diff --git a/src/state_path.rs b/src/state_path.rs new file mode 100644 index 0000000..7b7ed04 --- /dev/null +++ b/src/state_path.rs @@ -0,0 +1,64 @@ +use sha2::{Digest, Sha256}; +use std::path::PathBuf; + +use crate::git::GitContext; +use crate::store::STATE_FILE_NAME; + +pub const STATE_DIR_ENV: &str = "PLAN_TO_GIT_STATE_DIR"; +pub const STATE_PATH_ENV: &str = "PLAN_TO_GIT_STATE_PATH"; + +#[must_use] +pub fn state_path(context: &GitContext) -> PathBuf { + if let Some(path) = std::env::var_os(STATE_PATH_ENV) { + return PathBuf::from(path); + } + + let state_dir = std::env::var_os(STATE_DIR_ENV) + .map_or_else(|| std::env::temp_dir().join("plan-to-git"), PathBuf::from); + state_dir.join(repo_key(context)).join(STATE_FILE_NAME) +} + +fn repo_key(context: &GitContext) -> String { + let label = context + .repo_slug + .as_deref() + .or_else(|| context.repo_root.file_name().and_then(|name| name.to_str())) + .unwrap_or("repo"); + let label = sanitize_label(label); + let hash = hex_prefix( + &Sha256::digest(context.repo_root.to_string_lossy().as_bytes()), + 12, + ); + + format!("{label}-{hash}") +} + +fn sanitize_label(label: &str) -> String { + let sanitized = label + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_owned(); + + if sanitized.is_empty() { + "repo".to_owned() + } else { + sanitized + } +} + +fn hex_prefix(bytes: &[u8], length: usize) -> String { + bytes + .iter() + .flat_map(|byte| [byte >> 4, byte & 0x0f]) + .take(length) + .map(|nibble| char::from(b"0123456789abcdef"[usize::from(nibble)])) + .collect() +} diff --git a/src/store.rs b/src/store.rs index 4714d12..d8da2ec 100644 --- a/src/store.rs +++ b/src/store.rs @@ -341,6 +341,10 @@ pub fn load_state(path: &Path) -> AppResult { } pub fn save_state(path: &Path, state: &AgentPlanState) -> AppResult<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(state)?; fs::write(path, format!("{content}\n"))?; Ok(()) diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index aaed595..342fa42 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -3,10 +3,11 @@ mod unix { use std::fs; use std::io::Write; use std::os::unix::fs::PermissionsExt; - use std::path::Path; + use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use plan_to_git::store::STATE_FILE_NAME; + use walkdir::WalkDir; #[test] fn hook_captures_plan_and_handles_missing_pr() { @@ -35,6 +36,7 @@ mod unix { .arg("codex") .current_dir(&repo_dir) .env("PATH", path_with_fake_bin(&bin_dir)) + .env("PLAN_TO_GIT_STATE_PATH", state_path(&repo_dir)) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -55,6 +57,58 @@ mod unix { assert!(state.contains("Capture plan")); } + #[test] + fn hook_writes_state_under_configured_tmp_state_dir() { + 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 state_dir = temp_dir.path().join("plan-to-git-state"); + 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); + + let payload = format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_assistant_message":"\n# Tmp State\n\n- Store outside repo\n" + }}"#, + repo_dir.display() + ); + + let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("hook") + .arg("--source") + .arg("codex") + .current_dir(&repo_dir) + .env("PATH", path_with_fake_bin(&bin_dir)) + .env("PLAN_TO_GIT_STATE_DIR", &state_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn plan-to-git"); + + child + .stdin + .as_mut() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + + let output = child.wait_with_output().expect("wait"); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert!(!repo_dir.join(STATE_FILE_NAME).exists()); + + let state_files = find_state_files(&state_dir); + assert_eq!(state_files.len(), 1); + let state = fs::read_to_string(&state_files[0]).expect("state file"); + assert!(state.contains("Store outside repo")); + } + #[test] fn hook_accepts_current_codex_last_agent_message() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -196,6 +250,178 @@ mod unix { assert!(state.contains("\"pending_questions\": []")); } + #[test] + fn hook_captures_claude_plan_source() { + 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_source( + &repo_dir, + &bin_dir, + "claude", + &format!( + r#"{{ + "session_id":"session", + "transcript_path":"{}", + "cwd":"{}", + "hook_event_name":"Stop", + "last_assistant_message":"\n# Claude Hook\n\n- Capture Claude plan\n" + }}"#, + repo_dir.join("transcript.jsonl").display(), + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("\"source\": \"claude\"")); + assert!(state.contains("Capture Claude plan")); + } + + #[test] + fn hook_captures_claude_plan_from_transcript_path_fallback() { + 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 transcript_path = temp_dir.path().join("transcript.jsonl"); + 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); + + fs::write( + &transcript_path, + format!( + r#"{{"type":"user","sessionId":"session","cwd":"{}","message":{{"role":"user","content":"\n# User Plan\n\n- Do not capture\n"}}}} +{{"type":"assistant","sessionId":"session","cwd":"{}","message":{{"role":"assistant","content":[{{"type":"text","text":"\n# Transcript Claude Hook\n\n- Capture transcript fallback\n"}}]}}}} +"#, + repo_dir.display(), + repo_dir.display() + ), + ) + .expect("write transcript"); + + run_hook_source( + &repo_dir, + &bin_dir, + "claude", + &format!( + r#"{{ + "session_id":"session", + "transcript_path":"{}", + "cwd":"{}", + "hook_event_name":"Stop" + }}"#, + transcript_path.display(), + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("\"source\": \"claude\"")); + assert!(state.contains("Capture transcript fallback")); + assert!(!state.contains("Do not capture")); + } + + #[test] + fn hook_captures_claude_plan_mode_plan_from_transcript_path() { + 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 transcript_path = temp_dir.path().join("transcript.jsonl"); + let plan_path = temp_dir.path().join("plans/native-plan.md"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + fs::create_dir_all(plan_path.parent().expect("plan parent")).expect("plan dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_no_pr(&bin_dir); + + fs::write( + &transcript_path, + format!( + r##"{{"type":"assistant","sessionId":"session","cwd":"{}","message":{{"role":"assistant","content":[{{"type":"text","text":"No marked plan here."}}]}}}} +{{"type":"user","uuid":"plan-turn","sessionId":"session","cwd":"{}","gitBranch":"feature/test","timestamp":"2026-06-11T07:04:40Z","toolUseResult":{{"plan":"# Plan: Claude Plan Mode\n\n- Capture native plan mode","filePath":"{}"}}}} +"##, + repo_dir.display(), + repo_dir.display(), + plan_path.display() + ), + ) + .expect("write transcript"); + + run_hook_source( + &repo_dir, + &bin_dir, + "claude", + &format!( + r#"{{ + "session_id":"session", + "transcript_path":"{}", + "cwd":"{}", + "hook_event_name":"Stop" + }}"#, + transcript_path.display(), + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("\"source\": \"claude\"")); + assert!(state.contains("Claude Plan Mode")); + assert!(state.contains("Capture native plan mode")); + } + + #[test] + fn hook_records_claude_question_answer_decision() { + 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_source( + &repo_dir, + &bin_dir, + "claude", + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "last_assistant_message":"Should Claude sync plans?" + }}"#, + repo_dir.display() + ), + ); + + run_hook_source( + &repo_dir, + &bin_dir, + "claude", + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"UserPromptSubmit", + "prompt":"Yes, capture Claude planning decisions." + }}"#, + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("\"source\": \"claude\"")); + assert!(state.contains("Should Claude sync plans?")); + assert!(state.contains("Yes, capture Claude planning decisions.")); + assert!(state.contains("\"pending_questions\": []")); + } + #[test] fn hook_posts_open_pr_comment_through_gh_api() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -232,6 +458,73 @@ mod unix { assert!(state.contains("\"comment_id\": 12345")); } + #[test] + fn sync_explicit_pr_posts_unposted_items_from_all_sources() { + 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_no_pr(&bin_dir); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"codex-session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"codex-turn", + "last_assistant_message":"\n# Codex Plan\n\n- Sync Codex item\n" + }}"#, + repo_dir.display() + ), + ); + + run_hook_source( + &repo_dir, + &bin_dir, + "claude", + &format!( + r#"{{ + "session_id":"claude-session", + "cwd":"{}", + "hook_event_name":"Stop", + "last_assistant_message":"\n# Claude Plan\n\n- Sync Claude item\n" + }}"#, + repo_dir.display() + ), + ); + + write_fake_gh_explicit_open_pr(&bin_dir, &captured_request); + + let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("sync") + .arg("--pr") + .arg("17") + .current_dir(&repo_dir) + .env("PATH", path_with_fake_bin(&bin_dir)) + .env("PLAN_TO_GIT_STATE_PATH", state_path(&repo_dir)) + .output() + .expect("run sync"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("posted 2 plan item(s) to pull request #17 comment #12345")); + + let request = fs::read_to_string(captured_request).expect("captured request"); + assert!(request.contains("Source: codex")); + assert!(request.contains("Sync Codex item")); + assert!(request.contains("Source: claude")); + assert!(request.contains("Sync Claude item")); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("\"comment_id\": 12345")); + } + #[test] fn hook_leaves_plans_queued_when_pr_is_merged() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -358,6 +651,7 @@ mod unix { .arg("sync") .current_dir(&repo_dir) .env("PATH", path_with_fake_bin(&bin_dir)) + .env("PLAN_TO_GIT_STATE_PATH", state_path(&repo_dir)) .output() .expect("run sync"); @@ -397,6 +691,7 @@ mod unix { .arg("sync") .current_dir(&repo_dir) .env("PATH", path_with_fake_bin(&bin_dir)) + .env("PLAN_TO_GIT_STATE_PATH", state_path(&repo_dir)) .output() .expect("run sync"); @@ -439,13 +734,56 @@ mod unix { assert!(second.contains("skipped 1 duplicate(s)")); } + #[test] + fn import_claude_backfills_history_once_from_config_dir_without_syncing() { + 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 claude_home = temp_dir.path().join("claude"); + let session_dir = claude_home.join("projects/-tmp-repo"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + fs::create_dir_all(&session_dir).expect("session dir"); + write_fake_git(&bin_dir, &repo_dir); + + fs::write( + session_dir.join("session.jsonl"), + format!( + r#"{{"type":"user","uuid":"user","sessionId":"session","cwd":"{}","gitBranch":"feature/test","message":{{"role":"user","content":"ignore user text"}}}} +{{"type":"assistant","uuid":"turn-1","sessionId":"session","cwd":"{}","gitBranch":"feature/test","timestamp":"2026-06-11T12:34:56Z","message":{{"role":"assistant","content":[{{"type":"text","text":"\n# Claude Archived Plan\n\n- Import Claude archived plan\n"}}]}}}} +{{"type":"assistant","uuid":"turn-2","sessionId":"session","cwd":"{}","gitBranch":"feature/other","message":{{"role":"assistant","content":[{{"type":"text","text":"\n# Wrong Branch\n\n- Do not import\n"}}]}}}} +"#, + repo_dir.display(), + repo_dir.display(), + repo_dir.display() + ), + ) + .expect("write session"); + + let first = run_import_claude_from_default(&repo_dir, &bin_dir, &claude_home); + assert!(first.contains("found 1 plan(s), added 1")); + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("\"source\": \"claude\"")); + assert!(state.contains("Import Claude archived plan")); + assert!(!state.contains("Do not import")); + + let second = run_import_claude_from_default(&repo_dir, &bin_dir, &claude_home); + assert!(second.contains("found 1 plan(s), added 0")); + assert!(second.contains("skipped 1 duplicate(s)")); + } + fn run_hook(repo_dir: &Path, bin_dir: &Path, payload: &str) { + run_hook_source(repo_dir, bin_dir, "codex", payload); + } + + fn run_hook_source(repo_dir: &Path, bin_dir: &Path, source: &str, payload: &str) { let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) .arg("hook") .arg("--source") - .arg("codex") + .arg(source) .current_dir(repo_dir) .env("PATH", path_with_fake_bin(bin_dir)) + .env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -471,6 +809,7 @@ mod unix { .arg("--no-sync") .current_dir(repo_dir) .env("PATH", path_with_fake_bin(bin_dir)) + .env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)) .output() .expect("run import-codex"); @@ -478,6 +817,39 @@ mod unix { String::from_utf8(output.stdout).expect("stdout") } + fn run_import_claude_from_default( + repo_dir: &Path, + bin_dir: &Path, + claude_home: &Path, + ) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("import-claude") + .arg("--no-sync") + .current_dir(repo_dir) + .env("PATH", path_with_fake_bin(bin_dir)) + .env("CLAUDE_CONFIG_DIR", claude_home) + .env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)) + .output() + .expect("run import-claude"); + + assert!(output.status.success()); + String::from_utf8(output.stdout).expect("stdout") + } + + fn state_path(repo_dir: &Path) -> PathBuf { + repo_dir.join(STATE_FILE_NAME) + } + + fn find_state_files(dir: &Path) -> Vec { + WalkDir::new(dir) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .map(walkdir::DirEntry::into_path) + .filter(|path| path.file_name().and_then(|name| name.to_str()) == Some(STATE_FILE_NAME)) + .collect() + } + fn write_fake_git(bin_dir: &Path, repo_dir: &Path) { let script = format!( r#"#!/usr/bin/env bash @@ -543,6 +915,27 @@ exit 1 write_executable(&bin_dir.join("gh"), &script); } + fn write_fake_gh_explicit_open_pr(bin_dir: &Path, captured_request: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view 17 --json number,state,url,isDraft" ]]; 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 + cp "$6" "{}" + printf '%s\n' '{{"id":12345}}' + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#, + captured_request.display() + ); + 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