Skip to content

feat: add Claude plan capture and explicit PR sync#8

Merged
skulidropek merged 1 commit into
mainfrom
feat/claude-plan-sync-pr
Jun 11, 2026
Merged

feat: add Claude plan capture and explicit PR sync#8
skulidropek merged 1 commit into
mainfrom
feat/claude-plan-sync-pr

Conversation

@skulidropek

@skulidropek skulidropek commented Jun 11, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds Claude Code hook capture, import-claude, and Claude Plan Mode transcript backfill.
  • Moves default state storage under /tmp/plan-to-git/<repo-key>/.agent-plan.json with env overrides.
  • Adds plan-to-git sync --pr <number> so callers can sync queued plans to an explicit PR when branch discovery is not enough.
  • Keeps sync source-agnostic: Codex, Claude Code, and future supported agents share the same queued state.

Dependency for ProverCoderAI/docker-git#398.

Verification

  • cargo fmt --check
  • cargo test
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo build

@skulidropek

skulidropek commented Jun 11, 2026

Copy link
Copy Markdown
Member Author

AI Session Backup

Commit: f60fbe7
Status: success
Files: 12 (6.46 MB)
Links: README | Manifest

git status

On branch feat/claude-plan-sync-pr
Your branch is up to date with 'origin/feat/claude-plan-sync-pr'.

nothing to commit, working tree clean

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR extends plan-to-git to support Claude Code hooks, external state storage, and PR-targeted syncing. It adds Claude transcript parsing and backfill, refactors shared history infrastructure, and introduces environment-variable configuration for state file paths.

Changes

Claude Code Hook Support and External State Management

Layer / File(s) Summary
State path infrastructure for external storage
src/state_path.rs, src/store.rs
Environment variables PLAN_TO_GIT_STATE_PATH and PLAN_TO_GIT_STATE_DIR control where hook state is persisted, with per-repo key derivation using sanitized slugs and SHA-256 hash prefixes.
Shared history import infrastructure
src/history.rs, src/codex_history.rs
Extracted HistoryImportOutcome and helpers (collect_jsonl_files, looks_like_rendered_plan_stack, session/turn ID derivation) for reuse across Codex and Claude importers.
Claude hook payload ingestion and transcript fallback
src/capture.rs
New process_claude_hook entry point deserializes Claude payloads; message extraction uses transcript when payload is absent; plan-mode fallback recovery attempted when Stop event finds no marked plans.
Claude transcript parsing and history import
src/claude_history.rs
Full import workflow for Claude project JSONL files: scans events, filters by repository context, extracts marked assistant plans and plan-mode artifacts, deduplicates, and records import metrics.
PR-targeted sync helper
src/github.rs
New sync_state_to_pr(context, state, number) function centralizes sync logic via shared sync_to_pull_request, enabling explicit PR number targeting.
CLI command and main flow integration
src/main.rs, src/lib.rs
Adds ImportClaude subcommand with --dry-run and --no-sync options; updates Sync to accept optional --pr number; wires state-path resolution and default Claude home detection.
Module exports
src/lib.rs
Publicly exports state_path, history, and claude_history modules.
Documentation and changelog
README.md, changelog.d/*
Describes Codex+Claude MVP, external state storage with environment variables, hook JSON examples, PR-targeted sync, and safety considerations for both sources.
Integration tests
tests/integration/cli.rs
Tests for state directory behavior, Claude hook processing, transcript parsing, PR-targeted sync, history backfill, and cross-source plan consolidation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • The changes directly implement Claude Code hook support and external state directory management as requested, including PLAN_TO_GIT_STATE_* environment variables and sync --pr <number> functionality.

Possibly related PRs

  • ProverCoderAI/plan-to-git#3: Both PRs modify GitHub sync flow and de-duplication logic around posting plan items as PR comments; this PR extends that machinery with explicit PR targeting.

Poem

🐰 Claude whispers plans in JSON lines,
State hops out to temp, by design,
Marked or mode, transcripts unfold,
Posts to a PR, stories retold! 🎯

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive No pull request description was provided by the author. This is a very lenient check that should pass as long as the description is not completely off-topic, but an empty description cannot be evaluated. Add a pull request description explaining the changes, objectives, and any relevant context for reviewers.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title 'feat: add Claude plan capture and explicit PR sync' directly and clearly summarizes the two main features added: Claude plan capture functionality and explicit pull-request sync mechanism.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/claude-plan-sync-pr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/capture.rs (1)

119-161: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track “plan found” separately from “plan added.”

captured_plans only increments when state.add_plan(...) returns true. On a duplicate Stop event, a marked plan still looks like “no plan captured”, so this branch falls through into Claude transcript plan-mode recovery and then question extraction. That makes hook retries non-idempotent and can persist the wrong follow-up state.

Suggested fix
-            if let Some(message) = message.as_deref() {
+            let mut found_plan = false;
+
+            if let Some(message) = message.as_deref() {
                 for plan in extract_marked_plans(message) {
+                    found_plan = true;
                     let added = state.add_plan(NewPlanItem {
                         source: hook_input.source,
                         title: plan.title,
@@
-            if hook_input.source == AgentSource::Claude && captured_plans == 0 {
+            if hook_input.source == AgentSource::Claude && !found_plan {
                 if let Some(plan) = hook_input
                     .transcript_path
                     .as_deref()
                     .and_then(last_claude_plan_mode_plan_from_transcript)
                 {
+                    found_plan = true;
                     let added = state.add_plan(NewPlanItem {
                         source: hook_input.source,
                         title: plan.title,
@@
-            if captured_plans == 0 {
+            if !found_plan {
                 if let Some(message) = message.as_deref() {
🤖 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 119 - 161, The code currently only increments
captured_plans when state.add_plan(...) returns true, conflating "plan found in
input" with "plan actually added", which lets duplicate Stop events bypass
idempotent logic; update the logic to track plan_found (or similar) separately
from add_result: set plan_found = true whenever extract_marked_plans(message)
yields a plan or when last_claude_plan_mode_plan_from_transcript(...) returns
Some, then still call state.add_plan(NewPlanItem { ... }) and increment
captured_plans or changed only if add_plan returned true; use plan_found (not
captured_plans) to decide whether to skip Claude transcript recovery and
question extraction so retries remain idempotent.
src/codex_history.rs (1)

59-66: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Count files_matched once per session file.

files_matched is now part of the shared import outcome, but this branch increments it for every matching session_meta record. That can overcount a single file and diverge from the Claude importer’s first-match-only behavior.

Suggested fix
         if event.get("type").and_then(Value::as_str) == Some("session_meta") {
             metadata = Some(parse_session_metadata(&event));
-            file_matches = metadata
+            let matches = metadata
                 .as_ref()
                 .is_some_and(|session| session_matches_context(session, context));
-            if file_matches {
+            if matches && !file_matches {
                 outcome.files_matched += 1;
             }
+            file_matches = matches;
             continue;
         }
🤖 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/codex_history.rs` around lines 59 - 66, The current branch increments
outcome.files_matched for every matching session_meta record, causing duplicate
counts per session file; change the logic in the session_meta handling (around
metadata, parse_session_metadata, session_matches_context and file_matches) to
only increment outcome.files_matched when a session file transitions from
not-matched to matched (i.e., detect the previous file_matches state and only
increment when previous was false and current file_matches becomes true) so each
session file is counted at most once.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/capture.rs`:
- Around line 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.

In `@src/claude_history.rs`:
- Around line 241-248: The function claude_plan_mode_file currently uses
Path::starts_with (lexical) on the input path which can be fooled by .. segments
or symlinks; instead canonicalize both the input path and claude_home (using
fs::canonicalize) and only proceed if the canonicalized target starts_with the
canonicalized claude_home; on any canonicalization error return None, and then
read the file contents from the canonicalized path and pass them to
captured_plan_from_markdown (update references to path and claude_home in
claude_plan_mode_file accordingly).

In `@src/history.rs`:
- Around line 58-61: The current looks_like_rendered_plan_stack function returns
true if the content merely contains the three substrings, which can
false-positive on texts that mention those literals; change it to verify
ordering and proximity: locate the index of "## Agent Plan Stack" first, then
find START_MARKER after that index and END_MARKER after the START_MARKER index
(and optionally require a minimum separation or newline boundaries) so that
START_MARKER occurs after the header and END_MARKER occurs after START_MARKER;
update looks_like_rendered_plan_stack to use substring/index checks (using the
START_MARKER and END_MARKER symbols) rather than simple contains().

---

Outside diff comments:
In `@src/capture.rs`:
- Around line 119-161: The code currently only increments captured_plans when
state.add_plan(...) returns true, conflating "plan found in input" with "plan
actually added", which lets duplicate Stop events bypass idempotent logic;
update the logic to track plan_found (or similar) separately from add_result:
set plan_found = true whenever extract_marked_plans(message) yields a plan or
when last_claude_plan_mode_plan_from_transcript(...) returns Some, then still
call state.add_plan(NewPlanItem { ... }) and increment captured_plans or changed
only if add_plan returned true; use plan_found (not captured_plans) to decide
whether to skip Claude transcript recovery and question extraction so retries
remain idempotent.

In `@src/codex_history.rs`:
- Around line 59-66: The current branch increments outcome.files_matched for
every matching session_meta record, causing duplicate counts per session file;
change the logic in the session_meta handling (around metadata,
parse_session_metadata, session_matches_context and file_matches) to only
increment outcome.files_matched when a session file transitions from not-matched
to matched (i.e., detect the previous file_matches state and only increment when
previous was false and current file_matches becomes true) so each session file
is counted at most once.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 01a88f37-222c-4caa-bf2a-b7c26b2146cf

📥 Commits

Reviewing files that changed from the base of the PR and between d866533 and f60fbe7.

📒 Files selected for processing (12)
  • README.md
  • changelog.d/20260611_000000_claude_code_plan_capture.md
  • src/capture.rs
  • src/claude_history.rs
  • src/codex_history.rs
  • src/github.rs
  • src/history.rs
  • src/lib.rs
  • src/main.rs
  • src/state_path.rs
  • src/store.rs
  • tests/integration/cli.rs

Comment thread src/capture.rs
Comment on lines 180 to 183
let questions = drain_relevant_questions(&mut state.pending_questions);
if state.answer_pending_questions(NewDecision {
source: AgentSource::Codex,
source: hook_input.source,
questions,

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.

Comment thread src/claude_history.rs
Comment on lines +241 to +248
fn claude_plan_mode_file(path: &Path, claude_home: &Path) -> Option<CapturedPlan> {
if !path.starts_with(claude_home) {
return None;
}

fs::read_to_string(path)
.ok()
.and_then(|content| captured_plan_from_markdown(&content))

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

Canonicalize the artifact path before reading it.

Path::starts_with(claude_home) is only a lexical check. A crafted transcript can use .../.claude/../... segments or a symlink under claude_home to make import-claude read arbitrary local files, which then get persisted and potentially synced to GitHub. Resolve both paths first and verify the canonicalized target stays under the canonicalized Claude home.

Suggested fix
 fn claude_plan_mode_file(path: &Path, claude_home: &Path) -> Option<CapturedPlan> {
-    if !path.starts_with(claude_home) {
+    let canonical_home = claude_home.canonicalize().ok()?;
+    let canonical_path = path.canonicalize().ok()?;
+
+    if !canonical_path.starts_with(&canonical_home) {
         return None;
     }
 
-    fs::read_to_string(path)
+    fs::read_to_string(canonical_path)
         .ok()
         .and_then(|content| captured_plan_from_markdown(&content))
 }
🤖 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/claude_history.rs` around lines 241 - 248, The function
claude_plan_mode_file currently uses Path::starts_with (lexical) on the input
path which can be fooled by .. segments or symlinks; instead canonicalize both
the input path and claude_home (using fs::canonicalize) and only proceed if the
canonicalized target starts_with the canonicalized claude_home; on any
canonicalization error return None, and then read the file contents from the
canonicalized path and pass them to captured_plan_from_markdown (update
references to path and claude_home in claude_plan_mode_file accordingly).

Comment thread src/history.rs
Comment on lines +58 to +61
pub fn looks_like_rendered_plan_stack(content: &str) -> bool {
content.contains("## Agent Plan Stack")
&& content.contains(START_MARKER)
&& content.contains(END_MARKER)

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

Tighten rendered-stack detection before skipping imports.

This helper skips any plan that merely mentions the stack header plus the start/end markers, so a legitimate plan describing those literals will be dropped by both importers.

Suggested fix
 pub fn looks_like_rendered_plan_stack(content: &str) -> bool {
-    content.contains("## Agent Plan Stack")
-        && content.contains(START_MARKER)
-        && content.contains(END_MARKER)
+    let trimmed = content.trim();
+    trimmed.starts_with(START_MARKER)
+        && trimmed.contains("\n## Agent Plan Stack")
+        && trimmed.ends_with(END_MARKER)
 }
🤖 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/history.rs` around lines 58 - 61, The current
looks_like_rendered_plan_stack function returns true if the content merely
contains the three substrings, which can false-positive on texts that mention
those literals; change it to verify ordering and proximity: locate the index of
"## Agent Plan Stack" first, then find START_MARKER after that index and
END_MARKER after the START_MARKER index (and optionally require a minimum
separation or newline boundaries) so that START_MARKER occurs after the header
and END_MARKER occurs after START_MARKER; update looks_like_rendered_plan_stack
to use substring/index checks (using the START_MARKER and END_MARKER symbols)
rather than simple contains().

@skulidropek skulidropek changed the title feat: add claude plan capture and explicit pr sync feat: add Claude plan capture and explicit PR sync Jun 11, 2026
@skulidropek skulidropek merged commit 276b827 into main Jun 11, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant