diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3bf4e07..f8323d7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -153,6 +153,7 @@ jobs:
run: cargo install rust-script
- name: Cache cargo registry
+ continue-on-error: true
uses: actions/cache@v5
with:
path: |
@@ -192,6 +193,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo registry
+ continue-on-error: true
uses: actions/cache@v5
with:
path: |
@@ -231,6 +233,7 @@ jobs:
components: llvm-tools-preview
- name: Cache cargo registry
+ continue-on-error: true
uses: actions/cache@v5
with:
path: |
@@ -271,6 +274,7 @@ jobs:
run: cargo install rust-script
- name: Cache cargo registry
+ continue-on-error: true
uses: actions/cache@v5
with:
path: |
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 928c721..4560f4c 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.17.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.17.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 e06ecb0..5b3dc03 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,15 +1,15 @@
[package]
-name = "example-sum-package-name"
+name = "plan-to-git"
version = "0.17.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..05af6f4 100644
--- a/README.md
+++ b/README.md
@@ -1,332 +1,61 @@
-# 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 posts new pull request comments for plan updates.
-[](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`;
+- posts a new PR comment with newly captured current-branch items 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
-
-```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
-```
-
-### Running Tests
-
-```bash
-# Run all tests
-cargo test
-
-# Run tests with verbose output
-cargo test --verbose
-
-# Run doc tests
-cargo test --doc
-
-# Run a specific test
-cargo test test_sum_positive_numbers
-
-# Run tests with output
-cargo test -- --nocapture
-```
-
-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
+## CLI
```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
-```
-
-## Project Structure
-
-```
-.
-├── .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
+plan-to-git hook --source codex < hook-payload.json
+plan-to-git show
+plan-to-git render
+plan-to-git sync
+plan-to-git import-codex --dry-run
+plan-to-git import-codex
+plan-to-git clear --yes
```
-## 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
+`hook` is intentionally quiet on stdout, because Codex hook stdout is interpreted by Codex. Operational messages go to stderr.
-The template supports multiple levels of testing:
+## Codex Hook Example
-- **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)
+Add the command to Codex hook configuration for `Stop` and `UserPromptSubmit` events:
-Users can easily delete CI/CD tests in `tests/unit/ci-cd/` if not needed.
+```toml
+[[hooks.UserPromptSubmit]]
+[[hooks.UserPromptSubmit.hooks]]
+type = "command"
+command = "plan-to-git hook --source codex"
-### 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
+[[hooks.Stop]]
+[[hooks.Stop.hooks]]
+type = "command"
+command = "plan-to-git hook --source codex"
```
-### 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 |
+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`.
-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.
+## Pull Request Comments
-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` creates a new issue comment on that PR containing items that have not been posted before:
```markdown
-[](https://hub.docker.com/r/my-dockerhub-user/my-image)
-```
-
-## 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
+## Agent Plan Update
-```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
+The PR description is not edited. 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.
-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)
+## Safety
-## Resources
+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`.
-- [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/)
+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.
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..5bae08f
--- /dev/null
+++ b/changelog.d/20260530_104500_plan_to_git_mvp.md
@@ -0,0 +1,7 @@
+---
+bump: minor
+---
+
+### Added
+- Added the `plan-to-git` CLI for capturing Codex plan hooks, storing a local plan stack, and posting new GitHub pull request comments for unposted plans.
+- Added `plan-to-git import-codex` for backfilling explicitly marked plans from matching Codex session history without re-uploading duplicates.
diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs
index be9a37f..78f05b5 100644
--- a/examples/basic_usage.rs
+++ b/examples/basic_usage.rs
@@ -1,7 +1,23 @@
-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,
+ created_at: None,
+ });
+
+ println!("{}", render_plan_block(&state));
}
diff --git a/src/capture.rs b/src/capture.rs
new file mode 100644
index 0000000..61f6088
--- /dev/null
+++ b/src/capture.rs
@@ -0,0 +1,138 @@
+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(),
+ created_at: None,
+ });
+ 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, &mut state)?;
+ if changed || !state.items.is_empty() || !state.pending_questions.is_empty() {
+ save_state(&state_path, &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/codex_history.rs b/src/codex_history.rs
new file mode 100644
index 0000000..da897c8
--- /dev/null
+++ b/src/codex_history.rs
@@ -0,0 +1,544 @@
+use serde_json::Value;
+use std::fs::{self, File};
+use std::io::{BufRead, BufReader};
+use std::path::{Path, PathBuf};
+
+use crate::error::AppResult;
+use crate::git::{parse_github_slug, GitContext};
+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,
+ repo_slug: Option,
+ branch: Option,
+ cwd: Option,
+}
+
+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,
+ };
+
+ let mut files = codex_session_files(codex_home)?;
+ files.sort();
+
+ for path in files {
+ outcome.files_scanned += 1;
+ import_session_file(&path, context, state, &mut outcome)?;
+ }
+
+ Ok(outcome)
+}
+
+fn import_session_file(
+ path: &Path,
+ context: &GitContext,
+ state: &mut AgentPlanState,
+ outcome: &mut CodexHistoryImportOutcome,
+) -> AppResult<()> {
+ let file = File::open(path)?;
+ let reader = BufReader::new(file);
+ let mut metadata: Option = None;
+ let mut file_matches = false;
+
+ 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.get("type").and_then(Value::as_str) == Some("session_meta") {
+ metadata = Some(parse_session_metadata(&event));
+ file_matches = metadata
+ .as_ref()
+ .is_some_and(|session| session_matches_context(session, context));
+ if file_matches {
+ outcome.files_matched += 1;
+ }
+ continue;
+ }
+
+ if !file_matches {
+ continue;
+ }
+
+ let Some(message) = plan_message_text(&event) else {
+ continue;
+ };
+
+ let session_id = metadata
+ .as_ref()
+ .and_then(|session| session.id.clone())
+ .or_else(|| session_id_from_path(path));
+ let turn_id = event
+ .get("payload")
+ .and_then(|payload| payload.get("turn_id"))
+ .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);
+
+ for plan in extract_marked_plans(&message) {
+ outcome.plans_found += 1;
+ if looks_like_rendered_plan_stack(&plan.content) {
+ outcome.rendered_stacks_skipped += 1;
+ continue;
+ }
+ 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: session_id.clone(),
+ turn_id: turn_id.clone(),
+ created_at: created_at.clone(),
+ });
+
+ if added {
+ outcome.plans_added += 1;
+ } else {
+ outcome.duplicates += 1;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn codex_session_files(codex_home: &Path) -> AppResult> {
+ let sessions_dir = codex_home.join("sessions");
+ if !sessions_dir.exists() {
+ return Ok(Vec::new());
+ }
+
+ let mut files = Vec::new();
+ collect_jsonl_files(&sessions_dir, &mut files)?;
+ 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
+ .and_then(|payload| payload.get("id"))
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned);
+ let branch = payload
+ .and_then(|payload| payload.get("git"))
+ .and_then(|git| git.get("branch"))
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned);
+ let repo_slug = payload
+ .and_then(|payload| payload.get("git"))
+ .and_then(|git| git.get("repository_url"))
+ .and_then(Value::as_str)
+ .and_then(parse_github_slug);
+ let cwd = payload
+ .and_then(|payload| payload.get("cwd"))
+ .and_then(Value::as_str)
+ .map(PathBuf::from);
+
+ SessionMetadata {
+ id,
+ repo_slug,
+ branch,
+ cwd,
+ }
+}
+
+fn session_matches_context(session: &SessionMetadata, context: &GitContext) -> bool {
+ let repo_matches = match (&context.repo_slug, &session.repo_slug) {
+ (Some(current), Some(history)) => current == history,
+ _ => session
+ .cwd
+ .as_ref()
+ .is_some_and(|cwd| cwd.starts_with(&context.repo_root)),
+ };
+ let branch_matches = match (&context.branch, &session.branch) {
+ (Some(current), Some(history)) => current == history,
+ _ => true,
+ };
+
+ repo_matches && branch_matches
+}
+
+fn plan_message_text(event: &Value) -> Option {
+ assistant_message_text(event).or_else(|| task_complete_message_text(event))
+}
+
+fn assistant_message_text(event: &Value) -> Option {
+ if event.get("type").and_then(Value::as_str) != Some("response_item") {
+ return None;
+ }
+
+ let payload = event.get("payload")?;
+ if payload.get("type").and_then(Value::as_str) != Some("message") {
+ return None;
+ }
+ if payload.get("role").and_then(Value::as_str) != Some("assistant") {
+ return None;
+ }
+
+ let text = payload
+ .get("content")?
+ .as_array()?
+ .iter()
+ .filter_map(|content| content.get("text").and_then(Value::as_str))
+ .collect::>()
+ .join("\n");
+
+ (!text.trim().is_empty()).then_some(text)
+}
+
+fn task_complete_message_text(event: &Value) -> Option {
+ if event.get("type").and_then(Value::as_str) != Some("event_msg") {
+ return None;
+ }
+
+ let payload = event.get("payload")?;
+ if payload.get("type").and_then(Value::as_str) != Some("task_complete") {
+ return None;
+ }
+
+ let text = payload
+ .get("last_agent_message")
+ .or_else(|| payload.get("last_assistant_message"))
+ .and_then(Value::as_str)?;
+
+ (!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;
+ use std::{fs, path::Path};
+
+ use tempfile::tempdir;
+
+ use crate::git::GitContext;
+ use crate::store::AgentPlanState;
+
+ use super::import_codex_history;
+
+ fn json_line(value: &serde_json::Value) -> String {
+ serde_json::to_string(value).expect("serialize jsonl event")
+ }
+
+ fn session_meta_line(cwd: &Path, branch: &str) -> String {
+ json_line(&json!({
+ "type": "session_meta",
+ "payload": {
+ "id": "session",
+ "cwd": cwd.to_string_lossy().into_owned(),
+ "git": {
+ "branch": branch,
+ "repository_url": "https://github.com/example/repo.git"
+ }
+ }
+ }))
+ }
+
+ fn message_line(role: &str, content_type: &str, text: &str) -> String {
+ json_line(&json!({
+ "type": "response_item",
+ "payload": {
+ "type": "message",
+ "role": role,
+ "content": [{
+ "type": content_type,
+ "text": text
+ }]
+ }
+ }))
+ }
+
+ fn task_complete_line(timestamp: &str, text: &str) -> String {
+ json_line(&json!({
+ "timestamp": timestamp,
+ "type": "event_msg",
+ "payload": {
+ "type": "task_complete",
+ "last_agent_message": text
+ }
+ }))
+ }
+
+ fn write_jsonl(path: &Path, lines: &[String]) {
+ fs::write(path, format!("{}\n", lines.join("\n"))).expect("write session");
+ }
+
+ #[test]
+ fn test_jsonl_helpers_escape_windows_paths() {
+ let line = session_meta_line(Path::new(r"C:\Users\dev\repo"), "feature/test");
+ let event = serde_json::from_str::(&line).expect("parse session meta");
+
+ assert_eq!(
+ event
+ .get("payload")
+ .and_then(|payload| payload.get("cwd"))
+ .and_then(serde_json::Value::as_str),
+ Some(r"C:\Users\dev\repo")
+ );
+ }
+
+ #[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 codex_home = temp_dir.path().join("codex");
+ let session_dir = codex_home.join("sessions/2026/05/31");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ let session = session_dir.join("rollout-2026-05-31T00-00-00-session.jsonl");
+ write_jsonl(
+ &session,
+ &[
+ session_meta_line(&repo_root, "feature/test"),
+ message_line(
+ "user",
+ "input_text",
+ "ignore user text",
+ ),
+ message_line("assistant", "output_text", "not a marked plan"),
+ message_line(
+ "assistant",
+ "output_text",
+ "\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n",
+ ),
+ message_line(
+ "assistant",
+ "output_text",
+ "\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n",
+ ),
+ ],
+ );
+
+ let context = GitContext {
+ repo_root,
+ repo_slug: Some("example/repo".to_owned()),
+ branch: Some("feature/test".to_owned()),
+ head_sha: Some("abcdef".to_owned()),
+ };
+ let mut state = AgentPlanState::default();
+
+ let outcome = import_codex_history(&codex_home, &context, &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!(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("rollout-2026-05-31T00-00-00-session:4")
+ );
+ }
+
+ #[test]
+ fn skips_sessions_from_other_branches() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let codex_home = temp_dir.path().join("codex");
+ let session_dir = codex_home.join("sessions/2026/05/31");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ write_jsonl(
+ &session_dir.join("rollout-2026-05-31T00-00-00-other.jsonl"),
+ &[
+ session_meta_line(&repo_root, "feature/other"),
+ message_line(
+ "assistant",
+ "output_text",
+ "\n# Other\n\n- Wrong branch\n",
+ ),
+ ],
+ );
+
+ let context = GitContext {
+ repo_root,
+ repo_slug: Some("example/repo".to_owned()),
+ branch: Some("feature/test".to_owned()),
+ head_sha: Some("abcdef".to_owned()),
+ };
+ let mut state = AgentPlanState::default();
+
+ let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import");
+
+ assert_eq!(outcome.files_scanned, 1);
+ assert_eq!(outcome.files_matched, 0);
+ assert_eq!(outcome.plans_found, 0);
+ assert!(state.items.is_empty());
+ }
+
+ #[test]
+ fn imports_task_complete_last_agent_message() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let codex_home = temp_dir.path().join("codex");
+ let session_dir = codex_home.join("sessions/2026/05/31");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ write_jsonl(
+ &session_dir.join("rollout-2026-05-31T00-00-00-complete.jsonl"),
+ &[
+ session_meta_line(&repo_root, "feature/test"),
+ task_complete_line(
+ "2026-05-31T12:34:56Z",
+ "\n# Complete\n\n- Import completion text\n",
+ ),
+ ],
+ );
+
+ let context = GitContext {
+ repo_root,
+ repo_slug: Some("example/repo".to_owned()),
+ branch: Some("feature/test".to_owned()),
+ head_sha: Some("abcdef".to_owned()),
+ };
+ let mut state = AgentPlanState::default();
+
+ let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import");
+
+ assert_eq!(outcome.plans_added, 1);
+ assert!(state.items[0].content.contains("Import completion text"));
+ assert_eq!(state.items[0].created_at, "2026-05-31T12:34:56Z");
+ }
+
+ #[test]
+ fn skips_sessions_without_positive_repo_or_cwd_match() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let codex_home = temp_dir.path().join("codex");
+ let session_dir = codex_home.join("sessions/2026/05/31");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ fs::write(
+ session_dir.join("rollout-2026-05-31T00-00-00-no-context.jsonl"),
+ r#"{"type":"session_meta","payload":{"id":"session","git":{"branch":"feature/test"}}}
+{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n# No Context\n\n- Do not import\n"}]}}
+"#,
+ )
+ .expect("write session");
+
+ let context = GitContext {
+ repo_root,
+ repo_slug: Some("example/repo".to_owned()),
+ branch: Some("feature/test".to_owned()),
+ head_sha: Some("abcdef".to_owned()),
+ };
+ let mut state = AgentPlanState::default();
+
+ let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import");
+
+ assert_eq!(outcome.files_matched, 0);
+ assert!(state.items.is_empty());
+ }
+
+ #[test]
+ fn skips_rendered_plan_stack_blocks() {
+ let temp_dir = tempdir().expect("temp dir");
+ let repo_root = temp_dir.path().join("repo");
+ let codex_home = temp_dir.path().join("codex");
+ let session_dir = codex_home.join("sessions/2026/05/31");
+ fs::create_dir_all(&repo_root).expect("repo root");
+ fs::create_dir_all(&session_dir).expect("session dir");
+
+ write_jsonl(
+ &session_dir.join("rollout-2026-05-31T00-00-00-rendered.jsonl"),
+ &[
+ session_meta_line(&repo_root, "feature/test"),
+ message_line(
+ "assistant",
+ "output_text",
+ "\n\n## Agent Plan Stack\n\n",
+ ),
+ ],
+ );
+
+ let context = GitContext {
+ repo_root,
+ repo_slug: Some("example/repo".to_owned()),
+ branch: Some("feature/test".to_owned()),
+ head_sha: Some("abcdef".to_owned()),
+ };
+ let mut state = AgentPlanState::default();
+
+ let outcome = import_codex_history(&codex_home, &context, &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/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..9df1617
--- /dev/null
+++ b/src/github.rs
@@ -0,0 +1,127 @@
+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::render::render_plan_comment;
+use crate::store::AgentPlanState;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SyncStatus {
+ NoItems,
+ NoPullRequest,
+ Unchanged {
+ number: u64,
+ },
+ Commented {
+ number: u64,
+ comment_id: u64,
+ items: usize,
+ },
+}
+
+#[derive(Debug, Deserialize)]
+struct PullRequest {
+ number: u64,
+}
+
+#[derive(Debug, Deserialize)]
+struct IssueComment {
+ id: u64,
+}
+
+pub fn sync_state(context: &GitContext, state: &mut AgentPlanState) -> AppResult {
+ if !state.has_current_branch_items() {
+ return Ok(SyncStatus::NoItems);
+ }
+
+ let Some(pull_request) = view_current_pr(&context.repo_root)? else {
+ return Ok(SyncStatus::NoPullRequest);
+ };
+
+ let (comment_body, item_ids, item_count) = {
+ let items = state.unposted_items_for_pr(pull_request.number);
+ if items.is_empty() {
+ return Ok(SyncStatus::Unchanged {
+ number: pull_request.number,
+ });
+ }
+ let item_ids = items.iter().map(|item| item.id.clone()).collect::>();
+ (render_plan_comment(state, &items), item_ids, items.len())
+ };
+
+ let comment_id = create_issue_comment(context, pull_request.number, &comment_body)?;
+ state.mark_items_commented(pull_request.number, &item_ids, Some(comment_id));
+
+ Ok(SyncStatus::Commented {
+ number: pull_request.number,
+ comment_id,
+ items: item_count,
+ })
+}
+
+fn view_current_pr(repo_root: &Path) -> AppResult