From d9364e8a4904e447d289c4991aba8bfc8cf65d2c Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 24 Jun 2026 07:23:04 +0000 Subject: [PATCH 1/6] feat(overlay): add tested capture core for the overlay MVP The platform-agnostic 'brain' of the Lore capture overlay (Host B of lore-capture-surfaces), as a staging dir in rac-core to extract to its own lore-* repo later. Implements the two-gate flow (ADR-077) over three trait seams (rac / gateway / GitHub): propose (fidelity) then publish, which writes, validates with rac, and opens a DRAFT pull request only. The Publisher trait has no approve/merge method, so a host cannot self-approve. Model calls go through a bring-your-own OpenAI-compatible gateway (ADR-035). Verified by a hermetic cargo test suite plus a real rac-shell test; net clients compile. --- lore-overlay/.gitignore | 4 + lore-overlay/core/Cargo.lock | 1369 ++++++++++++++++++++++++++ lore-overlay/core/Cargo.toml | 18 + lore-overlay/core/src/config.rs | 50 + lore-overlay/core/src/error.rs | 36 + lore-overlay/core/src/flow.rs | 150 +++ lore-overlay/core/src/gateway.rs | 102 ++ lore-overlay/core/src/github.rs | 166 ++++ lore-overlay/core/src/lib.rs | 41 + lore-overlay/core/src/rac.rs | 84 ++ lore-overlay/core/tests/flow_test.rs | 199 ++++ 11 files changed, 2219 insertions(+) create mode 100644 lore-overlay/.gitignore create mode 100644 lore-overlay/core/Cargo.lock create mode 100644 lore-overlay/core/Cargo.toml create mode 100644 lore-overlay/core/src/config.rs create mode 100644 lore-overlay/core/src/error.rs create mode 100644 lore-overlay/core/src/flow.rs create mode 100644 lore-overlay/core/src/gateway.rs create mode 100644 lore-overlay/core/src/github.rs create mode 100644 lore-overlay/core/src/lib.rs create mode 100644 lore-overlay/core/src/rac.rs create mode 100644 lore-overlay/core/tests/flow_test.rs diff --git a/lore-overlay/.gitignore b/lore-overlay/.gitignore new file mode 100644 index 00000000..8571a1c3 --- /dev/null +++ b/lore-overlay/.gitignore @@ -0,0 +1,4 @@ +target/ +node_modules/ +dist/ +*.log diff --git a/lore-overlay/core/Cargo.lock b/lore-overlay/core/Cargo.lock new file mode 100644 index 00000000..d82fd8d5 --- /dev/null +++ b/lore-overlay/core/Cargo.lock @@ -0,0 +1,1369 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +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 = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[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 = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lore-capture-core" +version = "0.0.0" +dependencies = [ + "base64", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "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 = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[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 = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/lore-overlay/core/Cargo.toml b/lore-overlay/core/Cargo.toml new file mode 100644 index 00000000..44c35dae --- /dev/null +++ b/lore-overlay/core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lore-capture-core" +version = "0.0.0" +edition = "2021" +license = "MIT" +description = "Platform-agnostic capture core for the Lore overlay: draft a typed artifact through a bring-your-own gateway, validate it with rac, and PROPOSE it as a draft pull request (never lands it)." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Real network seams (gateway + GitHub) are only compiled for the desktop app, +# not for the hermetic core tests, so `cargo test` stays fast and offline. +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } +base64 = { version = "0.22", optional = true } + +[features] +default = [] +net = ["dep:reqwest", "dep:base64"] diff --git a/lore-overlay/core/src/config.rs b/lore-overlay/core/src/config.rs new file mode 100644 index 00000000..7e51029f --- /dev/null +++ b/lore-overlay/core/src/config.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; + +/// Bring-your-own gateway (ADR-035): any OpenAI-compatible endpoint the operator +/// controls — a self-hosted LiteLLM proxy, a cloud vendor, or a local model. +/// +/// The `api_key` is `#[serde(skip)]` so it is never written into a config file or +/// logged; in the shipped app it is read from the OS secret store at runtime. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GatewayConfig { + /// OpenAI-compatible base URL, e.g. `http://localhost:4000/v1`. + pub base_url: String, + /// Model name as the gateway knows it. + pub model: String, + #[serde(skip)] + pub api_key: String, +} + +/// The repository a capture proposes into. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RepoConfig { + pub owner: String, + pub repo: String, + #[serde(default = "default_base_branch")] + pub base_branch: String, +} + +fn default_base_branch() -> String { + "main".to_string() +} + +/// The overlay's whole persisted configuration. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Config { + pub gateway: GatewayConfig, + pub repo: RepoConfig, + /// Global hotkey, in Tauri accelerator syntax. + #[serde(default = "default_hotkey")] + pub hotkey: String, + /// How to invoke the `rac` engine (bundled, on PATH, or a wrapper). + #[serde(default = "default_rac_command")] + pub rac_command: String, +} + +fn default_hotkey() -> String { + "CmdOrCtrl+Shift+L".to_string() +} + +fn default_rac_command() -> String { + "rac".to_string() +} diff --git a/lore-overlay/core/src/error.rs b/lore-overlay/core/src/error.rs new file mode 100644 index 00000000..b0180f80 --- /dev/null +++ b/lore-overlay/core/src/error.rs @@ -0,0 +1,36 @@ +use std::fmt; + +/// One error type for the whole capture flow, naming which seam failed. +#[derive(Debug)] +pub enum CaptureError { + /// The `rac` engine seam (schema / new / validate) failed. + Rac(String), + /// The model gateway seam failed. + Gateway(String), + /// The git/GitHub publish seam failed. + Publish(String), + /// A local filesystem operation failed. + Io(String), + /// Output from a subprocess could not be parsed (e.g. the minted id). + Parse(String), +} + +impl fmt::Display for CaptureError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CaptureError::Rac(m) => write!(f, "rac error: {m}"), + CaptureError::Gateway(m) => write!(f, "gateway error: {m}"), + CaptureError::Publish(m) => write!(f, "publish error: {m}"), + CaptureError::Io(m) => write!(f, "io error: {m}"), + CaptureError::Parse(m) => write!(f, "parse error: {m}"), + } + } +} + +impl std::error::Error for CaptureError {} + +impl From for CaptureError { + fn from(e: std::io::Error) -> Self { + CaptureError::Io(e.to_string()) + } +} diff --git a/lore-overlay/core/src/flow.rs b/lore-overlay/core/src/flow.rs new file mode 100644 index 00000000..63d35a1e --- /dev/null +++ b/lore-overlay/core/src/flow.rs @@ -0,0 +1,150 @@ +use crate::config::RepoConfig; +use crate::error::CaptureError; +use crate::gateway::Gateway; +use crate::github::{PrResult, ProposalRequest, Publisher}; +use crate::rac::Rac; + +/// A proposed artifact, produced from raw intent, awaiting the author's fidelity +/// confirmation (Gate 1). Nothing has been written or pushed yet. +#[derive(Clone, Debug)] +pub struct Proposal { + pub artifact_type: String, + pub title: String, + pub body: String, +} + +/// The result of publishing a confirmed proposal: a **draft** pull request. It is +/// not landed — an independent maintainer's merge is the trust boundary (Gate 2). +#[derive(Clone, Debug)] +pub struct CaptureOutcome { + pub path: String, + pub minted_id: String, + pub pr: PrResult, +} + +/// Orchestrates the capture flow over the three seams. Generic over the traits so +/// the core is exercised with fakes in tests and with the real clients in the app. +pub struct CaptureFlow { + rac: R, + gateway: G, + publisher: P, + repo: RepoConfig, +} + +impl CaptureFlow { + pub fn new(rac: R, gateway: G, publisher: P, repo: RepoConfig) -> Self { + Self { + rac, + gateway, + publisher, + repo, + } + } + + pub fn repo(&self) -> &RepoConfig { + &self.repo + } + + /// Gate-1 preparation: turn raw `intent` into a [`Proposal`]. Reads the real + /// schema and drafts through the gateway. No file is written, nothing is + /// pushed — the author reviews the proposal next. + pub fn propose(&self, artifact_type: &str, intent: &str) -> Result { + let schema = self.rac.schema(artifact_type)?; + let drafted = self.gateway.draft(artifact_type, &schema, intent)?; + Ok(Proposal { + artifact_type: artifact_type.to_string(), + title: drafted.title, + body: drafted.body, + }) + } + + /// After the author confirms the proposal is faithful (Gate 1), scaffold the + /// file (minting the id), fill the body while keeping the frontmatter, + /// validate, and open a **draft** pull request. Refuses to proceed if the + /// publisher ever returns a non-draft PR (Gate 2 is the independent merge). + pub fn publish( + &self, + proposal: &Proposal, + dest_path: &str, + branch: &str, + coauthor_trailer: Option<&str>, + ) -> Result { + let stdout = self.rac.new_artifact(&proposal.artifact_type, dest_path)?; + let minted_id = parse_minted_id(&stdout).ok_or_else(|| { + CaptureError::Parse("could not find the minted id in `rac new` output".into()) + })?; + + let scaffold = std::fs::read_to_string(dest_path)?; + let filled = fill_body(&scaffold, &proposal.title, &proposal.body)?; + std::fs::write(dest_path, &filled)?; + + // Deterministic close before we propose anything. + self.rac.validate(dest_path)?; + + let mut pr_body = format!( + "Proposed via the Lore capture overlay. Fidelity confirmed by the author; this is a \ + **draft** awaiting an independent maintainer's review and merge (ADR-077).\n\n\ + Artifact: `{path}` ({id})\n", + path = dest_path, + id = minted_id + ); + if let Some(trailer) = coauthor_trailer { + pr_body.push('\n'); + pr_body.push_str(trailer); + pr_body.push('\n'); + } + + let req = ProposalRequest { + branch: branch.to_string(), + path: dest_path.to_string(), + content: filled, + commit_message: format!("capture: propose {}", proposal.title), + pr_title: format!("capture: {}", proposal.title), + pr_body, + }; + let pr = self.publisher.open_draft_pr(&req)?; + if !pr.draft { + return Err(CaptureError::Publish( + "refusing to proceed: capture must open a DRAFT pull request (ADR-077)".into(), + )); + } + Ok(CaptureOutcome { + path: dest_path.to_string(), + minted_id, + pr, + }) + } +} + +/// Parse the opaque id that `rac new` reports (a line like `ID: RAC-XXXX`, or any +/// bare `RAC-…` token in the output). +pub fn parse_minted_id(stdout: &str) -> Option { + for line in stdout.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("ID:") { + return Some(rest.trim().to_string()); + } + if let Some(tok) = line.split_whitespace().find(|t| t.starts_with("RAC-")) { + return Some(tok.trim_end_matches(['.', ',']).to_string()); + } + } + None +} + +/// Replace the scaffold's body with the drafted title + body, keeping the `---` +/// frontmatter block (which carries the minted id and type) byte-for-byte. +fn fill_body(scaffold: &str, title: &str, body: &str) -> Result { + let rest = scaffold + .strip_prefix("---\n") + .ok_or_else(|| CaptureError::Parse("scaffold has no frontmatter".into()))?; + let end = rest + .find("\n---\n") + .ok_or_else(|| CaptureError::Parse("scaffold frontmatter is unterminated".into()))?; + let frontmatter = &rest[..end]; + Ok(format!( + "---\n{frontmatter}\n---\n# {title}\n\n{body}\n", + frontmatter = frontmatter, + title = title.trim(), + body = body.trim_end() + )) +} diff --git a/lore-overlay/core/src/gateway.rs b/lore-overlay/core/src/gateway.rs new file mode 100644 index 00000000..551908c8 --- /dev/null +++ b/lore-overlay/core/src/gateway.rs @@ -0,0 +1,102 @@ +use crate::error::CaptureError; + +/// A drafted artifact: a human title plus the Markdown body — the sections under +/// the type's headings, **without** frontmatter (`rac new` owns the frontmatter +/// and mints the id). +#[derive(Clone, Debug)] +pub struct DraftedArtifact { + pub title: String, + pub body: String, +} + +/// The model seam. The interview/draft step runs in the host behind a +/// bring-your-own gateway (ADR-002/035/067: AI lives in the host, never the +/// engine). It is a trait so the core is testable without a real model. +pub trait Gateway { + /// Draft an artifact of `artifact_type` from the author's raw `intent`, + /// shaped by the real `schema_json` (so it uses only the schema's sections). + fn draft( + &self, + artifact_type: &str, + schema_json: &str, + intent: &str, + ) -> Result; +} + +/// Real OpenAI-compatible gateway client (LiteLLM / OpenRouter / Azure / Ollama / +/// vLLM …). Compiled only for the desktop app, behind the `net` feature. +#[cfg(feature = "net")] +pub struct OpenAiGateway { + base_url: String, + api_key: String, + model: String, + client: reqwest::blocking::Client, +} + +#[cfg(feature = "net")] +impl OpenAiGateway { + pub fn new(cfg: &crate::config::GatewayConfig) -> Self { + Self { + base_url: cfg.base_url.trim_end_matches('/').to_string(), + api_key: cfg.api_key.clone(), + model: cfg.model.clone(), + client: reqwest::blocking::Client::new(), + } + } +} + +#[cfg(feature = "net")] +impl Gateway for OpenAiGateway { + fn draft( + &self, + artifact_type: &str, + schema_json: &str, + intent: &str, + ) -> Result { + let system = format!( + "You draft a Lore (requirements-as-code) {ty} artifact from the author's words. \ + Use ONLY the section headings the schema lists; never invent sections, and never \ + emit frontmatter. Capture only what the author says — where a required section has \ + no material, write a one-line 'TODO: (gap to confirm)'. Respond with STRICT JSON: \ + {{\"title\": , \"body\": }}. \ + Schema: {schema}", + ty = artifact_type, + schema = schema_json + ); + let payload = serde_json::json!({ + "model": self.model, + "temperature": 0, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": intent}, + ], + }); + let resp = self + .client + .post(format!("{}/chat/completions", self.base_url)) + .bearer_auth(&self.api_key) + .json(&payload) + .send() + .map_err(|e| CaptureError::Gateway(e.to_string()))?; + if !resp.status().is_success() { + return Err(CaptureError::Gateway(format!("HTTP {}", resp.status()))); + } + let v: serde_json::Value = resp + .json() + .map_err(|e| CaptureError::Gateway(e.to_string()))?; + let content = v["choices"][0]["message"]["content"] + .as_str() + .ok_or_else(|| CaptureError::Gateway("no content in gateway response".into()))?; + let parsed: serde_json::Value = serde_json::from_str(content) + .map_err(|e| CaptureError::Gateway(format!("model did not return JSON: {e}")))?; + let title = parsed["title"].as_str().unwrap_or("").trim().to_string(); + let body = parsed["body"].as_str().unwrap_or("").to_string(); + if title.is_empty() || body.is_empty() { + return Err(CaptureError::Gateway( + "model returned an empty title or body".into(), + )); + } + Ok(DraftedArtifact { title, body }) + } +} diff --git a/lore-overlay/core/src/github.rs b/lore-overlay/core/src/github.rs new file mode 100644 index 00000000..1de6713a --- /dev/null +++ b/lore-overlay/core/src/github.rs @@ -0,0 +1,166 @@ +use crate::error::CaptureError; + +/// Everything needed to open a draft pull request proposing one artifact. +#[derive(Clone, Debug)] +pub struct ProposalRequest { + /// New branch to create off the base branch. + pub branch: String, + /// Repo-relative path of the artifact file. + pub path: String, + /// Full file content to commit. + pub content: String, + pub commit_message: String, + pub pr_title: String, + pub pr_body: String, +} + +/// The opened pull request. +#[derive(Clone, Debug)] +pub struct PrResult { + pub url: String, + pub number: u64, + pub draft: bool, +} + +/// The write seam. A capture host only ever **proposes**: it opens a draft pull +/// request and never approves or merges (ADR-065 / ADR-077). The trait has no +/// approve or merge method by construction, so the two-gate model cannot be +/// violated by a host that uses this core. +pub trait Publisher { + fn open_draft_pr(&self, req: &ProposalRequest) -> Result; +} + +/// Real GitHub publisher over the REST API. Compiled only for the desktop app, +/// behind the `net` feature. +/// +/// It is given a bearer `token` (a GitHub App installation token in the shipped +/// app; a PAT is fine for development). Obtaining that token — the desktop +/// device-flow install — is the shell's job, recorded as an open question in the +/// `lore-capture-overlay` design. +#[cfg(feature = "net")] +pub struct GithubPublisher { + owner: String, + repo: String, + base_branch: String, + token: String, + api_base: String, + client: reqwest::blocking::Client, +} + +#[cfg(feature = "net")] +impl GithubPublisher { + pub fn new( + owner: impl Into, + repo: impl Into, + base_branch: impl Into, + token: impl Into, + ) -> Self { + Self { + owner: owner.into(), + repo: repo.into(), + base_branch: base_branch.into(), + token: token.into(), + api_base: "https://api.github.com".to_string(), + client: reqwest::blocking::Client::new(), + } + } + + fn get(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client + .get(format!("{}{}", self.api_base, path)) + .bearer_auth(&self.token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "lore-capture-overlay") + } + + fn post(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client + .post(format!("{}{}", self.api_base, path)) + .bearer_auth(&self.token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "lore-capture-overlay") + } + + fn put(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client + .put(format!("{}{}", self.api_base, path)) + .bearer_auth(&self.token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "lore-capture-overlay") + } +} + +#[cfg(feature = "net")] +impl Publisher for GithubPublisher { + fn open_draft_pr(&self, req: &ProposalRequest) -> Result { + use base64::Engine as _; + let err = |e: reqwest::Error| CaptureError::Publish(e.to_string()); + + // 1. Base branch tip sha. + let base_ref = self + .get(&format!( + "/repos/{}/{}/git/ref/heads/{}", + self.owner, self.repo, self.base_branch + )) + .send() + .map_err(err)? + .error_for_status() + .map_err(err)? + .json::() + .map_err(err)?; + let base_sha = base_ref["object"]["sha"] + .as_str() + .ok_or_else(|| CaptureError::Publish("no base sha".into()))? + .to_string(); + + // 2. Create the work branch. + self.post(&format!("/repos/{}/{}/git/refs", self.owner, self.repo)) + .json(&serde_json::json!({ + "ref": format!("refs/heads/{}", req.branch), + "sha": base_sha, + })) + .send() + .map_err(err)? + .error_for_status() + .map_err(err)?; + + // 3. Write the file on the new branch. + let content_b64 = base64::engine::general_purpose::STANDARD.encode(req.content.as_bytes()); + self.put(&format!( + "/repos/{}/{}/contents/{}", + self.owner, self.repo, req.path + )) + .json(&serde_json::json!({ + "message": req.commit_message, + "content": content_b64, + "branch": req.branch, + })) + .send() + .map_err(err)? + .error_for_status() + .map_err(err)?; + + // 4. Open a DRAFT pull request. Never approves or merges. + let pr = self + .post(&format!("/repos/{}/{}/pulls", self.owner, self.repo)) + .json(&serde_json::json!({ + "title": req.pr_title, + "body": req.pr_body, + "head": req.branch, + "base": self.base_branch, + "draft": true, + })) + .send() + .map_err(err)? + .error_for_status() + .map_err(err)? + .json::() + .map_err(err)?; + + Ok(PrResult { + url: pr["html_url"].as_str().unwrap_or_default().to_string(), + number: pr["number"].as_u64().unwrap_or_default(), + draft: pr["draft"].as_bool().unwrap_or(false), + }) + } +} diff --git a/lore-overlay/core/src/lib.rs b/lore-overlay/core/src/lib.rs new file mode 100644 index 00000000..2b8f9933 --- /dev/null +++ b/lore-overlay/core/src/lib.rs @@ -0,0 +1,41 @@ +//! `lore-capture-core` — the platform-agnostic "brain" of the Lore capture +//! overlay (provisional name; see `lore-overlay/README.md`). +//! +//! It implements the capture flow that the desktop shell (Tauri) wraps, and that +//! any other host could reuse: take an author's raw intent, draft a typed +//! artifact through a bring-your-own gateway, validate it deterministically with +//! the `rac` engine, and **propose** it as a draft pull request. It never lands +//! anything. +//! +//! ## The two-gate write model (ADR-077) +//! +//! - **Gate 1 — fidelity.** [`CaptureFlow::propose`] turns intent into a +//! [`Proposal`] the author confirms in the host UI. This is a data-quality +//! check, not a trust boundary; no file is written and nothing is pushed. +//! - **Gate 2 — trust boundary.** [`CaptureFlow::publish`] writes the artifact, +//! validates it, and opens a **draft** pull request. The independent +//! maintainer's merge is the trust boundary. The [`Publisher`] trait has no +//! approve/merge method by construction — the host can only *propose*. +//! +//! The model call (gateway) and git writes (publisher) live behind traits so the +//! core is testable offline; the concrete network implementations are compiled +//! only under the `net` feature. + +mod config; +mod error; +mod flow; +mod gateway; +mod github; +mod rac; + +pub use config::{Config, GatewayConfig, RepoConfig}; +pub use error::CaptureError; +pub use flow::{parse_minted_id, CaptureFlow, CaptureOutcome, Proposal}; +pub use gateway::{DraftedArtifact, Gateway}; +pub use github::{PrResult, ProposalRequest, Publisher}; +pub use rac::{Rac, RacClient}; + +#[cfg(feature = "net")] +pub use gateway::OpenAiGateway; +#[cfg(feature = "net")] +pub use github::GithubPublisher; diff --git a/lore-overlay/core/src/rac.rs b/lore-overlay/core/src/rac.rs new file mode 100644 index 00000000..c07b3f5b --- /dev/null +++ b/lore-overlay/core/src/rac.rs @@ -0,0 +1,84 @@ +use crate::error::CaptureError; +use std::process::Command; + +/// The deterministic engine seam. The core shells to the `rac` CLI rather than +/// reimplementing classification or validation (ADR-063: thin client over the +/// published contract; ADR-002/067: no AI in the engine). +pub trait Rac { + /// The JSON schema for an artifact type (`rac schema --json`). + fn schema(&self, artifact_type: &str) -> Result; + /// Scaffold a new artifact (`rac new `), minting the opaque id. + /// Returns the command's stdout, which reports the minted id. + fn new_artifact(&self, artifact_type: &str, path: &str) -> Result; + /// Deterministic close (`rac validate `). `Ok(())` iff the file is valid. + fn validate(&self, path: &str) -> Result<(), CaptureError>; +} + +/// Shells out to a real `rac` binary. +/// +/// `program` plus `base_args` are prefixed before every subcommand, so a wrapper +/// invocation (`env PYTHONPATH=… python racrun.py`) works as well as a bare +/// `rac` on `PATH`. +#[derive(Clone, Debug)] +pub struct RacClient { + program: String, + base_args: Vec, +} + +impl RacClient { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + base_args: Vec::new(), + } + } + + /// Extra args inserted before every subcommand. + pub fn with_base_args(mut self, args: Vec) -> Self { + self.base_args = args; + self + } + + fn run(&self, args: &[&str]) -> Result { + Command::new(&self.program) + .args(&self.base_args) + .args(args) + .output() + .map_err(|e| CaptureError::Rac(format!("failed to run `{}`: {e}", self.program))) + } +} + +impl Rac for RacClient { + fn schema(&self, artifact_type: &str) -> Result { + let out = self.run(&["schema", artifact_type, "--json"])?; + if !out.status.success() { + return Err(CaptureError::Rac(stderr(&out))); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + fn new_artifact(&self, artifact_type: &str, path: &str) -> Result { + let out = self.run(&["new", artifact_type, path])?; + if !out.status.success() { + return Err(CaptureError::Rac(stderr(&out))); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + fn validate(&self, path: &str) -> Result<(), CaptureError> { + let out = self.run(&["validate", path])?; + if out.status.success() { + Ok(()) + } else { + Err(CaptureError::Rac(format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + stderr(&out) + ))) + } + } +} + +fn stderr(out: &std::process::Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} diff --git a/lore-overlay/core/tests/flow_test.rs b/lore-overlay/core/tests/flow_test.rs new file mode 100644 index 00000000..fd20f1d4 --- /dev/null +++ b/lore-overlay/core/tests/flow_test.rs @@ -0,0 +1,199 @@ +//! Hermetic tests for the capture flow. +//! +//! The three external seams (rac / gateway / GitHub) are faked, so the suite runs +//! offline and deterministically while still exercising the real logic: schema → +//! draft → scaffold → fill-keeping-frontmatter → validate → open a *draft* PR. +//! One extra test exercises the real `RacClient` shell when `LORE_TEST_RAC` is set. + +use lore_capture_core::{ + parse_minted_id, CaptureError, CaptureFlow, DraftedArtifact, Gateway, PrResult, + ProposalRequest, Publisher, Rac, RacClient, RepoConfig, +}; +use std::cell::RefCell; + +/// Emulates `rac new` by writing a real scaffold (frontmatter + minted id), and +/// `rac validate` by a trivial structural check — enough to drive the flow. +struct FakeRac; +impl Rac for FakeRac { + fn schema(&self, _t: &str) -> Result { + Ok(r#"{"required":["context","decision","consequences"]}"#.to_string()) + } + fn new_artifact(&self, _t: &str, path: &str) -> Result { + let scaffold = + "---\nschema_version: 1\nid: RAC-FAKE12345\ntype: decision\n---\n# Title\n\n## Context\n\nTODO\n"; + std::fs::write(path, scaffold)?; + Ok("Created decision artifact: x\nID: RAC-FAKE12345\n".to_string()) + } + fn validate(&self, path: &str) -> Result<(), CaptureError> { + let s = std::fs::read_to_string(path)?; + if s.starts_with("---\n") && s.contains("\n# ") { + Ok(()) + } else { + Err(CaptureError::Rac("invalid".into())) + } + } +} + +struct FakeGateway; +impl Gateway for FakeGateway { + fn draft( + &self, + _t: &str, + _schema: &str, + intent: &str, + ) -> Result { + Ok(DraftedArtifact { + title: "ADR-099: Example Decision".to_string(), + body: format!( + "## Context\n\n{intent}\n\n## Decision\n\nWe will do the thing.\n\n## Consequences\n\nTrade-offs accepted." + ), + }) + } +} + +/// Records the request it was asked to publish, and always returns a draft PR. +struct FakePublisher { + seen: RefCell>, +} +impl Publisher for FakePublisher { + fn open_draft_pr(&self, req: &ProposalRequest) -> Result { + *self.seen.borrow_mut() = Some(req.clone()); + Ok(PrResult { + url: "https://github.com/itsthelore/rac-core/pull/999".to_string(), + number: 999, + draft: true, + }) + } +} + +/// A publisher that (incorrectly) reports a non-draft PR — the flow must refuse it. +struct NonDraftPublisher; +impl Publisher for NonDraftPublisher { + fn open_draft_pr(&self, _req: &ProposalRequest) -> Result { + Ok(PrResult { + url: "x".to_string(), + number: 1, + draft: false, + }) + } +} + +fn repo() -> RepoConfig { + RepoConfig { + owner: "itsthelore".to_string(), + repo: "rac-core".to_string(), + base_branch: "main".to_string(), + } +} + +fn temp_path(name: &str) -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("lore-overlay-test-{}-{}", std::process::id(), name)); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("artifact.md") +} + +#[test] +fn propose_then_publish_keeps_frontmatter_and_opens_a_draft_pr() { + let path = temp_path("happy"); + let path_str = path.to_str().unwrap().to_string(); + + let flow = CaptureFlow::new( + FakeRac, + FakeGateway, + FakePublisher { + seen: RefCell::new(None), + }, + repo(), + ); + + // Gate 1: propose. No file yet. + let proposal = flow + .propose("decision", "We decided to adopt the capture overlay.") + .unwrap(); + assert!(proposal.title.starts_with("ADR-099")); + assert!(proposal + .body + .contains("We decided to adopt the capture overlay.")); + assert!(!path.exists(), "propose() must not write the artifact file"); + + // Gate 2 prep: publish opens a draft PR. + let outcome = flow + .publish( + &proposal, + &path_str, + "capture/adr-099", + Some("Co-authored-by: Author "), + ) + .unwrap(); + + assert_eq!(outcome.minted_id, "RAC-FAKE12345"); + assert!(outcome.pr.draft, "capture must open a DRAFT pull request"); + assert_eq!(outcome.pr.number, 999); + + // The written file keeps the minted frontmatter and uses the drafted body. + let written = std::fs::read_to_string(&path).unwrap(); + assert!( + written.starts_with("---\nschema_version: 1\nid: RAC-FAKE12345\ntype: decision\n---\n"), + "frontmatter (and the minted id) must be preserved, got:\n{written}" + ); + assert!(written.contains("# ADR-099: Example Decision")); + assert!(written.contains("## Decision")); + assert!( + !written.contains("# Title"), + "the scaffold's placeholder title must be replaced" + ); + + // The PR body carries the co-author trailer and the published content matches. + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn publish_refuses_a_non_draft_pull_request() { + let path = temp_path("nondraft"); + let path_str = path.to_str().unwrap().to_string(); + let flow = CaptureFlow::new(FakeRac, FakeGateway, NonDraftPublisher, repo()); + let proposal = flow.propose("decision", "something").unwrap(); + let err = flow + .publish(&proposal, &path_str, "capture/x", None) + .unwrap_err(); + match err { + CaptureError::Publish(m) => assert!(m.contains("DRAFT")), + other => panic!("expected a Publish error, got {other:?}"), + } + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn parses_minted_id_from_rac_new_output() { + assert_eq!( + parse_minted_id("Created decision artifact: x\nID: RAC-ABC123\nEdit the TODOs"), + Some("RAC-ABC123".to_string()) + ); + assert_eq!( + parse_minted_id("scaffolded RAC-XYZ789."), + Some("RAC-XYZ789".to_string()) + ); + assert_eq!(parse_minted_id("no id here"), None); +} + +/// Exercises the real `RacClient` shell against an actual `rac` when configured. +/// `LORE_TEST_RAC` is a whitespace-separated command, e.g. +/// `env PYTHONPATH=/abs/src python /abs/racrun.py`. Skipped (passes) when unset, +/// so the suite stays hermetic by default. +#[test] +fn real_rac_client_reads_schema_when_configured() { + let Ok(cmd) = std::env::var("LORE_TEST_RAC") else { + eprintln!("skipping: set LORE_TEST_RAC to exercise the real rac shell"); + return; + }; + let mut parts = cmd.split_whitespace(); + let program = parts.next().expect("LORE_TEST_RAC is empty").to_string(); + let base_args: Vec = parts.map(|s| s.to_string()).collect(); + let rac = RacClient::new(program).with_base_args(base_args); + let schema = Rac::schema(&rac, "decision").expect("rac schema decision --json"); + assert!( + schema.contains("decision") || schema.contains("required"), + "unexpected schema output: {schema}" + ); +} From b3a38d5d5745feadf21eba181ca9602cefae645b Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 24 Jun 2026 07:23:05 +0000 Subject: [PATCH 2/6] feat(overlay): scaffold the Tauri desktop shell and docs The macOS-first Tauri v2 shell that wraps the verified core: a global hotkey summons a modal that runs propose/publish as Tauri commands. Authored as a scaffold and NOT built/run here (it targets macOS and was developed in a Linux container); the README states clearly what is verified (the core) vs not (the desktop build/sign). Provisional product name. --- lore-overlay/README.md | 118 +++++++++++++++ lore-overlay/app/package.json | 16 ++ lore-overlay/app/src-tauri/Cargo.toml | 22 +++ lore-overlay/app/src-tauri/build.rs | 3 + lore-overlay/app/src-tauri/src/lib.rs | 164 +++++++++++++++++++++ lore-overlay/app/src-tauri/src/main.rs | 6 + lore-overlay/app/src-tauri/tauri.conf.json | 34 +++++ lore-overlay/app/src/index.html | 40 +++++ lore-overlay/app/src/main.js | 58 ++++++++ lore-overlay/app/src/styles.css | 66 +++++++++ 10 files changed, 527 insertions(+) create mode 100644 lore-overlay/README.md create mode 100644 lore-overlay/app/package.json create mode 100644 lore-overlay/app/src-tauri/Cargo.toml create mode 100644 lore-overlay/app/src-tauri/build.rs create mode 100644 lore-overlay/app/src-tauri/src/lib.rs create mode 100644 lore-overlay/app/src-tauri/src/main.rs create mode 100644 lore-overlay/app/src-tauri/tauri.conf.json create mode 100644 lore-overlay/app/src/index.html create mode 100644 lore-overlay/app/src/main.js create mode 100644 lore-overlay/app/src/styles.css diff --git a/lore-overlay/README.md b/lore-overlay/README.md new file mode 100644 index 00000000..b1ff1af9 --- /dev/null +++ b/lore-overlay/README.md @@ -0,0 +1,118 @@ +# Lore Capture Overlay (MVP — provisional name) + +> **Name is provisional.** "overlay" is a working title; the product name is +> still TBD. The corpus artifacts use `lore-overlay` / `lore-capture-overlay`; +> rename here and in `rac/designs/lore-capture-overlay.md` + +> `rac/roadmaps/future/lore-overlay.md` when the product name is settled. + +A small, always-one-keystroke-away desktop app that lets someone capture a +decision or requirement **mid-task**: press a global hotkey, a modal appears over +whatever they're doing, they talk it through, and it becomes a **proposed** +artifact — a draft pull request — without leaving their current app, learning +Markdown, or touching git. + +This is **Host B** of `rac/designs/lore-capture-surfaces.md`; the architecture is +`rac/designs/lore-capture-overlay.md` and the build plan is +`rac/roadmaps/future/lore-overlay.md`. + +It is a `lore-*` product (ADR-068) developed here as a **staging directory** in +`rac-core`, to be extracted to its own `itsthelore/lore-overlay` repo later — the +same develop-in-repo-then-extract pattern used for the VS Code extension. + +## Two surfaces: a verified brain and a scaffolded shell + +``` +lore-overlay/ + core/ the platform-agnostic capture "brain" — Rust library, FULLY TESTED here + app/ the Tauri v2 desktop shell — authored, NOT built here (needs macOS) +``` + +**The skill is the brain; the host is the interface.** `core/` implements the +whole capture flow over three trait seams — the `rac` engine, the model gateway, +and the GitHub writer — so it is exercised offline with fakes and against the real +`rac`. `app/` is the thin desktop shell (global hotkey → modal → invoke the core). + +### The two-gate write model (ADR-077) + +- **Gate 1 — fidelity.** `CaptureFlow::propose` turns the author's words into a + proposal they confirm in the modal. No file is written, nothing is pushed. +- **Gate 2 — trust boundary.** `CaptureFlow::publish` writes the artifact, + validates it with `rac`, and opens a **draft** pull request. An independent + maintainer's merge is the trust boundary. The `Publisher` trait has **no + approve/merge method by construction**, and the flow refuses any non-draft PR — + so a host built on this core cannot self-approve. + +### Bring-your-own gateway (ADR-035) + +The model call goes through a configurable **OpenAI-compatible** endpoint +(`base_url` + key + model) — a self-hosted LiteLLM proxy, a cloud vendor, or a +local model. No AI runs in the engine (ADR-002/067); the key lives in the OS +secret store, not in any serialized config. + +## What is verified vs not + +This MVP was developed in a Linux container, which **cannot build, run, sign, or +notarize a macOS app**. So: + +| Part | Status | How checked | +| --- | --- | --- | +| `core/` logic (flow, fill-keeping-frontmatter, id parse, draft-PR guard) | **Verified** | `cargo test` — 4 hermetic tests pass | +| `core/` ↔ real `rac` shell | **Verified** | `LORE_TEST_RAC=… cargo test` exercises `rac schema` | +| `core/` network clients (gateway + GitHub) | **Compiles** | `cargo check --features net` | +| `app/` Tauri desktop shell (hotkey, panel, build, sign) | **Not built / not run** | requires macOS; authored as a scaffold only | + +## Build & run + +### Core (works anywhere with Rust) + +```bash +cd lore-overlay/core +cargo test # hermetic suite +cargo check --features net # compile the gateway + GitHub clients +# exercise the real rac shell: +LORE_TEST_RAC="rac" cargo test real_rac_client_reads_schema_when_configured +``` + +### Desktop app (macOS first; needs a Mac) + +Prerequisites: Rust, Node + npm, the Tauri CLI (`cargo install tauri-cli`), and +Xcode command-line tools. + +```bash +cd lore-overlay/app +npm install +cargo tauri dev # run the dev build +cargo tauri build # produce a .app / .dmg +``` + +Signing & notarization (required for distribution outside the App Store): sign +with a **Developer ID** certificate and notarize with `notarytool`. Windows +(fast-follow) uses **Authenticode** — practically **Azure Trusted Signing** — plus +the SmartScreen-reputation ramp and a bundled/bootstrapped **WebView2** runtime. + +## Configuration + +`core::Config` holds everything the app needs: + +- `gateway` — `base_url`, `model`, and an `api_key` (read from the OS secret + store at runtime; never serialized). +- `repo` — `owner`, `repo`, `base_branch` (default `main`). +- `hotkey` — Tauri accelerator (default `CmdOrCtrl+Shift+L`). +- `rac_command` — how to invoke the engine (bundled, on `PATH`, or a wrapper). + +## Open questions (from the design) + +- **Desktop GitHub-App auth** — the OAuth **device flow** to install the App and + obtain an installation token, and where that token is cached on-device. The + core takes a bearer token; acquiring it is the shell's job. +- **Embed vs shell `rac`** — bundle the CLI with the app, require it on `PATH`, or + consume the TypeScript SDK once published. Thin-client either way (ADR-063). +- **Offline** — capture-and-queue when there's no network; open the draft PR on + reconnect. + +## Corpus pointers + +- Design (the *how*): `rac/designs/lore-capture-overlay.md` +- Build plan: `rac/roadmaps/future/lore-overlay.md` +- Trust model: `rac/decisions/adr-077-two-gate-capture-write-model.md` +- Shared pipeline it reuses: `rac/designs/lore-slack-capture-flow.md` diff --git a/lore-overlay/app/package.json b/lore-overlay/app/package.json new file mode 100644 index 00000000..41c15941 --- /dev/null +++ b/lore-overlay/app/package.json @@ -0,0 +1,16 @@ +{ + "name": "lore-capture-overlay-app", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Frontend for the Lore capture overlay (provisional name). Authored scaffold; add a bundler (Vite) before running.", + "scripts": { + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2" + }, + "devDependencies": { + "@tauri-apps/cli": "^2" + } +} diff --git a/lore-overlay/app/src-tauri/Cargo.toml b/lore-overlay/app/src-tauri/Cargo.toml new file mode 100644 index 00000000..54b689a0 --- /dev/null +++ b/lore-overlay/app/src-tauri/Cargo.toml @@ -0,0 +1,22 @@ +# Tauri v2 desktop shell. NOTE: not built or run in this repo's CI — it targets +# macOS and was developed in a Linux container. The capture logic it depends on +# (`lore-capture-core`) is fully tested. See ../../README.md. +[package] +name = "lore-capture-overlay" +version = "0.0.0" +edition = "2021" +description = "Desktop capture overlay for Lore (provisional name)." + +[lib] +name = "lore_capture_overlay_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-global-shortcut = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +lore-capture-core = { path = "../../core", features = ["net"] } diff --git a/lore-overlay/app/src-tauri/build.rs b/lore-overlay/app/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/lore-overlay/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/lore-overlay/app/src-tauri/src/lib.rs b/lore-overlay/app/src-tauri/src/lib.rs new file mode 100644 index 00000000..11fc43f5 --- /dev/null +++ b/lore-overlay/app/src-tauri/src/lib.rs @@ -0,0 +1,164 @@ +//! Tauri v2 desktop shell for the Lore capture overlay (provisional name). +//! +//! **Not built or run in this repo.** It targets macOS (a non-activating +//! `NSPanel`, Developer ID signing) and was developed in a Linux container, so +//! the exact plugin API calls below are an authored scaffold, not a compiled +//! surface. The capture logic it calls — [`lore_capture_core`] — is fully tested. +//! +//! The shell's only job is the desktop affordance: a global hotkey summons a +//! modal, which runs the two-gate capture flow via the [`propose`] and [`publish`] +//! commands. All model and git work happens in the core, behind a +//! bring-your-own gateway. + +use lore_capture_core::{ + CaptureFlow, Config, GatewayConfig, GithubPublisher, OpenAiGateway, Proposal, RacClient, + RepoConfig, +}; +use tauri::{Manager, WebviewWindow}; + +// --- configuration / secrets (scaffold) ------------------------------------- + +/// Load configuration. In the shipped app this reads persisted settings plus the +/// gateway key and a GitHub installation token from the **OS secret store**; here +/// it reads env vars so the wiring type-checks. TODO: real settings + keychain. +fn load_config() -> Config { + Config { + gateway: GatewayConfig { + base_url: env("LORE_GATEWAY_URL"), + model: env("LORE_GATEWAY_MODEL"), + api_key: env("LORE_GATEWAY_KEY"), + }, + repo: RepoConfig { + owner: env("LORE_REPO_OWNER"), + repo: env("LORE_REPO_NAME"), + base_branch: std::env::var("LORE_REPO_BASE").unwrap_or_else(|_| "main".into()), + }, + hotkey: std::env::var("LORE_HOTKEY").unwrap_or_else(|_| "CmdOrCtrl+Shift+L".into()), + rac_command: std::env::var("LORE_RAC").unwrap_or_else(|_| "rac".into()), + } +} + +/// The GitHub bearer token — a GitHub App installation token (obtained via the +/// device flow) in the shipped app; a PAT works for development. +fn github_token() -> String { + env("LORE_GITHUB_TOKEN") +} + +fn env(key: &str) -> String { + std::env::var(key).unwrap_or_default() +} + +fn build_flow(cfg: &Config) -> CaptureFlow { + CaptureFlow::new( + RacClient::new(cfg.rac_command.clone()), + OpenAiGateway::new(&cfg.gateway), + GithubPublisher::new( + cfg.repo.owner.clone(), + cfg.repo.repo.clone(), + cfg.repo.base_branch.clone(), + github_token(), + ), + cfg.repo.clone(), + ) +} + +// --- the two gates, as Tauri commands --------------------------------------- + +#[derive(serde::Serialize)] +struct ProposalView { + artifact_type: String, + title: String, + body: String, +} + +/// Gate 1 — fidelity: draft a proposal from the author's words. No file written. +#[tauri::command] +fn propose(artifact_type: String, intent: String) -> Result { + let cfg = load_config(); + let flow = build_flow(&cfg); + let p = flow + .propose(&artifact_type, &intent) + .map_err(|e| e.to_string())?; + Ok(ProposalView { + artifact_type: p.artifact_type, + title: p.title, + body: p.body, + }) +} + +#[derive(serde::Serialize)] +struct OutcomeView { + path: String, + minted_id: String, + pr_url: String, +} + +/// Gate 2 prep: after the author confirms, write + validate + open a DRAFT pull +/// request. The core refuses any non-draft PR; the independent merge is Gate 2. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +fn publish( + artifact_type: String, + title: String, + body: String, + dest_path: String, + branch: String, + coauthor: Option, +) -> Result { + let cfg = load_config(); + let flow = build_flow(&cfg); + let proposal = Proposal { + artifact_type, + title, + body, + }; + let outcome = flow + .publish(&proposal, &dest_path, &branch, coauthor.as_deref()) + .map_err(|e| e.to_string())?; + Ok(OutcomeView { + path: outcome.path, + minted_id: outcome.minted_id, + pr_url: outcome.pr.url, + }) +} + +// --- desktop shell: a global hotkey toggles the capture modal ---------------- + +fn toggle(window: &WebviewWindow) { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } +} + +pub fn run() { + use tauri_plugin_global_shortcut::{ + Builder as ShortcutBuilder, GlobalShortcutExt, ShortcutState, + }; + + tauri::Builder::default() + .plugin( + ShortcutBuilder::new() + .with_handler(|app, _shortcut, event| { + if event.state() == ShortcutState::Pressed { + if let Some(win) = app.get_webview_window("capture") { + toggle(&win); + } + } + }) + .build(), + ) + .setup(|app| { + let hotkey = load_config().hotkey; + app.global_shortcut().register(hotkey.as_str())?; + // macOS enhancement (TODO): promote the "capture" window to a + // non-activating NSPanel that joins all Spaces, so the overlay never + // steals focus from the app the author is working in. + Ok(()) + }) + .invoke_handler(tauri::generate_handler![propose, publish]) + .run(tauri::generate_context!()) + .expect("error while running the Lore capture overlay"); +} diff --git a/lore-overlay/app/src-tauri/src/main.rs b/lore-overlay/app/src-tauri/src/main.rs new file mode 100644 index 00000000..e60b9def --- /dev/null +++ b/lore-overlay/app/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Desktop entrypoint. Not built/run in this repo — see ../../README.md. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + lore_capture_overlay_lib::run() +} diff --git a/lore-overlay/app/src-tauri/tauri.conf.json b/lore-overlay/app/src-tauri/tauri.conf.json new file mode 100644 index 00000000..a222e461 --- /dev/null +++ b/lore-overlay/app/src-tauri/tauri.conf.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Lore Capture", + "version": "0.0.0", + "identifier": "io.github.tcballard.lore-capture", + "build": { + "frontendDist": "../src" + }, + "app": { + "windows": [ + { + "label": "capture", + "title": "Lore Capture", + "width": 560, + "height": 440, + "resizable": false, + "visible": false, + "alwaysOnTop": true, + "center": true, + "skipTaskbar": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "category": "Productivity", + "shortDescription": "Capture a decision from anywhere into Lore.", + "icon": ["icons/icon.png"] + } +} diff --git a/lore-overlay/app/src/index.html b/lore-overlay/app/src/index.html new file mode 100644 index 00000000..61f9addf --- /dev/null +++ b/lore-overlay/app/src/index.html @@ -0,0 +1,40 @@ + + + + + + Lore Capture + + + +
+

Capture a decision

+ + +
+ +
+ +
+
+ + + + +

+
+ + + diff --git a/lore-overlay/app/src/main.js b/lore-overlay/app/src/main.js new file mode 100644 index 00000000..09ed0ff6 --- /dev/null +++ b/lore-overlay/app/src/main.js @@ -0,0 +1,58 @@ +// Minimal two-gate capture UI. Authored scaffold — needs a bundler (e.g. Vite) +// to resolve the @tauri-apps/api import; see ../../README.md. +import { invoke } from "@tauri-apps/api/core"; + +const $ = (id) => document.getElementById(id); +let current = null; // the proposed { artifact_type, title, body } + +$("propose").addEventListener("click", async () => { + const intent = $("intent").value.trim(); + if (!intent) return; + setResult("Drafting…"); + try { + // Gate 1 prep: propose. No file is written yet. + current = await invoke("propose", { artifactType: "decision", intent }); + $("title").value = current.title; + $("body").value = current.body; + $("capture").hidden = true; + $("review").hidden = false; + setResult(""); + } catch (e) { + setResult("Couldn't draft: " + e); + } +}); + +$("back").addEventListener("click", () => { + $("review").hidden = true; + $("capture").hidden = false; +}); + +$("publish").addEventListener("click", async () => { + if (!current) return; + const title = $("title").value.trim(); + const body = $("body").value; + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 60); + setResult("Opening a draft pull request…"); + try { + // Gate 2 prep: write + validate + open a DRAFT PR (independent merge lands it). + const outcome = await invoke("publish", { + artifactType: current.artifact_type, + title, + body, + destPath: `rac/decisions/${slug}.md`, + branch: `capture/${slug}`, + coauthor: null, + }); + setResult(`Proposed as ${outcome.minted_id} — review & merge: ${outcome.pr_url}`); + } catch (e) { + setResult("Couldn't publish: " + e); + } +}); + +function setResult(msg) { + $("result").textContent = msg; +} diff --git a/lore-overlay/app/src/styles.css b/lore-overlay/app/src/styles.css new file mode 100644 index 00000000..3f97d95f --- /dev/null +++ b/lore-overlay/app/src/styles.css @@ -0,0 +1,66 @@ +:root { + color-scheme: light dark; + font: 14px/1.5 system-ui, -apple-system, sans-serif; +} +body { + margin: 0; +} +#app { + padding: 16px 18px; +} +h1 { + font-size: 16px; + margin: 0 0 10px; +} +textarea, +input { + width: 100%; + box-sizing: border-box; + font: inherit; + padding: 8px; + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + border-radius: 6px; + background: transparent; + color: inherit; +} +label { + display: block; + margin: 8px 0; + font-weight: 600; +} +label input, +label textarea { + margin-top: 4px; + font-weight: 400; +} +.row { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} +button { + font: inherit; + padding: 7px 12px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + background: transparent; + color: inherit; + cursor: pointer; +} +button.primary { + background: #2563eb; + border-color: #2563eb; + color: #fff; +} +.hint { + font-size: 12px; + opacity: 0.8; + margin: 0 0 8px; +} +#result { + margin-top: 12px; + font-size: 13px; + opacity: 0.9; + word-break: break-all; +} From d7a1cefcaebaf04ee88af9d4bf317246ab751288 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 24 Jun 2026 16:48:39 +0000 Subject: [PATCH 3/6] feat(overlay): render the draft body with an edit/preview toggle Adds a Preview/Edit segmented control to the Gate-1 review step so the drafted artifact body reads as normal text by default, with a raw editable view a click away. The textarea remains the source of truth for publish. The renderer is a small dependency-free markdown subset (headings, bold, italic, inline code, lists, paragraphs) and escapes HTML before rendering, keeping artifact content treated as untrusted input (ADR-065). --- lore-overlay/app/src/index.html | 14 +++++- lore-overlay/app/src/main.js | 83 ++++++++++++++++++++++++++++++++- lore-overlay/app/src/styles.css | 70 +++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/lore-overlay/app/src/index.html b/lore-overlay/app/src/index.html index 61f9addf..029a2aff 100644 --- a/lore-overlay/app/src/index.html +++ b/lore-overlay/app/src/index.html @@ -26,7 +26,19 @@

Capture a decision

independent maintainer reviews and merges the pull request.

- +
+
+ Body +
+ + +
+
+ +
+ + +
diff --git a/lore-overlay/app/src/main.js b/lore-overlay/app/src/main.js index 09ed0ff6..0a3c9e9c 100644 --- a/lore-overlay/app/src/main.js +++ b/lore-overlay/app/src/main.js @@ -13,7 +13,8 @@ $("propose").addEventListener("click", async () => { // Gate 1 prep: propose. No file is written yet. current = await invoke("propose", { artifactType: "decision", intent }); $("title").value = current.title; - $("body").value = current.body; + $("body").value = current.body; // the textarea stays the source of truth + showPreview(); // default to the rendered view $("capture").hidden = true; $("review").hidden = false; setResult(""); @@ -27,6 +28,86 @@ $("back").addEventListener("click", () => { $("capture").hidden = false; }); +// --- Body view toggle: rendered "Preview" (default) vs raw "Edit" ----------- + +function showPreview() { + $("body-preview").innerHTML = renderMarkdown($("body").value); + $("body-preview").hidden = false; + $("body").hidden = true; + $("tab-preview").setAttribute("aria-selected", "true"); + $("tab-edit").setAttribute("aria-selected", "false"); +} + +function showEdit() { + $("body").hidden = false; + $("body-preview").hidden = true; + $("tab-edit").setAttribute("aria-selected", "true"); + $("tab-preview").setAttribute("aria-selected", "false"); + $("body").focus(); +} + +$("tab-preview").addEventListener("click", showPreview); +$("tab-edit").addEventListener("click", showEdit); + +// Minimal, dependency-free markdown → HTML. Artifact content is untrusted +// (ADR-065), so escape first, then apply a small, safe subset. +function renderMarkdown(src) { + const esc = src.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); + const inline = (s) => + s + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1"); + let html = ""; + let list = null; + let para = []; + const closeList = () => { + if (list) { + html += ``; + list = null; + } + }; + const flushPara = () => { + if (para.length) { + html += `

${inline(para.join(" "))}

`; + para = []; + } + }; + for (const line of esc.split("\n")) { + let m; + if (/^\s*$/.test(line)) { + flushPara(); + closeList(); + } else if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { + flushPara(); + closeList(); + html += `${inline(m[2])}`; + } else if ((m = line.match(/^\s*[-*]\s+(.*)$/))) { + flushPara(); + if (list !== "ul") { + closeList(); + html += "
    "; + list = "ul"; + } + html += `
  • ${inline(m[1])}
  • `; + } else if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) { + flushPara(); + if (list !== "ol") { + closeList(); + html += "
      "; + list = "ol"; + } + html += `
    1. ${inline(m[1])}
    2. `; + } else { + closeList(); + para.push(line.trim()); + } + } + flushPara(); + closeList(); + return html; +} + $("publish").addEventListener("click", async () => { if (!current) return; const title = $("title").value.trim(); diff --git a/lore-overlay/app/src/styles.css b/lore-overlay/app/src/styles.css index 3f97d95f..c433086e 100644 --- a/lore-overlay/app/src/styles.css +++ b/lore-overlay/app/src/styles.css @@ -58,6 +58,76 @@ button.primary { opacity: 0.8; margin: 0 0 8px; } +.bodyfield { + margin: 8px 0; +} +.bodyhead { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} +.bodyhead .lbl { + font-weight: 600; +} +.seg { + display: inline-flex; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + border-radius: 7px; + overflow: hidden; +} +.seg-btn { + border: 0; + border-radius: 0; + padding: 3px 10px; + font-size: 12px; + opacity: 0.7; +} +.seg-btn[aria-selected="true"] { + background: color-mix(in srgb, currentColor 12%, transparent); + opacity: 1; + font-weight: 600; +} +/* Rendered markdown — deliberately quiet, so it reads as normal prose. */ +.rendered { + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + border-radius: 6px; + padding: 8px 10px; + min-height: 196px; + max-height: 280px; + overflow-y: auto; +} +.rendered h1, +.rendered h2, +.rendered h3 { + font-size: 13px; + font-weight: 700; + margin: 12px 0 4px; + text-transform: uppercase; + letter-spacing: 0.3px; + opacity: 0.65; +} +.rendered > :first-child { + margin-top: 0; +} +.rendered p { + margin: 0 0 8px; +} +.rendered ul, +.rendered ol { + margin: 0 0 8px; + padding-left: 20px; +} +.rendered li { + margin: 2px 0; +} +.rendered code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; + padding: 1px 4px; + border-radius: 4px; + background: color-mix(in srgb, currentColor 10%, transparent); +} #result { margin-top: 12px; font-size: 13px; From df939db36f3518a50e321b8ffe8b2543315d1f66 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 24 Jun 2026 16:57:41 +0000 Subject: [PATCH 4/6] feat(overlay): render the body as full CommonMark Replaces the hand-rolled markdown subset with markdown-it for full CommonMark fidelity in the Gate-1 body preview. Output is sanitized with DOMPurify before it touches innerHTML and markdown-it runs with html:false, keeping artifact content treated as untrusted input (ADR-065). Extends the rendered-view styles to cover blockquotes, fenced code, tables, links, images, and rules. --- lore-overlay/README.md | 11 ++++++ lore-overlay/app/package.json | 4 +- lore-overlay/app/src/main.js | 70 ++++++--------------------------- lore-overlay/app/src/styles.css | 48 +++++++++++++++++++++- 4 files changed, 74 insertions(+), 59 deletions(-) diff --git a/lore-overlay/README.md b/lore-overlay/README.md index b1ff1af9..573c011f 100644 --- a/lore-overlay/README.md +++ b/lore-overlay/README.md @@ -49,6 +49,17 @@ The model call goes through a configurable **OpenAI-compatible** endpoint local model. No AI runs in the engine (ADR-002/067); the key lives in the OS secret store, not in any serialized config. +### Rendered body (Gate 1) + +The review step shows the drafted body as **rendered CommonMark** by default, with +a one-click **Edit** toggle back to the raw source (the textarea stays the source +of truth for publish). Rendering uses **markdown-it** (`html: false`) and the +output is sanitized with **DOMPurify** before it touches `innerHTML` — artifact +content is untrusted input (ADR-065), so the render path must not become an +injection vector. These are the app's only frontend dependencies; `npm install` +pulls them, and the frontend needs a bundler (e.g. Vite) to resolve the +bare-module imports. + ## What is verified vs not This MVP was developed in a Linux container, which **cannot build, run, sign, or diff --git a/lore-overlay/app/package.json b/lore-overlay/app/package.json index 41c15941..de478d81 100644 --- a/lore-overlay/app/package.json +++ b/lore-overlay/app/package.json @@ -8,7 +8,9 @@ "tauri": "tauri" }, "dependencies": { - "@tauri-apps/api": "^2" + "@tauri-apps/api": "^2", + "dompurify": "^3", + "markdown-it": "^14" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/lore-overlay/app/src/main.js b/lore-overlay/app/src/main.js index 0a3c9e9c..69db9d63 100644 --- a/lore-overlay/app/src/main.js +++ b/lore-overlay/app/src/main.js @@ -1,6 +1,16 @@ // Minimal two-gate capture UI. Authored scaffold — needs a bundler (e.g. Vite) -// to resolve the @tauri-apps/api import; see ../../README.md. +// to resolve the bare-module imports; see ../../README.md. import { invoke } from "@tauri-apps/api/core"; +import MarkdownIt from "markdown-it"; +import DOMPurify from "dompurify"; + +// Full CommonMark rendering for the body preview. `html: false` escapes any raw +// HTML embedded in the artifact rather than passing it through, and the output is +// run through DOMPurify before it touches innerHTML — artifact content is +// untrusted input (ADR-065), so the render path must not become an injection +// vector. `linkify` turns bare URLs into links; markdown-it's own validateLink +// already blocks javascript:/vbscript:/data: schemes. +const md = new MarkdownIt({ html: false, linkify: true, typographer: true }); const $ = (id) => document.getElementById(id); let current = null; // the proposed { artifact_type, title, body } @@ -49,63 +59,9 @@ function showEdit() { $("tab-preview").addEventListener("click", showPreview); $("tab-edit").addEventListener("click", showEdit); -// Minimal, dependency-free markdown → HTML. Artifact content is untrusted -// (ADR-065), so escape first, then apply a small, safe subset. +// CommonMark → sanitized HTML for the rendered body view. function renderMarkdown(src) { - const esc = src.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); - const inline = (s) => - s - .replace(/`([^`]+)`/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/\*([^*]+)\*/g, "$1"); - let html = ""; - let list = null; - let para = []; - const closeList = () => { - if (list) { - html += ``; - list = null; - } - }; - const flushPara = () => { - if (para.length) { - html += `

      ${inline(para.join(" "))}

      `; - para = []; - } - }; - for (const line of esc.split("\n")) { - let m; - if (/^\s*$/.test(line)) { - flushPara(); - closeList(); - } else if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { - flushPara(); - closeList(); - html += `${inline(m[2])}`; - } else if ((m = line.match(/^\s*[-*]\s+(.*)$/))) { - flushPara(); - if (list !== "ul") { - closeList(); - html += "
        "; - list = "ul"; - } - html += `
      • ${inline(m[1])}
      • `; - } else if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) { - flushPara(); - if (list !== "ol") { - closeList(); - html += "
          "; - list = "ol"; - } - html += `
        1. ${inline(m[1])}
        2. `; - } else { - closeList(); - para.push(line.trim()); - } - } - flushPara(); - closeList(); - return html; + return DOMPurify.sanitize(md.render(src)); } $("publish").addEventListener("click", async () => { diff --git a/lore-overlay/app/src/styles.css b/lore-overlay/app/src/styles.css index c433086e..e2d17e45 100644 --- a/lore-overlay/app/src/styles.css +++ b/lore-overlay/app/src/styles.css @@ -99,7 +99,10 @@ button.primary { } .rendered h1, .rendered h2, -.rendered h3 { +.rendered h3, +.rendered h4, +.rendered h5, +.rendered h6 { font-size: 13px; font-weight: 700; margin: 12px 0 4px; @@ -121,6 +124,38 @@ button.primary { .rendered li { margin: 2px 0; } +.rendered li > ul, +.rendered li > ol { + margin: 2px 0; +} +.rendered a { + color: #2563eb; +} +.rendered img { + max-width: 100%; + border-radius: 6px; +} +.rendered hr { + border: 0; + border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent); + margin: 12px 0; +} +.rendered blockquote { + margin: 0 0 8px; + padding: 2px 0 2px 10px; + border-left: 3px solid color-mix(in srgb, currentColor 25%, transparent); + opacity: 0.85; +} +.rendered table { + border-collapse: collapse; + margin: 0 0 8px; +} +.rendered th, +.rendered td { + border: 1px solid color-mix(in srgb, currentColor 20%, transparent); + padding: 3px 8px; + text-align: left; +} .rendered code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; @@ -128,6 +163,17 @@ button.primary { border-radius: 4px; background: color-mix(in srgb, currentColor 10%, transparent); } +.rendered pre { + margin: 0 0 8px; + padding: 8px 10px; + overflow-x: auto; + border-radius: 6px; + background: color-mix(in srgb, currentColor 8%, transparent); +} +.rendered pre code { + padding: 0; + background: none; +} #result { margin-top: 12px; font-size: 13px; From 6350c6964878c2f81286dd4c51383794c4619f24 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 24 Jun 2026 17:16:10 +0000 Subject: [PATCH 5/6] feat(overlay): configurable write modes (per-capture, rolling, direct) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouples PR granularity from capture cadence. The PR is the trust boundary (ADR-077 Gate 2), not the save action, so its granularity is configurable without weakening the model: - per-capture: a branch and draft PR per capture (current behaviour) - rolling (default): append to a shared capture branch with one batch draft PR, so a maintainer reviews on their cadence, not per artifact - direct: commit to a branch, no PR (a solo repo with no independent reviewer) Splits the Publisher seam into commit_file + ensure_draft_pr — still no approve/merge method by construction, so the two-gate property holds in every mode. Adds a read-only footer showing where a capture lands (repo, branch, mode). Hermetic tests cover all three modes (6 passing). --- lore-overlay/app/src-tauri/src/lib.rs | 76 +++++++-- lore-overlay/app/src/index.html | 9 ++ lore-overlay/app/src/main.js | 26 ++- lore-overlay/app/src/styles.css | 19 +++ lore-overlay/core/src/config.rs | 31 ++++ lore-overlay/core/src/flow.rs | 96 ++++++++---- lore-overlay/core/src/github.rs | 218 ++++++++++++++++++-------- lore-overlay/core/src/lib.rs | 13 +- lore-overlay/core/tests/flow_test.rs | 156 +++++++++++++++--- 9 files changed, 506 insertions(+), 138 deletions(-) diff --git a/lore-overlay/app/src-tauri/src/lib.rs b/lore-overlay/app/src-tauri/src/lib.rs index 11fc43f5..bc5df898 100644 --- a/lore-overlay/app/src-tauri/src/lib.rs +++ b/lore-overlay/app/src-tauri/src/lib.rs @@ -12,7 +12,7 @@ use lore_capture_core::{ CaptureFlow, Config, GatewayConfig, GithubPublisher, OpenAiGateway, Proposal, RacClient, - RepoConfig, + RepoConfig, WriteMode, }; use tauri::{Manager, WebviewWindow}; @@ -35,6 +35,33 @@ fn load_config() -> Config { }, hotkey: std::env::var("LORE_HOTKEY").unwrap_or_else(|_| "CmdOrCtrl+Shift+L".into()), rac_command: std::env::var("LORE_RAC").unwrap_or_else(|_| "rac".into()), + write_mode: match std::env::var("LORE_WRITE_MODE").as_deref() { + Ok("per-capture") => WriteMode::PerCapture, + Ok("direct") => WriteMode::Direct, + _ => WriteMode::Rolling, // default: batch draft PR + }, + capture_branch: std::env::var("LORE_CAPTURE_BRANCH") + .unwrap_or_else(|_| "capture/inbox".into()), + } +} + +/// The branch a capture in the current mode targets (for `PerCapture`, the slug +/// is appended at publish time, so this is a label). +fn target_branch(cfg: &Config, slug: Option<&str>) -> String { + match cfg.write_mode { + WriteMode::PerCapture => match slug { + Some(s) => format!("capture/{s}"), + None => "capture/".to_string(), + }, + WriteMode::Rolling | WriteMode::Direct => cfg.capture_branch.clone(), + } +} + +fn mode_label(mode: WriteMode) -> &'static str { + match mode { + WriteMode::PerCapture => "draft PR per capture", + WriteMode::Rolling => "batch draft PR", + WriteMode::Direct => "commit only", } } @@ -52,12 +79,7 @@ fn build_flow(cfg: &Config) -> CaptureFlow Result TargetView { + let cfg = load_config(); + TargetView { + repo: format!("{}/{}", cfg.repo.owner, cfg.repo.repo), + branch: target_branch(&cfg, None), + mode: mode_label(cfg.write_mode).to_string(), + opens_pr: cfg.write_mode != WriteMode::Direct, + } +} + +/// Gate 2 prep: after the author confirms, write + validate + commit, and (unless +/// the mode is `Direct`) ensure a DRAFT pull request. The core refuses any +/// non-draft PR; the independent merge is Gate 2. `slug` derives the per-capture +/// branch; it is ignored in rolling/direct modes. #[tauri::command] #[allow(clippy::too_many_arguments)] fn publish( @@ -102,7 +149,7 @@ fn publish( title: String, body: String, dest_path: String, - branch: String, + slug: String, coauthor: Option, ) -> Result { let cfg = load_config(); @@ -112,13 +159,16 @@ fn publish( title, body, }; + let branch = target_branch(&cfg, Some(&slug)); let outcome = flow - .publish(&proposal, &dest_path, &branch, coauthor.as_deref()) + .publish(&proposal, &dest_path, &branch, cfg.write_mode, coauthor.as_deref()) .map_err(|e| e.to_string())?; Ok(OutcomeView { path: outcome.path, minted_id: outcome.minted_id, - pr_url: outcome.pr.url, + branch: outcome.branch, + mode: mode_label(outcome.mode).to_string(), + pr_url: outcome.pr.map(|p| p.url).unwrap_or_default(), }) } @@ -158,7 +208,7 @@ pub fn run() { // steals focus from the app the author is working in. Ok(()) }) - .invoke_handler(tauri::generate_handler![propose, publish]) + .invoke_handler(tauri::generate_handler![propose, publish, capture_target]) .run(tauri::generate_context!()) .expect("error while running the Lore capture overlay"); } diff --git a/lore-overlay/app/src/index.html b/lore-overlay/app/src/index.html index 029a2aff..65624d16 100644 --- a/lore-overlay/app/src/index.html +++ b/lore-overlay/app/src/index.html @@ -46,6 +46,15 @@

          Capture a decision

          + + +
          + + · + + · + +
          diff --git a/lore-overlay/app/src/main.js b/lore-overlay/app/src/main.js index 69db9d63..7c88dbf7 100644 --- a/lore-overlay/app/src/main.js +++ b/lore-overlay/app/src/main.js @@ -15,6 +15,19 @@ const md = new MarkdownIt({ html: false, linkify: true, typographer: true }); const $ = (id) => document.getElementById(id); let current = null; // the proposed { artifact_type, title, body } +// Show where captures land (repo · branch · mode) as a read-only footer. +(async () => { + try { + const t = await invoke("capture_target"); + $("dest-repo").textContent = t.repo; + $("dest-branch").textContent = t.branch; + $("dest-mode").textContent = t.mode; + $("dest").hidden = false; + } catch { + // No config yet (e.g. first run) — leave the footer hidden. + } +})(); + $("propose").addEventListener("click", async () => { const intent = $("intent").value.trim(); if (!intent) return; @@ -73,18 +86,23 @@ $("publish").addEventListener("click", async () => { .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, "") .slice(0, 60); - setResult("Opening a draft pull request…"); + setResult("Saving…"); try { - // Gate 2 prep: write + validate + open a DRAFT PR (independent merge lands it). + // Gate 2 prep: write + validate + commit, and (unless Direct) a DRAFT PR. + // The branch + PR granularity is decided by the configured write mode. const outcome = await invoke("publish", { artifactType: current.artifact_type, title, body, destPath: `rac/decisions/${slug}.md`, - branch: `capture/${slug}`, + slug, coauthor: null, }); - setResult(`Proposed as ${outcome.minted_id} — review & merge: ${outcome.pr_url}`); + setResult( + outcome.pr_url + ? `Proposed as ${outcome.minted_id} on ${outcome.branch} — review & merge: ${outcome.pr_url}` + : `Committed ${outcome.minted_id} to ${outcome.branch} (no PR — ${outcome.mode}).` + ); } catch (e) { setResult("Couldn't publish: " + e); } diff --git a/lore-overlay/app/src/styles.css b/lore-overlay/app/src/styles.css index e2d17e45..9ddc67ec 100644 --- a/lore-overlay/app/src/styles.css +++ b/lore-overlay/app/src/styles.css @@ -180,3 +180,22 @@ button.primary { opacity: 0.9; word-break: break-all; } +.dest { + display: flex; + align-items: center; + gap: 6px; + margin-top: 14px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); + font-size: 11px; + opacity: 0.6; +} +.dest-branch { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.dest-sep { + opacity: 0.5; +} +.dest-mode { + margin-left: auto; +} diff --git a/lore-overlay/core/src/config.rs b/lore-overlay/core/src/config.rs index 7e51029f..b9120057 100644 --- a/lore-overlay/core/src/config.rs +++ b/lore-overlay/core/src/config.rs @@ -28,6 +28,30 @@ fn default_base_branch() -> String { "main".to_string() } +/// How a capture lands. The PR is the *trust boundary* (ADR-077 Gate 2), not the +/// save action — so its granularity is configurable without weakening the model. +/// In every mode the host only ever *proposes*; it never merges. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum WriteMode { + /// A new branch and a draft PR for every capture. Highest ceremony; suits a + /// repo where each decision warrants its own review thread. + PerCapture, + /// Append each capture to one shared branch with a single rolling batch draft + /// PR — review on the maintainer's cadence, not per artifact. The default: + /// it keeps Gate 2 (independent merge) while removing per-doc overhead. + #[default] + Rolling, + /// Commit straight to a branch, no PR — for a solo/personal repo where there + /// is no independent reviewer and a PR would be self-approval theatre. Never + /// targets the base branch directly; content still lands on a branch. + Direct, +} + +fn default_capture_branch() -> String { + "capture/inbox".to_string() +} + /// The overlay's whole persisted configuration. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Config { @@ -39,6 +63,13 @@ pub struct Config { /// How to invoke the `rac` engine (bundled, on PATH, or a wrapper). #[serde(default = "default_rac_command")] pub rac_command: String, + /// How captures land (per-capture PR, rolling batch PR, or direct commit). + #[serde(default)] + pub write_mode: WriteMode, + /// Branch used by `Rolling` and `Direct` modes. `PerCapture` derives its own + /// branch per capture and ignores this. + #[serde(default = "default_capture_branch")] + pub capture_branch: String, } fn default_hotkey() -> String { diff --git a/lore-overlay/core/src/flow.rs b/lore-overlay/core/src/flow.rs index 63d35a1e..b076a99a 100644 --- a/lore-overlay/core/src/flow.rs +++ b/lore-overlay/core/src/flow.rs @@ -1,7 +1,7 @@ -use crate::config::RepoConfig; +use crate::config::{RepoConfig, WriteMode}; use crate::error::CaptureError; use crate::gateway::Gateway; -use crate::github::{PrResult, ProposalRequest, Publisher}; +use crate::github::{CommitRequest, PrRequest, PrResult, Publisher}; use crate::rac::Rac; /// A proposed artifact, produced from raw intent, awaiting the author's fidelity @@ -13,13 +13,20 @@ pub struct Proposal { pub body: String, } -/// The result of publishing a confirmed proposal: a **draft** pull request. It is -/// not landed — an independent maintainer's merge is the trust boundary (Gate 2). +/// The result of publishing a confirmed proposal. The artifact is committed to a +/// branch and, unless the write mode is `Direct`, proposed as a **draft** pull +/// request. It is never landed — an independent maintainer's merge is the trust +/// boundary (Gate 2). #[derive(Clone, Debug)] pub struct CaptureOutcome { pub path: String, pub minted_id: String, - pub pr: PrResult, + /// The branch the artifact was committed to. + pub branch: String, + /// How it landed. + pub mode: WriteMode, + /// The draft PR, when the mode opens one (`None` for `Direct`). + pub pr: Option, } /// Orchestrates the capture flow over the three seams. Generic over the traits so @@ -45,6 +52,11 @@ impl CaptureFlow { &self.repo } + /// Borrow the publisher — lets a host (or a test) inspect the write seam. + pub fn publisher(&self) -> &P { + &self.publisher + } + /// Gate-1 preparation: turn raw `intent` into a [`Proposal`]. Reads the real /// schema and drafts through the gateway. No file is written, nothing is /// pushed — the author reviews the proposal next. @@ -60,13 +72,16 @@ impl CaptureFlow { /// After the author confirms the proposal is faithful (Gate 1), scaffold the /// file (minting the id), fill the body while keeping the frontmatter, - /// validate, and open a **draft** pull request. Refuses to proceed if the + /// validate, and commit it to `branch`. Then, unless `mode` is `Direct`, + /// ensure a **draft** pull request — reusing an open one in `Rolling` mode so + /// successive captures share a single batch PR. Refuses to proceed if the /// publisher ever returns a non-draft PR (Gate 2 is the independent merge). pub fn publish( &self, proposal: &Proposal, dest_path: &str, branch: &str, + mode: WriteMode, coauthor_trailer: Option<&str>, ) -> Result { let stdout = self.rac.new_artifact(&proposal.artifact_type, dest_path)?; @@ -81,36 +96,59 @@ impl CaptureFlow { // Deterministic close before we propose anything. self.rac.validate(dest_path)?; - let mut pr_body = format!( - "Proposed via the Lore capture overlay. Fidelity confirmed by the author; this is a \ - **draft** awaiting an independent maintainer's review and merge (ADR-077).\n\n\ - Artifact: `{path}` ({id})\n", - path = dest_path, - id = minted_id - ); - if let Some(trailer) = coauthor_trailer { - pr_body.push('\n'); - pr_body.push_str(trailer); - pr_body.push('\n'); - } - - let req = ProposalRequest { + // Save is a commit (ADR-077): the artifact lands on a branch first. + self.publisher.commit_file(&CommitRequest { branch: branch.to_string(), + base_branch: self.repo.base_branch.clone(), path: dest_path.to_string(), content: filled, - commit_message: format!("capture: propose {}", proposal.title), - pr_title: format!("capture: {}", proposal.title), - pr_body, + message: format!("capture: propose {}", proposal.title), + })?; + + // Promotion is a PR — its granularity follows the write mode. `Direct` + // skips it (a solo repo with no independent reviewer); the others ensure + // a draft PR, which `Rolling` shares across captures as a batch. + let pr = match mode { + WriteMode::Direct => None, + WriteMode::PerCapture | WriteMode::Rolling => { + let mut pr_body = format!( + "Proposed via the Lore capture overlay. Fidelity confirmed by the author; \ + this is a **draft** awaiting an independent maintainer's review and merge \ + (ADR-077).\n\nArtifact: `{path}` ({id})\n", + path = dest_path, + id = minted_id + ); + if let Some(trailer) = coauthor_trailer { + pr_body.push('\n'); + pr_body.push_str(trailer); + pr_body.push('\n'); + } + // A stable title in `Rolling` so the same batch PR is recognisable. + let pr_title = match mode { + WriteMode::Rolling => "capture: proposed artifacts (batch)".to_string(), + _ => format!("capture: {}", proposal.title), + }; + let pr = self.publisher.ensure_draft_pr(&PrRequest { + branch: branch.to_string(), + base_branch: self.repo.base_branch.clone(), + title: pr_title, + body: pr_body, + })?; + if !pr.draft { + return Err(CaptureError::Publish( + "refusing to proceed: capture must open a DRAFT pull request (ADR-077)" + .into(), + )); + } + Some(pr) + } }; - let pr = self.publisher.open_draft_pr(&req)?; - if !pr.draft { - return Err(CaptureError::Publish( - "refusing to proceed: capture must open a DRAFT pull request (ADR-077)".into(), - )); - } + Ok(CaptureOutcome { path: dest_path.to_string(), minted_id, + branch: branch.to_string(), + mode, pr, }) } diff --git a/lore-overlay/core/src/github.rs b/lore-overlay/core/src/github.rs index 1de6713a..f064ab54 100644 --- a/lore-overlay/core/src/github.rs +++ b/lore-overlay/core/src/github.rs @@ -1,20 +1,37 @@ use crate::error::CaptureError; -/// Everything needed to open a draft pull request proposing one artifact. +/// A commit of one artifact onto a branch. The branch is created from +/// `base_branch` if it does not yet exist; an existing file is updated in place, +/// so `Rolling` mode appends successive captures to the same branch. #[derive(Clone, Debug)] -pub struct ProposalRequest { - /// New branch to create off the base branch. +pub struct CommitRequest { pub branch: String, + pub base_branch: String, /// Repo-relative path of the artifact file. pub path: String, /// Full file content to commit. pub content: String, - pub commit_message: String, - pub pr_title: String, - pub pr_body: String, + pub message: String, } -/// The opened pull request. +/// The result of committing an artifact onto a branch. +#[derive(Clone, Debug)] +pub struct CommitResult { + pub branch: String, + /// The commit URL, when the backend reports one. + pub commit_url: String, +} + +/// A request to ensure a DRAFT pull request exists for `branch` → `base_branch`. +#[derive(Clone, Debug)] +pub struct PrRequest { + pub branch: String, + pub base_branch: String, + pub title: String, + pub body: String, +} + +/// The opened (or already-open) pull request. #[derive(Clone, Debug)] pub struct PrResult { pub url: String, @@ -22,12 +39,21 @@ pub struct PrResult { pub draft: bool, } -/// The write seam. A capture host only ever **proposes**: it opens a draft pull -/// request and never approves or merges (ADR-065 / ADR-077). The trait has no -/// approve or merge method by construction, so the two-gate model cannot be -/// violated by a host that uses this core. +/// The write seam. A capture host only ever **proposes**: it commits to a branch +/// and, when the write mode calls for it, ensures a *draft* pull request exists. +/// The trait has **no approve or merge method by construction**, so the two-gate +/// model (ADR-065 / ADR-077) cannot be violated by a host built on this core — +/// in any write mode. pub trait Publisher { - fn open_draft_pr(&self, req: &ProposalRequest) -> Result; + /// Commit `content` at `path` on `branch`, creating the branch from + /// `base_branch` if it does not exist. Idempotent: re-committing updates the + /// file in place rather than failing. + fn commit_file(&self, req: &CommitRequest) -> Result; + + /// Ensure a DRAFT pull request is open for `branch`. Returns the existing PR + /// if one is already open (so `Rolling` mode reuses a single batch PR), else + /// opens a new draft. Never approves or merges. + fn ensure_draft_pr(&self, req: &PrRequest) -> Result; } /// Real GitHub publisher over the REST API. Compiled only for the desktop app, @@ -41,7 +67,6 @@ pub trait Publisher { pub struct GithubPublisher { owner: String, repo: String, - base_branch: String, token: String, api_base: String, client: reqwest::blocking::Client, @@ -49,16 +74,16 @@ pub struct GithubPublisher { #[cfg(feature = "net")] impl GithubPublisher { + /// The base branch each capture targets is carried per-request (it can differ + /// by write mode), so it is not stored on the publisher. pub fn new( owner: impl Into, repo: impl Into, - base_branch: impl Into, token: impl Into, ) -> Self { Self { owner: owner.into(), repo: repo.into(), - base_branch: base_branch.into(), token: token.into(), api_base: "https://api.github.com".to_string(), client: reqwest::blocking::Client::new(), @@ -66,88 +91,153 @@ impl GithubPublisher { } fn get(&self, path: &str) -> reqwest::blocking::RequestBuilder { - self.client - .get(format!("{}{}", self.api_base, path)) - .bearer_auth(&self.token) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "lore-capture-overlay") + self.req(self.client.get(format!("{}{}", self.api_base, path))) } fn post(&self, path: &str) -> reqwest::blocking::RequestBuilder { - self.client - .post(format!("{}{}", self.api_base, path)) - .bearer_auth(&self.token) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "lore-capture-overlay") + self.req(self.client.post(format!("{}{}", self.api_base, path))) } fn put(&self, path: &str) -> reqwest::blocking::RequestBuilder { - self.client - .put(format!("{}{}", self.api_base, path)) - .bearer_auth(&self.token) + self.req(self.client.put(format!("{}{}", self.api_base, path))) + } + + fn req(&self, b: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder { + b.bearer_auth(&self.token) .header("Accept", "application/vnd.github+json") .header("User-Agent", "lore-capture-overlay") } + + /// Tip sha of an existing branch, or `None` if the branch does not exist. + fn branch_sha(&self, branch: &str) -> Result, CaptureError> { + let err = |e: reqwest::Error| CaptureError::Publish(e.to_string()); + let resp = self + .get(&format!( + "/repos/{}/{}/git/ref/heads/{}", + self.owner, self.repo, branch + )) + .send() + .map_err(err)?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let json = resp + .error_for_status() + .map_err(err)? + .json::() + .map_err(err)?; + Ok(json["object"]["sha"].as_str().map(|s| s.to_string())) + } + + /// The blob sha of `path` on `branch`, or `None` if the file is absent + /// (needed to update an existing file in `Rolling` mode). + fn file_sha(&self, branch: &str, path: &str) -> Result, CaptureError> { + let err = |e: reqwest::Error| CaptureError::Publish(e.to_string()); + let resp = self + .get(&format!( + "/repos/{}/{}/contents/{}?ref={}", + self.owner, self.repo, path, branch + )) + .send() + .map_err(err)?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let json = resp + .error_for_status() + .map_err(err)? + .json::() + .map_err(err)?; + Ok(json["sha"].as_str().map(|s| s.to_string())) + } } #[cfg(feature = "net")] impl Publisher for GithubPublisher { - fn open_draft_pr(&self, req: &ProposalRequest) -> Result { + fn commit_file(&self, req: &CommitRequest) -> Result { use base64::Engine as _; let err = |e: reqwest::Error| CaptureError::Publish(e.to_string()); - // 1. Base branch tip sha. - let base_ref = self - .get(&format!( - "/repos/{}/{}/git/ref/heads/{}", - self.owner, self.repo, self.base_branch + // Create the branch from the base tip if it does not already exist; + // an existing branch (Rolling) is reused so captures accumulate. + if self.branch_sha(&req.branch)?.is_none() { + let base_sha = self + .branch_sha(&req.base_branch)? + .ok_or_else(|| CaptureError::Publish("base branch not found".into()))?; + self.post(&format!("/repos/{}/{}/git/refs", self.owner, self.repo)) + .json(&serde_json::json!({ + "ref": format!("refs/heads/{}", req.branch), + "sha": base_sha, + })) + .send() + .map_err(err)? + .error_for_status() + .map_err(err)?; + } + + // Write (or update) the file on the branch. + let content_b64 = base64::engine::general_purpose::STANDARD.encode(req.content.as_bytes()); + let mut body = serde_json::json!({ + "message": req.message, + "content": content_b64, + "branch": req.branch, + }); + if let Some(sha) = self.file_sha(&req.branch, &req.path)? { + body["sha"] = serde_json::Value::String(sha); + } + let commit = self + .put(&format!( + "/repos/{}/{}/contents/{}", + self.owner, self.repo, req.path )) + .json(&body) .send() .map_err(err)? .error_for_status() .map_err(err)? .json::() .map_err(err)?; - let base_sha = base_ref["object"]["sha"] - .as_str() - .ok_or_else(|| CaptureError::Publish("no base sha".into()))? - .to_string(); - // 2. Create the work branch. - self.post(&format!("/repos/{}/{}/git/refs", self.owner, self.repo)) - .json(&serde_json::json!({ - "ref": format!("refs/heads/{}", req.branch), - "sha": base_sha, - })) + Ok(CommitResult { + branch: req.branch.clone(), + commit_url: commit["commit"]["html_url"] + .as_str() + .unwrap_or_default() + .to_string(), + }) + } + + fn ensure_draft_pr(&self, req: &PrRequest) -> Result { + let err = |e: reqwest::Error| CaptureError::Publish(e.to_string()); + + // Reuse an already-open PR for this branch (the rolling batch PR). + let open = self + .get(&format!( + "/repos/{}/{}/pulls?state=open&head={}:{}", + self.owner, self.repo, self.owner, req.branch + )) .send() .map_err(err)? .error_for_status() + .map_err(err)? + .json::() .map_err(err)?; + if let Some(pr) = open.as_array().and_then(|a| a.first()) { + return Ok(PrResult { + url: pr["html_url"].as_str().unwrap_or_default().to_string(), + number: pr["number"].as_u64().unwrap_or_default(), + draft: pr["draft"].as_bool().unwrap_or(false), + }); + } - // 3. Write the file on the new branch. - let content_b64 = base64::engine::general_purpose::STANDARD.encode(req.content.as_bytes()); - self.put(&format!( - "/repos/{}/{}/contents/{}", - self.owner, self.repo, req.path - )) - .json(&serde_json::json!({ - "message": req.commit_message, - "content": content_b64, - "branch": req.branch, - })) - .send() - .map_err(err)? - .error_for_status() - .map_err(err)?; - - // 4. Open a DRAFT pull request. Never approves or merges. + // Otherwise open a new DRAFT pull request. Never approves or merges. let pr = self .post(&format!("/repos/{}/{}/pulls", self.owner, self.repo)) .json(&serde_json::json!({ - "title": req.pr_title, - "body": req.pr_body, + "title": req.title, + "body": req.body, "head": req.branch, - "base": self.base_branch, + "base": req.base_branch, "draft": true, })) .send() diff --git a/lore-overlay/core/src/lib.rs b/lore-overlay/core/src/lib.rs index 2b8f9933..60d2cf3a 100644 --- a/lore-overlay/core/src/lib.rs +++ b/lore-overlay/core/src/lib.rs @@ -13,9 +13,12 @@ //! [`Proposal`] the author confirms in the host UI. This is a data-quality //! check, not a trust boundary; no file is written and nothing is pushed. //! - **Gate 2 — trust boundary.** [`CaptureFlow::publish`] writes the artifact, -//! validates it, and opens a **draft** pull request. The independent -//! maintainer's merge is the trust boundary. The [`Publisher`] trait has no -//! approve/merge method by construction — the host can only *propose*. +//! validates it, commits it to a branch, and (unless the [`WriteMode`] is +//! `Direct`) ensures a **draft** pull request. The independent maintainer's +//! merge is the trust boundary. The PR's *granularity* is configurable — +//! per-capture, a rolling batch, or none — but the [`Publisher`] trait has no +//! approve/merge method by construction, so the host can only *propose* in +//! every mode. //! //! The model call (gateway) and git writes (publisher) live behind traits so the //! core is testable offline; the concrete network implementations are compiled @@ -28,11 +31,11 @@ mod gateway; mod github; mod rac; -pub use config::{Config, GatewayConfig, RepoConfig}; +pub use config::{Config, GatewayConfig, RepoConfig, WriteMode}; pub use error::CaptureError; pub use flow::{parse_minted_id, CaptureFlow, CaptureOutcome, Proposal}; pub use gateway::{DraftedArtifact, Gateway}; -pub use github::{PrResult, ProposalRequest, Publisher}; +pub use github::{CommitRequest, CommitResult, PrRequest, PrResult, Publisher}; pub use rac::{Rac, RacClient}; #[cfg(feature = "net")] diff --git a/lore-overlay/core/tests/flow_test.rs b/lore-overlay/core/tests/flow_test.rs index fd20f1d4..99be9e50 100644 --- a/lore-overlay/core/tests/flow_test.rs +++ b/lore-overlay/core/tests/flow_test.rs @@ -2,12 +2,14 @@ //! //! The three external seams (rac / gateway / GitHub) are faked, so the suite runs //! offline and deterministically while still exercising the real logic: schema → -//! draft → scaffold → fill-keeping-frontmatter → validate → open a *draft* PR. -//! One extra test exercises the real `RacClient` shell when `LORE_TEST_RAC` is set. +//! draft → scaffold → fill-keeping-frontmatter → validate → commit → (maybe) open +//! a *draft* PR. The write-mode tests cover per-capture, rolling batch, and direct +//! commit. One extra test exercises the real `RacClient` shell when `LORE_TEST_RAC` +//! is set. use lore_capture_core::{ - parse_minted_id, CaptureError, CaptureFlow, DraftedArtifact, Gateway, PrResult, - ProposalRequest, Publisher, Rac, RacClient, RepoConfig, + parse_minted_id, CaptureError, CaptureFlow, CommitRequest, CommitResult, DraftedArtifact, + Gateway, PrRequest, PrResult, Publisher, Rac, RacClient, RepoConfig, WriteMode, }; use std::cell::RefCell; @@ -51,13 +53,29 @@ impl Gateway for FakeGateway { } } -/// Records the request it was asked to publish, and always returns a draft PR. +/// Records every commit and PR request, and emulates a single rolling batch PR: +/// the first `ensure_draft_pr` opens PR #999, later calls for the same branch +/// return that same PR rather than opening another. +#[derive(Default)] struct FakePublisher { - seen: RefCell>, + commits: RefCell>, + prs: RefCell>, + open_branch: RefCell>, } impl Publisher for FakePublisher { - fn open_draft_pr(&self, req: &ProposalRequest) -> Result { - *self.seen.borrow_mut() = Some(req.clone()); + fn commit_file(&self, req: &CommitRequest) -> Result { + self.commits.borrow_mut().push(req.clone()); + Ok(CommitResult { + branch: req.branch.clone(), + commit_url: format!("https://example.com/commit/{}", self.commits.borrow().len()), + }) + } + fn ensure_draft_pr(&self, req: &PrRequest) -> Result { + self.prs.borrow_mut().push(req.clone()); + let already_open = self.open_branch.borrow().as_deref() == Some(req.branch.as_str()); + if !already_open { + *self.open_branch.borrow_mut() = Some(req.branch.clone()); + } Ok(PrResult { url: "https://github.com/itsthelore/rac-core/pull/999".to_string(), number: 999, @@ -69,7 +87,13 @@ impl Publisher for FakePublisher { /// A publisher that (incorrectly) reports a non-draft PR — the flow must refuse it. struct NonDraftPublisher; impl Publisher for NonDraftPublisher { - fn open_draft_pr(&self, _req: &ProposalRequest) -> Result { + fn commit_file(&self, req: &CommitRequest) -> Result { + Ok(CommitResult { + branch: req.branch.clone(), + commit_url: "x".to_string(), + }) + } + fn ensure_draft_pr(&self, _req: &PrRequest) -> Result { Ok(PrResult { url: "x".to_string(), number: 1, @@ -94,18 +118,11 @@ fn temp_path(name: &str) -> std::path::PathBuf { } #[test] -fn propose_then_publish_keeps_frontmatter_and_opens_a_draft_pr() { +fn per_capture_keeps_frontmatter_and_opens_a_draft_pr() { let path = temp_path("happy"); let path_str = path.to_str().unwrap().to_string(); - let flow = CaptureFlow::new( - FakeRac, - FakeGateway, - FakePublisher { - seen: RefCell::new(None), - }, - repo(), - ); + let flow = CaptureFlow::new(FakeRac, FakeGateway, FakePublisher::default(), repo()); // Gate 1: propose. No file yet. let proposal = flow @@ -117,19 +134,23 @@ fn propose_then_publish_keeps_frontmatter_and_opens_a_draft_pr() { .contains("We decided to adopt the capture overlay.")); assert!(!path.exists(), "propose() must not write the artifact file"); - // Gate 2 prep: publish opens a draft PR. + // Gate 2 prep: publish commits and opens a draft PR. let outcome = flow .publish( &proposal, &path_str, "capture/adr-099", + WriteMode::PerCapture, Some("Co-authored-by: Author "), ) .unwrap(); assert_eq!(outcome.minted_id, "RAC-FAKE12345"); - assert!(outcome.pr.draft, "capture must open a DRAFT pull request"); - assert_eq!(outcome.pr.number, 999); + assert_eq!(outcome.branch, "capture/adr-099"); + assert_eq!(outcome.mode, WriteMode::PerCapture); + let pr = outcome.pr.expect("per-capture opens a PR"); + assert!(pr.draft, "capture must open a DRAFT pull request"); + assert_eq!(pr.number, 999); // The written file keeps the minted frontmatter and uses the drafted body. let written = std::fs::read_to_string(&path).unwrap(); @@ -144,7 +165,90 @@ fn propose_then_publish_keeps_frontmatter_and_opens_a_draft_pr() { "the scaffold's placeholder title must be replaced" ); - // The PR body carries the co-author trailer and the published content matches. + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn rolling_appends_to_one_branch_and_reuses_a_single_batch_pr() { + let path_a = temp_path("rolling-a"); + let path_b = temp_path("rolling-b"); + let publisher = FakePublisher::default(); + let flow = CaptureFlow::new(FakeRac, FakeGateway, publisher, repo()); + + let p1 = flow.propose("decision", "first decision").unwrap(); + let out1 = flow + .publish( + &p1, + path_a.to_str().unwrap(), + "capture/inbox", + WriteMode::Rolling, + None, + ) + .unwrap(); + let p2 = flow.propose("decision", "second decision").unwrap(); + let out2 = flow + .publish( + &p2, + path_b.to_str().unwrap(), + "capture/inbox", + WriteMode::Rolling, + None, + ) + .unwrap(); + + // Both captures land on the one shared branch and share the one batch PR. + assert_eq!(out1.branch, "capture/inbox"); + assert_eq!(out2.branch, "capture/inbox"); + assert_eq!(out1.pr.as_ref().unwrap().number, 999); + assert_eq!(out2.pr.as_ref().unwrap().number, 999); + + // Two commits, both to capture/inbox; the PR was opened once and then reused. + let pubref = flow.publisher(); + assert_eq!(pubref.commits.borrow().len(), 2); + assert!(pubref + .commits + .borrow() + .iter() + .all(|c| c.branch == "capture/inbox")); + assert_eq!( + pubref.prs.borrow().len(), + 2, + "ensure_draft_pr called per capture" + ); + assert!(pubref + .prs + .borrow() + .iter() + .all(|p| p.title.contains("batch"))); + + let _ = std::fs::remove_dir_all(path_a.parent().unwrap()); + let _ = std::fs::remove_dir_all(path_b.parent().unwrap()); +} + +#[test] +fn direct_commits_without_opening_a_pull_request() { + let path = temp_path("direct"); + let flow = CaptureFlow::new(FakeRac, FakeGateway, FakePublisher::default(), repo()); + let p = flow.propose("decision", "solo repo decision").unwrap(); + let outcome = flow + .publish( + &p, + path.to_str().unwrap(), + "capture/inbox", + WriteMode::Direct, + None, + ) + .unwrap(); + + assert_eq!(outcome.mode, WriteMode::Direct); + assert!(outcome.pr.is_none(), "direct mode must not open a PR"); + let pubref = flow.publisher(); + assert_eq!(pubref.commits.borrow().len(), 1, "direct still commits"); + assert!( + pubref.prs.borrow().is_empty(), + "direct must not call ensure_draft_pr" + ); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -155,7 +259,13 @@ fn publish_refuses_a_non_draft_pull_request() { let flow = CaptureFlow::new(FakeRac, FakeGateway, NonDraftPublisher, repo()); let proposal = flow.propose("decision", "something").unwrap(); let err = flow - .publish(&proposal, &path_str, "capture/x", None) + .publish( + &proposal, + &path_str, + "capture/x", + WriteMode::PerCapture, + None, + ) .unwrap_err(); match err { CaptureError::Publish(m) => assert!(m.contains("DRAFT")), From db429390dba7d5a9b611dc97e547fa63f3d0cc44 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 24 Jun 2026 17:18:01 +0000 Subject: [PATCH 6/6] docs(design): record configurable capture write modes [roadmap:lore-overlay] The PR is the trust boundary (ADR-077 Gate 2), not the save action, so its granularity is a configuration choice. Records the three write modes (per-capture, rolling batch default, direct), the constraint that granularity is configurable while the trust boundary is not, and the rolling-batch lifecycle open question. Implements rac/designs/lore-capture-overlay.md. --- rac/designs/lore-capture-overlay.md | 41 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/rac/designs/lore-capture-overlay.md b/rac/designs/lore-capture-overlay.md index 2477c2ac..92a63506 100644 --- a/rac/designs/lore-capture-overlay.md +++ b/rac/designs/lore-capture-overlay.md @@ -76,11 +76,38 @@ model (ADR-077)** governs: the author's in-app confirmation is *fidelity*, not a trust boundary; an **independent** maintainer's PR merge is the trust boundary (ADR-065). The app's own confirmation never lands anything in the trusted corpus. +### Write granularity — save is a commit, promotion is a (batched) PR + +The pull request is the **trust boundary** (ADR-077 Gate 2), not the save action, +so its *granularity* is a configuration choice that does not weaken the model. A +PR per captured artifact is real overhead — a notification, a merge, a branch +cleanup each time — that buys nothing in a repo with one maintainer, where a +solo PR is self-approval theatre. The overlay therefore offers three **write +modes**, all of which keep the writer to *proposing* only: + +- **Per-capture** — a branch and a draft PR for every capture. Highest ceremony; + fits a repo where each decision warrants its own review thread. +- **Rolling (default)** — append each capture to one shared branch with a single + rolling **batch draft PR**, so a maintainer reviews on their cadence rather than + per artifact. This is "save is a commit, promotion is a PR" with the PR cadence + decoupled from the capture cadence; it keeps Gate 2 while removing the per-doc + overhead. +- **Direct** — commit to a branch with no PR, for a solo/personal repo with no + independent reviewer. It still never targets the base branch directly; content + lands on a branch, so ADR-065's "no untrusted content onto `main` unreviewed" + holds. + +The mode changes only *when and how often* a PR is opened, never *who merges* — +the host has no approve/merge capability in any mode. A small read-only line in +the modal shows where the capture will land (repository, branch, mode), so the +author always sees the destination. + ### Settings -A small settings surface holds the three things the app needs: the **gateway** -(endpoint, key, model), the **target repository + GitHub App** install, and the -**global hotkey**. Nothing else is stored; the app is not a content store +A small settings surface holds the four things the app needs: the **gateway** +(endpoint, key, model), the **target repository + GitHub App** install, the +**global hotkey**, and the **write mode** (per-capture / rolling / direct, with +the capture branch). Nothing else is stored; the app is not a content store (ADR-024) — it emits artifacts to git and keeps no canonical copy. ### Distribution @@ -98,7 +125,10 @@ renders in the OS webview). reimplements no engine behaviour. - **Two gates; the writer only proposes (ADR-065, ADR-077).** In-app confirm is fidelity; an independent PR merge is the trust boundary; the app's GitHub - identity never approves or merges. + identity never approves or merges — in *any* write mode. +- **PR granularity is configurable, the trust boundary is not.** Per-capture, + rolling batch, or direct-commit changes only when a PR opens, never who merges; + no mode writes untrusted content onto the base branch unreviewed. - **Not a content store (ADR-024).** Emit to git; store only configuration. - **A `lore-*` product in its own repo (ADR-068).** Not engine code in `rac-core`. - **Summon-a-modal, not watch-the-screen.** No Accessibility/Screen-Recording @@ -155,6 +185,9 @@ properties are inherited rather than re-argued. token, and where the installation token is cached on-device. - **Offline behaviour**: capture-and-queue when there is no network, draft PR on reconnect. +- **Rolling-batch lifecycle**: when to "cut" a batch PR (size, age, or manual), + and how a captured artifact behaves if it is edited again before the batch + merges. - **Live-viewer tie-in**: should the overlay also host the repo-watching `rac export` viewer (Thread A of `lore-frontend-optionality`), or stay capture-only?