From b2d3c028b5e7b8388c9fcf35df1b6cba6eac79c6 Mon Sep 17 00:00:00 2001
From: skulidropek <66840575+skulidropek@users.noreply.github.com>
Date: Sun, 31 May 2026 13:37:48 +0000
Subject: [PATCH 01/11] feat: add plan-to-git CLI
---
.gitignore | 1 +
Cargo.lock | 685 ++++++++++++++++--
Cargo.toml | 32 +-
README.md | 345 +--------
.../20260530_104500_plan_to_git_mvp.md | 6 +
examples/basic_usage.rs | 23 +-
src/capture.rs | 134 ++++
src/error.rs | 26 +
src/git.rs | 66 ++
src/github.rs | 106 +++
src/lib.rs | 12 +-
src/main.rs | 142 +++-
src/normalize.rs | 159 ++++
src/pr_body.rs | 33 +
src/redact.rs | 27 +
src/render.rs | 88 +++
src/store.rs | 337 +++++++++
src/sum.rs | 4 -
tests/integration/cli.rs | 186 +++++
tests/integration/mod.rs | 2 +-
tests/integration/sum.rs | 36 -
tests/unit/mod.rs | 2 +-
tests/unit/plan_capture.rs | 209 ++++++
tests/unit/sum.rs | 26 -
24 files changed, 2232 insertions(+), 455 deletions(-)
create mode 100644 changelog.d/20260530_104500_plan_to_git_mvp.md
create mode 100644 src/capture.rs
create mode 100644 src/error.rs
create mode 100644 src/git.rs
create mode 100644 src/github.rs
create mode 100644 src/normalize.rs
create mode 100644 src/pr_body.rs
create mode 100644 src/redact.rs
create mode 100644 src/render.rs
create mode 100644 src/store.rs
delete mode 100644 src/sum.rs
create mode 100644 tests/integration/cli.rs
delete mode 100644 tests/integration/sum.rs
create mode 100644 tests/unit/plan_capture.rs
delete mode 100644 tests/unit/sum.rs
diff --git a/.gitignore b/.gitignore
index 2863516..ef84cf0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,7 @@ doc/
.env
.env.local
*.local
+.agent-plan.json
# Log files
*.log
diff --git a/Cargo.lock b/Cargo.lock
index 68fcef1..21d5e9d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,6 +11,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "anstream"
version = "0.6.21"
@@ -61,6 +70,67 @@ dependencies = [
"windows-sys",
]
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
[[package]]
name = "clap"
version = "4.5.60"
@@ -108,58 +178,184 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
-name = "ctor"
-version = "0.4.3"
+name = "core-foundation-sys"
+version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
- "ctor-proc-macro",
- "dtor",
+ "libc",
]
[[package]]
-name = "ctor-proc-macro"
-version = "0.0.6"
+name = "crypto-common"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
[[package]]
-name = "dotenvy"
-version = "0.15.7"
+name = "equivalent"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
-name = "dtor"
-version = "0.0.6"
+name = "errno"
+version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
- "dtor-proc-macro",
+ "libc",
+ "windows-sys",
]
[[package]]
-name = "dtor-proc-macro"
-version = "0.0.5"
+name = "fastrand"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
-name = "example-sum-package-name"
-version = "0.16.0"
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
- "clap",
- "lino-arguments",
- "regex",
- "walkdir",
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
]
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -167,24 +363,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
-name = "lino-arguments"
-version = "0.3.0"
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be512a5c5eacea6ef5ec015fb0c7e1725c8e4cda1befd31606e203f281069968"
+checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
- "clap",
- "ctor",
- "dotenvy",
- "lino-env",
- "serde",
- "thiserror",
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
]
[[package]]
-name = "lino-env"
+name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f453c53827aabe91a3d3856d61d14ae3867ab1a4344db22f9fa5396664c8d0e"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "memchr"
@@ -192,12 +410,57 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "plan-to-git"
+version = "0.16.0"
+dependencies = [
+ "chrono",
+ "clap",
+ "regex",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "walkdir",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.103"
@@ -216,6 +479,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "regex"
version = "1.12.3"
@@ -245,6 +514,25 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -254,6 +542,12 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -284,6 +578,42 @@ dependencies = [
"syn",
]
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -302,24 +632,23 @@ dependencies = [
]
[[package]]
-name = "thiserror"
-version = "1.0.69"
+name = "tempfile"
+version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
- "thiserror-impl",
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
]
[[package]]
-name = "thiserror-impl"
-version = "1.0.69"
+name = "typenum"
+version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
@@ -327,12 +656,24 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -343,6 +684,103 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -352,12 +790,65 @@ dependencies = [
"windows-sys",
]
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -366,3 +857,103 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
index 954bffb..3b062d3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,15 +1,15 @@
[package]
-name = "example-sum-package-name"
+name = "plan-to-git"
version = "0.16.0"
edition = "2021"
-description = "A Rust package template for AI-driven development"
+description = "Capture agent plans and sync them to GitHub pull requests"
readme = "README.md"
license = "Unlicense"
-keywords = ["template", "rust", "ai-driven"]
-categories = ["development-tools"]
-repository = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template"
-documentation = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template"
-rust-version = "1.70"
+keywords = ["agents", "github", "pull-request", "codex"]
+categories = ["command-line-utilities", "development-tools"]
+repository = "https://github.com/ProverCoderAI/plan-to-git"
+documentation = "https://github.com/ProverCoderAI/plan-to-git"
+rust-version = "1.85"
# Narrow allowlist of files shipped in the published `.crate` archive.
# Keeping this list tight prevents docs, case studies, generated CI artifacts,
@@ -18,25 +18,29 @@ rust-version = "1.70"
include = [
"src/**/*.rs",
"examples/**/*.rs",
- "README.md",
- "LICENSE",
- "CHANGELOG.md",
+ "/README.md",
+ "/LICENSE",
+ "/CHANGELOG.md",
]
[lib]
-name = "example_sum_package_name"
+name = "plan_to_git"
path = "src/lib.rs"
[[bin]]
-name = "example-sum-package-name"
+name = "plan-to-git"
path = "src/main.rs"
[dependencies]
-lino-arguments = "0.3"
clap = { version = "4.4", features = ["derive", "env"] }
+chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
+regex = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+sha2 = "0.10"
[dev-dependencies]
-regex = "1"
+tempfile = "3"
walkdir = "2"
[lints.rust]
diff --git a/README.md b/README.md
index 2e615f5..4b125bc 100644
--- a/README.md
+++ b/README.md
@@ -1,332 +1,59 @@
-# rust-ai-driven-development-pipeline-template
+# plan-to-git
-A comprehensive template for AI-driven Rust development with full CI/CD pipeline support.
+`plan-to-git` captures explicit plans produced by coding agents and keeps the current pull request description in sync.
-[](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/actions?workflow=CI%2FCD+Pipeline)
-[](https://crates.io/crates/example-sum-package-name)
-[](https://docs.rs/example-sum-package-name)
-[](https://www.rust-lang.org/)
-[](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template)
-[](http://unlicense.org/)
+The MVP is Codex-first:
-## Features
+- reads Codex 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`;
+- updates the current branch PR body between stable markers when a PR exists;
+- leaves the local stack queued when no PR exists yet.
-- **Rust stable support**: Works with Rust stable version
-- **Cross-platform testing**: CI runs on Ubuntu, macOS, and Windows
-- **Comprehensive testing**: Unit tests, integration tests, and doc tests
-- **Code quality**: rustfmt + Clippy with pedantic lints
-- **Pre-commit hooks**: Automated code quality checks before commits
-- **CI/CD pipeline**: GitHub Actions with multi-platform support
-- **Changelog management**: Fragment-based changelog (like Changesets/Scriv)
-- **Code coverage**: Automated coverage reports with cargo-llvm-cov and Codecov
-- **Release automation**: Automatic GitHub releases, crates.io publishing, and optional Docker Hub image publishing
-- **Template-safe defaults**: CI/CD skips publishing when package name is `example-sum-package-name`
-
-## Quick Start
-
-### Using This Template
-
-1. Click "Use this template" on GitHub to create a new repository
-2. Clone your new repository
-3. Update `Cargo.toml`:
- - Change `name` from `example-sum-package-name` to your package name
- - Update `description`, `repository`, and `documentation` URLs
- - Update `[lib]` name and `[[bin]]` name
-4. Update imports in `src/main.rs`, `tests/`, and `examples/`
-5. Build and start developing!
-
-### Development Setup
+## CLI
```bash
-# Clone the repository
-git clone https://github.com/link-foundation/rust-ai-driven-development-pipeline-template.git
-cd rust-ai-driven-development-pipeline-template
-
-# Build the project
-cargo build
-
-# Run tests
-cargo test
-
-# Run the CLI binary
-cargo run -- --a 3 --b 7
-
-# Run an example
-cargo run --example basic_usage
+plan-to-git hook --source codex < hook-payload.json
+plan-to-git show
+plan-to-git render
+plan-to-git sync
+plan-to-git clear --yes
```
-### Running Tests
-
-```bash
-# Run all tests
-cargo test
+`hook` is intentionally quiet on stdout, because Codex hook stdout is interpreted by Codex. Operational messages go to stderr.
-# Run tests with verbose output
-cargo test --verbose
+## Codex Hook Example
-# Run doc tests
-cargo test --doc
+Add the command to Codex hook configuration for `Stop` and `UserPromptSubmit` events:
-# Run a specific test
-cargo test test_sum_positive_numbers
+```toml
+[[hooks.UserPromptSubmit]]
+[[hooks.UserPromptSubmit.hooks]]
+type = "command"
+command = "plan-to-git hook --source codex"
-# Run tests with output
-cargo test -- --nocapture
+[[hooks.Stop]]
+[[hooks.Stop.hooks]]
+type = "command"
+command = "plan-to-git hook --source codex"
```
-CI caps each test-matrix job at 10 minutes. `cargo test` does not provide a portable global per-test timeout, so long-running network, IO, or async tests should use explicit test-level timeouts. Repositories that adopt `cargo nextest` can configure runner deadlines with options such as `--slow-timeout` and `--leak-timeout`.
-
-### Code Quality Checks
-
-```bash
-# Format code
-cargo fmt
-
-# Check formatting (CI style)
-cargo fmt --check
-
-# Run Clippy lints
-cargo clippy --all-targets --all-features
-
-# Check file size limits (requires rust-script: cargo install rust-script)
-rust-script scripts/check-file-size.rs
-
-# Check the packaged crate stays under the crates.io 10 MiB upload limit
-rust-script scripts/check-crate-size.rs
-
-# Run all checks
-cargo fmt --check && cargo clippy --all-targets --all-features && rust-script scripts/check-file-size.rs
-```
+Exact hook configuration shape can vary by Codex release. The hook command itself expects the release behavior documented by Codex hooks: `Stop` includes `last_assistant_message`, and `UserPromptSubmit` includes `prompt`.
-## Project Structure
+## Pull Request Block
-```
-.
-├── .github/
-│ └── workflows/
-│ └── release.yml # CI/CD pipeline configuration
-├── changelog.d/ # Changelog fragments
-│ ├── README.md # Fragment instructions
-│ └── *.md # Individual changelog entries
-├── examples/
-│ └── basic_usage.rs # Usage examples
-├── experiments/ # Experiment and debug scripts
-│ ├── test-changelog-parsing.rs # Changelog parsing validation
-│ └── test-crates-io-check.rs # Crates.io version check validation
-├── scripts/ # Rust scripts (via rust-script)
-│ ├── bump-version.rs # Version bumping utility
-│ ├── check-changelog-fragment.rs # Changelog fragment validation
-│ ├── check-crate-size.rs # Crate archive size guard (crates.io 10 MiB limit)
-│ ├── check-file-size.rs # File size validation script
-│ ├── check-release-needed.rs # Release necessity check
-│ ├── check-version-modification.rs # Version modification detection
-│ ├── collect-changelog.rs # Changelog collection script
-│ ├── create-changelog-fragment.rs # Changelog fragment creation
-│ ├── create-github-release.rs # GitHub release creation
-│ ├── detect-code-changes.rs # Code change detection for CI
-│ ├── get-bump-type.rs # Version bump type determination
-│ ├── get-version.rs # Version extraction from Cargo.toml
-│ ├── git-config.rs # Git configuration for CI
-│ ├── publish-crate.rs # Crates.io publishing
-│ ├── rust-paths.rs # Rust root path detection
-│ ├── version-and-commit.rs # CI/CD version management
-│ └── wait-for-crate.rs # Crates.io availability wait before image publishing
-├── src/
-│ ├── lib.rs # Library entry point
-│ ├── main.rs # CLI binary (uses lino-arguments)
-│ └── sum.rs # Sum function module
-├── tests/
-│ ├── unit_tests.rs # Unit test entry point
-│ ├── unit/
-│ │ ├── mod.rs
-│ │ ├── sum.rs # Unit tests for sum function
-│ │ └── ci-cd/
-│ │ ├── mod.rs
-│ │ └── changelog_parsing.rs # CI/CD changelog parsing tests
-│ ├── integration_tests.rs # Integration test entry point
-│ └── integration/
-│ ├── mod.rs
-│ └── sum.rs # CLI integration tests
-├── .gitignore # Git ignore patterns
-├── .pre-commit-config.yaml # Pre-commit hooks configuration
-├── Cargo.toml # Project configuration
-├── CHANGELOG.md # Project changelog
-├── CONTRIBUTING.md # Contribution guidelines
-├── LICENSE # Unlicense (public domain)
-└── README.md # This file
-```
-
-## Design Choices
-
-### Example Application
-
-The template includes a simple CLI sum application using [lino-arguments](https://github.com/link-foundation/lino-arguments) (a drop-in replacement for clap that also supports `.lenv` and `.env` files). This demonstrates:
-
-- Library module (`src/sum.rs`) with a pure function
-- CLI binary (`src/main.rs`) using `lino-arguments` for argument parsing
-- Unit tests (`tests/unit/sum.rs`) testing the function directly
-- Integration tests (`tests/integration/sum.rs`) testing the full CLI binary
-
-### Code Quality Tools
-
-- **rustfmt**: Standard Rust code formatter
-- **Clippy**: Rust linter with pedantic and nursery lints enabled
-- **Pre-commit hooks**: Automated checks before each commit
-
-### Testing Strategy
-
-The template supports multiple levels of testing:
-
-- **Unit tests**: In `tests/unit/` directory, testing functions directly
-- **Integration tests**: In `tests/integration/` directory, testing CLI binary
-- **CI/CD tests**: In `tests/unit/ci-cd/` directory, testing CI/CD script logic
-- **Doc tests**: In documentation examples using `///` comments
-- **Examples**: In `examples/` directory (also serve as documentation)
-
-Users can easily delete CI/CD tests in `tests/unit/ci-cd/` if not needed.
-
-### Changelog Management
-
-This template uses a fragment-based changelog system similar to [Changesets](https://github.com/changesets/changesets) and [Scriv](https://scriv.readthedocs.io/).
-
-```bash
-# Create a changelog fragment
-touch changelog.d/$(date +%Y%m%d_%H%M%S)_my_change.md
-
-# Edit the fragment to document your changes
-```
-
-### CI/CD Pipeline
-
-The GitHub Actions workflow provides:
-
-1. **Change detection**: Only runs relevant jobs based on changed files
-2. **Changelog check**: Validates changelog fragments on PRs with code changes
-3. **Version check**: Prevents manual version modification in PRs
-4. **Linting**: rustfmt and Clippy checks
-5. **Test matrix**: 3 OS (Ubuntu, macOS, Windows) with Rust stable
-6. **Code coverage**: cargo-llvm-cov with Codecov upload
-7. **Building**: Release build and package validation
-8. **Auto release**: Automatic releases when changelog fragments are merged to main
-9. **Manual release**: Workflow dispatch with version bump type selection
-10. **Optional Docker Hub publishing**: Pushes `latest` and version tags after the matching crates.io version is visible
-11. **Documentation**: Automatic docs deployment to GitHub Pages after release
-
-### Template-Safe Defaults
-
-The default package name `example-sum-package-name` triggers skip logic in CI/CD scripts:
-- `publish-crate.rs` skips crates.io publishing
-- `create-github-release.rs` skips GitHub release creation
-- Docker Hub publishing stays disabled unless `DOCKERHUB_IMAGE` is configured and a root `Dockerfile` exists
-
-Rename the package in `Cargo.toml` to enable full CI/CD publishing.
-
-## Configuration
-
-### Updating Package Name
-
-After creating a repository from this template:
-
-1. Update `Cargo.toml`:
- - Change `name` field from `example-sum-package-name`
- - Update `repository` and `documentation` URLs
- - Change `[lib]` name and `[[bin]]` name
-
-2. Update imports:
- - `src/main.rs`
- - `tests/unit/sum.rs`
- - `tests/integration/sum.rs`
- - `examples/basic_usage.rs`
-
-3. Update badges in this `README.md`
-
-### Optional Docker Hub Publishing
-
-Projects that ship a Docker image can publish Docker Hub releases from the same Rust release workflow. Add a root `Dockerfile`, then configure:
-
-| Name | Type | Example | Purpose |
-| ---- | ---- | ------- | ------- |
-| `DOCKERHUB_IMAGE` | Repository variable | `my-dockerhub-user/my-image` | Docker Hub repository to publish |
-| `DOCKERHUB_USERNAME` | Repository variable or secret | `my-dockerhub-user` | Docker Hub login username |
-| `DOCKERHUB_TOKEN` | Repository secret | Docker Hub access token | Docker Hub login token |
-
-When configured, the release workflow publishes both `latest` and the Cargo package version tag, for example `my-dockerhub-user/my-image:0.10.0`. Docker publishing runs only after crates.io reports the matching version as available, and release checks rerun missing Docker Hub or GitHub release artifacts without bumping the version again.
-
-Add a visible Docker Hub badge next to the crates.io badge in repositories that enable image publishing:
+When `gh pr view` finds a PR for the current branch, `plan-to-git` inserts or replaces only this section:
```markdown
-[](https://hub.docker.com/r/my-dockerhub-user/my-image)
-```
+
+## Agent Plan Stack
-## Deploying API documentation
-
-The `deploy-docs` job in `.github/workflows/release.yml` publishes `cargo doc --no-deps --all-features` output to GitHub Pages on every push to `main` and on `workflow_dispatch` with `release_mode == 'instant'`. It uses the official `actions/configure-pages` / `actions/upload-pages-artifact` / `actions/deploy-pages` flow, which requires the repository's Pages source to be set to **GitHub Actions**.
-
-Before the first run on `main`, open **Settings → Pages** of the new repository and set **Source = GitHub Actions**. This is a one-time manual step and cannot be configured from a workflow. The `deploy-docs` job will then provision the Pages site on its first run.
-
-If this step is skipped, the first `deploy-docs` run fails on `actions/deploy-pages@v5` with `Error: Get Pages site failed.` / `Error: Failed to create deployment`. Flip the Pages source as described above and re-run the failed job; no workflow changes are required.
-
-## Scripts Reference
-
-All scripts in `scripts/` are Rust scripts that use [rust-script](https://github.com/fornwall/rust-script).
-Install rust-script with: `cargo install rust-script`
-
-| Command | Description |
-| ------------------------------------- | ------------------------ |
-| `cargo test` | Run all tests |
-| `cargo fmt` | Format code |
-| `cargo clippy` | Run lints |
-| `cargo run -- --a 3 --b 7` | Run CLI (sum 3 + 7) |
-| `cargo run --example basic_usage` | Run example |
-| `rust-script scripts/check-file-size.rs` | Check file size limits |
-| `rust-script scripts/check-crate-size.rs` | Check crate archive size (crates.io 10 MiB limit) |
-| `rust-script scripts/bump-version.rs` | Bump version |
-
-## Example Usage
-
-```rust
-use example_sum_package_name::sum;
-
-fn main() {
- let result = sum(2, 3);
- println!("2 + 3 = {result}");
-}
+...
+
```
-See `examples/basic_usage.rs` for more examples.
-
-## Contributing
-
-Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
-
-### Development Workflow
-
-1. Fork the repository
-2. Create a feature branch: `git checkout -b feature/my-feature`
-3. Make your changes and add tests
-4. Run quality checks: `cargo fmt && cargo clippy && cargo test`
-5. Add a changelog fragment
-6. Commit your changes (pre-commit hooks will run automatically)
-7. Push and create a Pull Request
-
-## License
-
-[Unlicense](LICENSE) - Public Domain
-
-This is free and unencumbered software released into the public domain. See [LICENSE](LICENSE) for details.
-
-## Acknowledgments
-
-Inspired by:
-- [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template)
-- [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template)
-- [lino-arguments](https://github.com/link-foundation/lino-arguments)
-- [trees-rs](https://github.com/linksplatform/trees-rs)
+If only one marker exists, sync fails rather than risking corruption of the human-written PR body.
-## Resources
+## Safety
-- [Rust Book](https://doc.rust-lang.org/book/)
-- [Cargo Book](https://doc.rust-lang.org/cargo/)
-- [Clippy Documentation](https://rust-lang.github.io/rust-clippy/)
-- [rustfmt Documentation](https://rust-lang.github.io/rustfmt/)
-- [Pre-commit Documentation](https://pre-commit.com/)
+The tool does not scrape raw `~/.codex` or `~/.claude` transcripts. It only uses stable hook payload fields and explicitly marked plan text. Captured content is redacted before local storage and PR sync.
diff --git a/changelog.d/20260530_104500_plan_to_git_mvp.md b/changelog.d/20260530_104500_plan_to_git_mvp.md
new file mode 100644
index 0000000..babfd6d
--- /dev/null
+++ b/changelog.d/20260530_104500_plan_to_git_mvp.md
@@ -0,0 +1,6 @@
+---
+bump: minor
+---
+
+### Added
+- Added the `plan-to-git` CLI for capturing Codex plan hooks, storing a local plan stack, and syncing it into GitHub pull request bodies.
diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs
index be9a37f..a26bae5 100644
--- a/examples/basic_usage.rs
+++ b/examples/basic_usage.rs
@@ -1,7 +1,22 @@
-use example_sum_package_name::sum;
+use plan_to_git::render::render_plan_block;
+use plan_to_git::store::{AgentPlanState, AgentSource, NewPlanItem};
fn main() {
- println!("2 + 3 = {}", sum(2, 3));
- println!("-5 + 10 = {}", sum(-5, 10));
- println!("1000 + 2000 = {}", sum(1000, 2000));
+ let mut state = AgentPlanState::default();
+ state.set_context(
+ Some("example/repo".to_owned()),
+ Some("feature/plan-sync".to_owned()),
+ Some("abcdef1234567890".to_owned()),
+ );
+ state.add_plan(NewPlanItem {
+ source: AgentSource::Codex,
+ title: Some("Example Plan".to_owned()),
+ content: "- Capture the plan.\n- Sync it to the pull request.".to_owned(),
+ branch: Some("feature/plan-sync".to_owned()),
+ head_sha: Some("abcdef1234567890".to_owned()),
+ session_id: None,
+ turn_id: None,
+ });
+
+ println!("{}", render_plan_block(&state));
}
diff --git a/src/capture.rs b/src/capture.rs
new file mode 100644
index 0000000..cfdea16
--- /dev/null
+++ b/src/capture.rs
@@ -0,0 +1,134 @@
+use serde::Deserialize;
+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::store::{
+ load_state, save_state, AgentSource, NewDecision, NewPendingQuestion, NewPlanItem,
+ PendingQuestion, STATE_FILE_NAME,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HookOutcome {
+ pub changed: bool,
+ pub captured_plans: usize,
+ pub captured_decisions: usize,
+ pub pending_questions: usize,
+ pub sync_status: SyncStatus,
+}
+
+#[derive(Debug, Deserialize)]
+struct CodexHookInput {
+ #[serde(default)]
+ session_id: Option,
+ #[serde(default)]
+ cwd: Option,
+ hook_event_name: String,
+ #[serde(default)]
+ turn_id: Option,
+ #[serde(default)]
+ prompt: Option,
+ #[serde(default)]
+ last_assistant_message: Option,
+}
+
+pub fn process_codex_hook(input: &str) -> AppResult {
+ let hook_input: CodexHookInput = serde_json::from_str(input)?;
+ 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 mut state = load_state(&state_path)?;
+ state.set_context(
+ context.repo_slug.clone(),
+ context.branch.clone(),
+ context.head_sha.clone(),
+ );
+
+ let mut captured_plans = 0;
+ let mut captured_decisions = 0;
+ let mut changed = false;
+
+ match hook_input.hook_event_name.as_str() {
+ "Stop" => {
+ if let Some(message) = hook_input.last_assistant_message.as_deref() {
+ for plan in extract_marked_plans(message) {
+ let added = state.add_plan(NewPlanItem {
+ source: AgentSource::Codex,
+ 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(),
+ });
+ if added {
+ captured_plans += 1;
+ changed = true;
+ }
+ }
+
+ if captured_plans == 0 {
+ let questions = extract_questions(message);
+ if state.add_pending_question(NewPendingQuestion {
+ source: AgentSource::Codex,
+ questions,
+ branch: context.branch.clone(),
+ head_sha: context.head_sha.clone(),
+ session_id: hook_input.session_id.clone(),
+ turn_id: hook_input.turn_id.clone(),
+ }) {
+ changed = true;
+ }
+ }
+ }
+ }
+ "UserPromptSubmit" => {
+ if let Some(prompt) = hook_input.prompt.as_deref() {
+ if !prompt.trim().is_empty() {
+ let questions = drain_relevant_questions(&mut state.pending_questions);
+ if state.answer_pending_questions(NewDecision {
+ source: AgentSource::Codex,
+ questions,
+ answer: prompt.to_owned(),
+ branch: context.branch.clone(),
+ head_sha: context.head_sha.clone(),
+ session_id: hook_input.session_id.clone(),
+ turn_id: hook_input.turn_id.clone(),
+ }) {
+ captured_decisions += 1;
+ changed = true;
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+
+ if changed || !state.items.is_empty() || !state.pending_questions.is_empty() {
+ save_state(&state_path, &state)?;
+ }
+
+ let sync_status = github::sync_state(&context, &state)?;
+
+ Ok(HookOutcome {
+ changed,
+ captured_plans,
+ captured_decisions,
+ pending_questions: state.pending_questions.len(),
+ sync_status,
+ })
+}
+
+fn drain_relevant_questions(pending_questions: &mut Vec) -> Vec {
+ let mut questions = Vec::new();
+ for pending_question in pending_questions.drain(..) {
+ for question in pending_question.questions {
+ if !questions.iter().any(|existing| existing == &question) {
+ questions.push(question);
+ }
+ }
+ }
+ questions
+}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..4d695a1
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,26 @@
+use std::error::Error;
+use std::fmt::{Display, Formatter};
+
+pub type AppResult = Result>;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AppError {
+ message: String,
+}
+
+impl AppError {
+ #[must_use]
+ pub fn new(message: impl Into) -> Self {
+ Self {
+ message: message.into(),
+ }
+ }
+}
+
+impl Display for AppError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
+ formatter.write_str(&self.message)
+ }
+}
+
+impl Error for AppError {}
diff --git a/src/git.rs b/src/git.rs
new file mode 100644
index 0000000..f7de0a5
--- /dev/null
+++ b/src/git.rs
@@ -0,0 +1,66 @@
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use crate::error::{AppError, AppResult};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GitContext {
+ pub repo_root: PathBuf,
+ pub repo_slug: Option,
+ pub branch: Option,
+ pub head_sha: Option,
+}
+
+pub fn discover(start: &Path) -> AppResult {
+ let repo_root = git_output(start, ["rev-parse", "--show-toplevel"])?;
+ let repo_root = PathBuf::from(repo_root.trim());
+ let branch = git_output(&repo_root, ["rev-parse", "--abbrev-ref", "HEAD"]).ok();
+ let head_sha = git_output(&repo_root, ["rev-parse", "HEAD"]).ok();
+ let remote = git_output(&repo_root, ["remote", "get-url", "origin"]).ok();
+
+ Ok(GitContext {
+ repo_root,
+ repo_slug: remote.as_deref().and_then(parse_github_slug),
+ branch: branch.map(|value| value.trim().to_owned()),
+ head_sha: head_sha.map(|value| value.trim().to_owned()),
+ })
+}
+
+#[must_use]
+pub fn parse_github_slug(remote: &str) -> Option {
+ let remote = remote.trim().trim_end_matches(".git");
+
+ if let Some(path) = remote.strip_prefix("git@github.com:") {
+ return normalize_slug(path);
+ }
+
+ if let Some(path) = remote.strip_prefix("https://github.com/") {
+ return normalize_slug(path);
+ }
+
+ if let Some(path) = remote.strip_prefix("ssh://git@github.com/") {
+ return normalize_slug(path);
+ }
+
+ None
+}
+
+fn normalize_slug(path: &str) -> Option {
+ let mut parts = path.split('/');
+ let owner = parts.next()?;
+ let repo = parts.next()?;
+ if owner.is_empty() || repo.is_empty() {
+ return None;
+ }
+ Some(format!("{owner}/{repo}"))
+}
+
+fn git_output(cwd: &Path, args: [&str; N]) -> AppResult {
+ let output = Command::new("git").arg("-C").arg(cwd).args(args).output()?;
+ if output.status.success() {
+ return Ok(String::from_utf8(output.stdout)?.trim().to_owned());
+ }
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ Err(AppError::new(format!("git command failed: {stderr}")).into())
+}
diff --git a/src/github.rs b/src/github.rs
new file mode 100644
index 0000000..0d51b9d
--- /dev/null
+++ b/src/github.rs
@@ -0,0 +1,106 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::Deserialize;
+
+use crate::error::{AppError, AppResult};
+use crate::git::GitContext;
+use crate::pr_body::upsert_marker_block;
+use crate::render::{has_current_branch_items, render_plan_block};
+use crate::store::AgentPlanState;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SyncStatus {
+ NoItems,
+ NoPullRequest,
+ Unchanged { number: u64 },
+ Updated { number: u64 },
+}
+
+#[derive(Debug, Deserialize)]
+struct PullRequest {
+ number: u64,
+ #[serde(default)]
+ body: Option,
+}
+
+pub fn sync_state(context: &GitContext, state: &AgentPlanState) -> AppResult {
+ if !has_current_branch_items(state) {
+ return Ok(SyncStatus::NoItems);
+ }
+
+ let Some(pull_request) = view_current_pr(&context.repo_root)? else {
+ return Ok(SyncStatus::NoPullRequest);
+ };
+
+ let body = pull_request.body.unwrap_or_default();
+ let plan_block = render_plan_block(state);
+ let updated_body = upsert_marker_block(&body, &plan_block)?;
+
+ if body.trim_end() == updated_body.trim_end() {
+ return Ok(SyncStatus::Unchanged {
+ number: pull_request.number,
+ });
+ }
+
+ edit_pr_body(&context.repo_root, &updated_body)?;
+ Ok(SyncStatus::Updated {
+ number: pull_request.number,
+ })
+}
+
+fn view_current_pr(repo_root: &Path) -> AppResult