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
63 changes: 56 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,41 @@

`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 `<proposed_plan>...</proposed_plan>`, `<proposed_plan title="...">...</proposed_plan>`, 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.

## CLI

```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/<repo-key>/.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

Expand All @@ -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
Expand All @@ -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 `<proposed_plan>...</proposed_plan>`, `<proposed_plan title="...">...</proposed_plan>`, 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 `<proposed_plan>...</proposed_plan>`, `<proposed_plan title="...">...</proposed_plan>`, `## 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.
8 changes: 8 additions & 0 deletions changelog.d/20260611_000000_claude_code_plan_capture.md
Original file line number Diff line number Diff line change
@@ -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 <number>` for source-agnostic syncing of queued Codex, Claude Code, and other supported agent plans to an explicit pull request.
202 changes: 194 additions & 8 deletions src/capture.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -34,11 +38,64 @@ struct CodexHookInput {
last_assistant_message: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ClaudeHookInput {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
transcript_path: Option<PathBuf>,
#[serde(default)]
cwd: Option<PathBuf>,
hook_event_name: String,
#[serde(default)]
prompt: Option<String>,
#[serde(default, alias = "last_agent_message")]
last_assistant_message: Option<String>,
}

pub fn process_codex_hook(input: &str) -> AppResult<HookOutcome> {
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<HookOutcome> {
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<String>,
cwd: Option<PathBuf>,
hook_event_name: String,
turn_id: Option<String>,
prompt: Option<String>,
last_assistant_message: Option<String>,
transcript_path: Option<PathBuf>,
}

fn process_agent_hook(hook_input: &AgentHookInput) -> AppResult<HookOutcome> {
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(),
Expand All @@ -52,10 +109,40 @@ pub fn process_codex_hook(input: &str) -> AppResult<HookOutcome> {

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(),
Expand All @@ -69,11 +156,13 @@ pub fn process_codex_hook(input: &str) -> AppResult<HookOutcome> {
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(),
Expand All @@ -90,7 +179,7 @@ pub fn process_codex_hook(input: &str) -> AppResult<HookOutcome> {
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,
Comment on lines 180 to 183

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t let one agent answer another agent’s pending questions.

Now that Claude and Codex share the same state file, drain_relevant_questions empties the entire queue and UserPromptSubmit stores one decision under the current hook_input.source. A Claude prompt can therefore consume Codex questions, and vice versa, permanently mis-associating persisted decisions. Filter by source at minimum, and ideally by session/turn as well.

Also applies to: 314-323

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/capture.rs` around lines 180 - 183, drain_relevant_questions currently
empties the entire state.pending_questions queue and allows a decision created
under NewDecision { source: hook_input.source, questions } to include questions
from other agents; change the flow so only questions matching the current source
(and preferably session/turn id) are drained/answered: either update
drain_relevant_questions to accept a filter parameter (e.g., source and optional
session_id/turn_id) and return only matching questions while leaving others in
state.pending_questions, or filter state.pending_questions in-place before
calling state.answer_pending_questions so you build NewDecision with questions
that have question.source == hook_input.source (and matching session/turn when
available). Ensure the same change is applied at the other location mentioned
(around lines 314-323) to prevent cross-agent consumption.

answer: prompt.to_owned(),
branch: context.branch.clone(),
Expand Down Expand Up @@ -125,6 +214,103 @@ pub fn process_codex_hook(input: &str) -> AppResult<HookOutcome> {
})
}

fn last_assistant_message_from_transcript(path: &Path) -> Option<String> {
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::<Value>(&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<CapturedPlan> {
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::<Value>(&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<String> {
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<String> {
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::<Vec<_>>()
.join("\n");

(!text.trim().is_empty()).then_some(text)
}

fn claude_plan_mode_plan(event: &Value) -> Option<CapturedPlan> {
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<CapturedPlan> {
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<String> {
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<PendingQuestion>) -> Vec<String> {
let mut questions = Vec::new();
for pending_question in pending_questions.drain(..) {
Expand Down
Loading
Loading