swarm repositories / source
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock562
-rw-r--r--Cargo.toml1
-rw-r--r--README.md14
-rw-r--r--assets/systemd/fidget-spinner-libgrid-ui.service.in14
-rw-r--r--crates/fidget-spinner-cli/Cargo.toml1
-rw-r--r--crates/fidget-spinner-cli/src/ui.rs865
-rw-r--r--crates/fidget-spinner-cli/tests/mcp_hardening.rs1
-rw-r--r--crates/fidget-spinner-store-sqlite/src/lib.rs67
-rw-r--r--docs/frontier-membership-design.md242
-rwxr-xr-xscripts/install-local.sh82
10 files changed, 1774 insertions, 75 deletions
diff --git a/Cargo.lock b/Cargo.lock
index e26d49f..79a4763 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,6 +3,21 @@
version = 4
[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -65,6 +80,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -118,6 +139,12 @@ dependencies = [
[[package]]
name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
@@ -129,6 +156,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -160,6 +199,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -200,12 +252,79 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "core-text"
+version = "20.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5"
+dependencies = [
+ "core-foundation",
+ "core-graphics",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -248,6 +367,27 @@ dependencies = [
]
[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "dwrote"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02"
+dependencies = [
+ "lazy_static",
+ "libc",
+ "winapi",
+ "wio",
+]
+
+[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -272,6 +412,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
name = "fidget-spinner-cli"
version = "0.1.0"
dependencies = [
@@ -285,6 +434,7 @@ dependencies = [
"libmcp-testkit",
"maud",
"percent-encoding",
+ "plotters",
"serde",
"serde_json",
"time",
@@ -324,12 +474,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "float-ord"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
+
+[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
+name = "font-kit"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3"
+dependencies = [
+ "bitflags 2.11.0",
+ "byteorder",
+ "core-foundation",
+ "core-graphics",
+ "core-text",
+ "dirs",
+ "dwrote",
+ "float-ord",
+ "freetype-sys",
+ "lazy_static",
+ "libc",
+ "log",
+ "pathfinder_geometry",
+ "pathfinder_simd",
+ "walkdir",
+ "winapi",
+ "yeslogic-fontconfig-sys",
+]
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -339,6 +557,17 @@ dependencies = [
]
[[package]]
+name = "freetype-sys"
+version = "0.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -396,6 +625,16 @@ dependencies = [
]
[[package]]
+name = "gif"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80792593675e051cf94a4b111980da2ba60d4a83e43e0048c5693baab3977045"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
+[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -507,6 +746,30 @@ dependencies = [
]
[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -615,6 +878,20 @@ dependencies = [
]
[[package]]
+name = "image"
+version = "0.24.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
+dependencies = [
+ "bytemuck",
+ "byteorder",
+ "color_quant",
+ "jpeg-decoder",
+ "num-traits",
+ "png",
+]
+
+[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -639,6 +916,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
+name = "jpeg-decoder"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
+
+[[package]]
name = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -649,6 +932,12 @@ dependencies = [
]
[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -661,6 +950,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
name = "libmcp"
version = "1.1.0"
source = "git+https://git.swarm.moe/libmcp.git?rev=bb92a05eb5446e07c6288e266bd06d7b5899eee5#bb92a05eb5446e07c6288e266bd06d7b5899eee5"
@@ -769,6 +1068,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -786,6 +1095,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -804,6 +1122,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
+name = "pathfinder_geometry"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3"
+dependencies = [
+ "log",
+ "pathfinder_simd",
+]
+
+[[package]]
+name = "pathfinder_simd"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57"
+dependencies = [
+ "rustc_version",
+]
+
+[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -828,6 +1165,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
+name = "plotters"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
+dependencies = [
+ "chrono",
+ "font-kit",
+ "image",
+ "lazy_static",
+ "num-traits",
+ "pathfinder_geometry",
+ "plotters-backend",
+ "plotters-bitmap",
+ "plotters-svg",
+ "ttf-parser",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
+
+[[package]]
+name = "plotters-bitmap"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405"
+dependencies = [
+ "gif",
+ "image",
+ "plotters-backend",
+]
+
+[[package]]
+name = "plotters-svg"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
+dependencies = [
+ "plotters-backend",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -925,7 +1321,7 @@ version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -935,6 +1331,15 @@ dependencies = [
]
[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -947,6 +1352,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
name = "schemars"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1061,6 +1475,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1258,6 +1678,12 @@ dependencies = [
]
[[package]]
+name = "ttf-parser"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
+
+[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1318,6 +1744,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1414,19 +1850,119 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
+name = "web-sys"
+version = "0.3.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1436,6 +1972,15 @@ dependencies = [
]
[[package]]
+name = "wio"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1493,7 +2038,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
- "bitflags",
+ "bitflags 2.11.0",
"indexmap",
"log",
"serde",
@@ -1530,6 +2075,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
+name = "yeslogic-fontconfig-sys"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd"
+dependencies = [
+ "dlib",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 01bd62a..4b626c2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,7 @@ dirs = "6"
linkify = "0.10"
maud = { version = "0.27", features = ["axum"] }
percent-encoding = "2"
+plotters = "0.3.7"
rusqlite = { version = "0.37", features = ["bundled", "time"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
diff --git a/README.md b/README.md
index b2bd6cd..0277361 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,20 @@ symlinks in `~/.codex/skills`:
The installed binary is `~/.local/bin/fidget-spinner-cli`.
+The installer also installs a user systemd service for the libgrid navigator at
+`http://127.0.0.1:8913/` and refreshes it on every reinstall:
+
+```bash
+systemctl --user status fidget-spinner-libgrid-ui.service
+journalctl --user -u fidget-spinner-libgrid-ui.service -f
+```
+
+You can override the default service target for one install with:
+
+```bash
+FIDGET_SPINNER_UI_PROJECT=/abs/path/to/project ./scripts/install-local.sh
+```
+
## Quickstart
Initialize a project:
diff --git a/assets/systemd/fidget-spinner-libgrid-ui.service.in b/assets/systemd/fidget-spinner-libgrid-ui.service.in
new file mode 100644
index 0000000..eddfab6
--- /dev/null
+++ b/assets/systemd/fidget-spinner-libgrid-ui.service.in
@@ -0,0 +1,14 @@
+[Unit]
+Description=Fidget Spinner libgrid navigator
+
+[Service]
+Type=simple
+WorkingDirectory=@UI_PROJECT_ROOT@
+Environment=HOME=@HOME@
+Environment=PATH=@HOME@/.cargo/bin:@LOCAL_BIN_DIR@:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+ExecStart=@LOCAL_BIN_DIR@/fidget-spinner-cli ui serve --path @UI_PROJECT_ROOT@ --bind @UI_BIND@
+Restart=always
+RestartSec=3
+
+[Install]
+WantedBy=default.target
diff --git a/crates/fidget-spinner-cli/Cargo.toml b/crates/fidget-spinner-cli/Cargo.toml
index a9e76d4..69f72b2 100644
--- a/crates/fidget-spinner-cli/Cargo.toml
+++ b/crates/fidget-spinner-cli/Cargo.toml
@@ -21,6 +21,7 @@ fidget-spinner-store-sqlite = { path = "../fidget-spinner-store-sqlite" }
libmcp = { git = "https://git.swarm.moe/libmcp.git", rev = "bb92a05eb5446e07c6288e266bd06d7b5899eee5" }
maud.workspace = true
percent-encoding.workspace = true
+plotters.workspace = true
serde.workspace = true
serde_json.workspace = true
time.workspace = true
diff --git a/crates/fidget-spinner-cli/src/ui.rs b/crates/fidget-spinner-cli/src/ui.rs
index 8eb1845..d88bab0 100644
--- a/crates/fidget-spinner-cli/src/ui.rs
+++ b/crates/fidget-spinner-cli/src/ui.rs
@@ -2,21 +2,28 @@ use std::io;
use std::net::SocketAddr;
use axum::Router;
-use axum::extract::{Path, State};
+use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use camino::Utf8PathBuf;
use fidget_spinner_core::{
AttachmentTargetRef, ExperimentAnalysis, ExperimentOutcome, ExperimentStatus, FrontierRecord,
- FrontierVerdict, MetricUnit, RunDimensionValue, Slug, VertexRef,
+ FrontierVerdict, MetricUnit, NonEmptyText, RunDimensionValue, Slug, VertexRef,
};
use fidget_spinner_store_sqlite::{
- ExperimentDetail, ExperimentSummary, FrontierOpenProjection, FrontierSummary,
- HypothesisCurrentState, HypothesisDetail, ProjectStatus, StoreError, VertexSummary,
+ ExperimentDetail, ExperimentSummary, FrontierMetricSeries, FrontierOpenProjection,
+ FrontierSummary, HypothesisCurrentState, HypothesisDetail, ListExperimentsQuery,
+ ListHypothesesQuery, MetricKeysQuery, MetricScope, ProjectStatus, StoreError, VertexSummary,
};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
+use plotters::prelude::{
+ BLACK, ChartBuilder, Circle, IntoDrawingArea, LineSeries, PathElement, SVGBackend, ShapeStyle,
+ Text,
+};
+use plotters::style::{Color, IntoFont, RGBColor};
+use serde::Deserialize;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use time::macros::format_description;
@@ -29,6 +36,27 @@ struct NavigatorState {
limit: Option<u32>,
}
+#[derive(Clone)]
+struct ShellFrame {
+ active_frontier_slug: Option<Slug>,
+ frontiers: Vec<FrontierSummary>,
+ project_status: ProjectStatus,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum FrontierTab {
+ Brief,
+ Open,
+ Closed,
+ Metrics,
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+struct FrontierPageQuery {
+ metric: Option<String>,
+ tab: Option<String>,
+}
+
struct AttachmentDisplay {
kind: &'static str,
href: String,
@@ -36,6 +64,35 @@ struct AttachmentDisplay {
summary: Option<String>,
}
+impl FrontierTab {
+ fn from_query(raw: Option<&str>) -> Self {
+ match raw {
+ Some("open") => Self::Open,
+ Some("closed") => Self::Closed,
+ Some("metrics") => Self::Metrics,
+ _ => Self::Brief,
+ }
+ }
+
+ const fn as_query(self) -> &'static str {
+ match self {
+ Self::Brief => "brief",
+ Self::Open => "open",
+ Self::Closed => "closed",
+ Self::Metrics => "metrics",
+ }
+ }
+
+ const fn label(self) -> &'static str {
+ match self {
+ Self::Brief => "Brief",
+ Self::Open => "Open",
+ Self::Closed => "Closed",
+ Self::Metrics => "Metrics",
+ }
+ }
+}
+
pub(crate) fn serve(
project_root: Utf8PathBuf,
bind: SocketAddr,
@@ -74,8 +131,9 @@ async fn project_home(State(state): State<NavigatorState>) -> Response {
async fn frontier_detail(
State(state): State<NavigatorState>,
Path(selector): Path<String>,
+ Query(query): Query<FrontierPageQuery>,
) -> Response {
- render_response(render_frontier_detail(state, selector))
+ render_response(render_frontier_detail(state, selector, query))
}
async fn hypothesis_detail(
@@ -118,50 +176,65 @@ fn render_response(result: Result<Markup, StoreError>) -> Response {
fn render_project_home(state: NavigatorState) -> Result<Markup, StoreError> {
let store = open_store(state.project_root.as_std_path())?;
- let project_status = store.status()?;
- let frontiers = store.list_frontiers()?;
- let title = format!("{} navigator", project_status.display_name);
+ let shell = load_shell_frame(&store, None)?;
+ let title = format!("{} navigator", shell.project_status.display_name);
let content = html! {
- (render_project_status(&project_status))
- (render_frontier_grid(&frontiers, state.limit))
+ (render_project_status(&shell.project_status))
+ (render_frontier_grid(&shell.frontiers, state.limit))
};
Ok(render_shell(
&title,
- Some(&project_status.display_name.to_string()),
+ &shell,
+ true,
+ Some(&shell.project_status.display_name.to_string()),
+ None,
None,
content,
))
}
-fn render_frontier_detail(state: NavigatorState, selector: String) -> Result<Markup, StoreError> {
+fn render_frontier_detail(
+ state: NavigatorState,
+ selector: String,
+ query: FrontierPageQuery,
+) -> Result<Markup, StoreError> {
let store = open_store(state.project_root.as_std_path())?;
let projection = store.frontier_open(&selector)?;
+ let shell = load_shell_frame(&store, Some(projection.frontier.slug.clone()))?;
+ let tab = FrontierTab::from_query(query.tab.as_deref());
let title = format!("{} · frontier", projection.frontier.label);
let subtitle = format!(
"{} hypotheses active · {} experiments open",
projection.active_hypotheses.len(),
projection.open_experiments.len()
);
- let content = html! {
- (render_frontier_header(&projection.frontier))
- (render_frontier_brief(&projection))
- (render_frontier_active_sets(&projection))
- (render_hypothesis_current_state_grid(
- &projection.active_hypotheses,
- state.limit,
- ))
- (render_open_experiment_grid(
- &projection.open_experiments,
- state.limit,
- ))
- };
- Ok(render_shell(&title, Some(&subtitle), None, content))
+ let content = render_frontier_tab_content(
+ &store,
+ &projection,
+ tab,
+ query.metric.as_deref(),
+ state.limit,
+ )?;
+ Ok(render_shell(
+ &title,
+ &shell,
+ false,
+ Some(&subtitle),
+ None,
+ Some(render_frontier_tab_bar(
+ &projection.frontier.slug,
+ tab,
+ query.metric.as_deref(),
+ )),
+ content,
+ ))
}
fn render_hypothesis_detail(state: NavigatorState, selector: String) -> Result<Markup, StoreError> {
let store = open_store(state.project_root.as_std_path())?;
let detail = store.read_hypothesis(&selector)?;
let frontier = store.read_frontier(&detail.record.frontier_id.to_string())?;
+ let shell = load_shell_frame(&store, Some(frontier.slug.clone()))?;
let title = format!("{} · hypothesis", detail.record.title);
let subtitle = detail.record.summary.to_string();
let content = html! {
@@ -182,8 +255,11 @@ fn render_hypothesis_detail(state: NavigatorState, selector: String) -> Result<M
};
Ok(render_shell(
&title,
+ &shell,
+ true,
Some(&subtitle),
Some((frontier.label.as_str(), frontier_href(&frontier.slug))),
+ None,
content,
))
}
@@ -192,6 +268,7 @@ fn render_experiment_detail(state: NavigatorState, selector: String) -> Result<M
let store = open_store(state.project_root.as_std_path())?;
let detail = store.read_experiment(&selector)?;
let frontier = store.read_frontier(&detail.record.frontier_id.to_string())?;
+ let shell = load_shell_frame(&store, Some(frontier.slug.clone()))?;
let title = format!("{} · experiment", detail.record.title);
let subtitle = detail.record.summary.as_ref().map_or_else(
|| detail.record.status.as_str().to_owned(),
@@ -212,8 +289,11 @@ fn render_experiment_detail(state: NavigatorState, selector: String) -> Result<M
};
Ok(render_shell(
&title,
+ &shell,
+ true,
Some(&subtitle),
Some((frontier.label.as_str(), frontier_href(&frontier.slug))),
+ None,
content,
))
}
@@ -221,6 +301,7 @@ fn render_experiment_detail(state: NavigatorState, selector: String) -> Result<M
fn render_artifact_detail(state: NavigatorState, selector: String) -> Result<Markup, StoreError> {
let store = open_store(state.project_root.as_std_path())?;
let detail = store.read_artifact(&selector)?;
+ let shell = load_shell_frame(&store, None)?;
let attachments = detail
.attachments
.iter()
@@ -263,7 +344,411 @@ fn render_artifact_detail(state: NavigatorState, selector: String) -> Result<Mar
}
}
};
- Ok(render_shell(&title, Some(&subtitle), None, content))
+ Ok(render_shell(
+ &title,
+ &shell,
+ true,
+ Some(&subtitle),
+ None,
+ None,
+ content,
+ ))
+}
+
+fn load_shell_frame(
+ store: &fidget_spinner_store_sqlite::ProjectStore,
+ active_frontier_slug: Option<Slug>,
+) -> Result<ShellFrame, StoreError> {
+ Ok(ShellFrame {
+ active_frontier_slug,
+ frontiers: store.list_frontiers()?,
+ project_status: store.status()?,
+ })
+}
+
+fn render_frontier_tab_content(
+ store: &fidget_spinner_store_sqlite::ProjectStore,
+ projection: &FrontierOpenProjection,
+ tab: FrontierTab,
+ metric_selector: Option<&str>,
+ limit: Option<u32>,
+) -> Result<Markup, StoreError> {
+ match tab {
+ FrontierTab::Brief => Ok(html! {
+ (render_frontier_header(&projection.frontier))
+ (render_frontier_brief(projection))
+ (render_frontier_active_sets(projection))
+ }),
+ FrontierTab::Open => Ok(html! {
+ (render_frontier_header(&projection.frontier))
+ (render_hypothesis_current_state_grid(&projection.active_hypotheses, limit))
+ (render_open_experiment_grid(&projection.open_experiments, limit))
+ }),
+ FrontierTab::Closed => {
+ let closed_hypotheses = store
+ .list_hypotheses(ListHypothesesQuery {
+ frontier: Some(projection.frontier.slug.to_string()),
+ limit: None,
+ ..ListHypothesesQuery::default()
+ })?
+ .into_iter()
+ .filter(|hypothesis| hypothesis.open_experiment_count == 0)
+ .collect::<Vec<_>>();
+ let closed_experiments = store.list_experiments(ListExperimentsQuery {
+ frontier: Some(projection.frontier.slug.to_string()),
+ status: Some(ExperimentStatus::Closed),
+ limit: None,
+ ..ListExperimentsQuery::default()
+ })?;
+ Ok(html! {
+ (render_frontier_header(&projection.frontier))
+ (render_closed_hypothesis_grid(&closed_hypotheses, limit))
+ (render_experiment_section("Closed Experiments", &closed_experiments, limit))
+ })
+ }
+ FrontierTab::Metrics => {
+ let metric_keys = if projection.active_metric_keys.is_empty() {
+ store.metric_keys(MetricKeysQuery {
+ frontier: Some(projection.frontier.slug.to_string()),
+ scope: MetricScope::Visible,
+ })?
+ } else {
+ projection.active_metric_keys.clone()
+ };
+ let selected_metric = metric_selector
+ .and_then(|selector| NonEmptyText::new(selector.to_owned()).ok())
+ .or_else(|| metric_keys.first().map(|metric| metric.key.clone()));
+ let series = selected_metric
+ .as_ref()
+ .map(|metric| {
+ store.frontier_metric_series(projection.frontier.slug.as_str(), metric, true)
+ })
+ .transpose()?;
+ Ok(html! {
+ (render_frontier_header(&projection.frontier))
+ (render_metric_series_section(
+ &projection.frontier.slug,
+ &metric_keys,
+ selected_metric.as_ref(),
+ series.as_ref(),
+ limit,
+ ))
+ })
+ }
+ }
+}
+
+fn render_frontier_tab_bar(
+ frontier_slug: &Slug,
+ active_tab: FrontierTab,
+ metric: Option<&str>,
+) -> Markup {
+ const TABS: [FrontierTab; 4] = [
+ FrontierTab::Brief,
+ FrontierTab::Open,
+ FrontierTab::Closed,
+ FrontierTab::Metrics,
+ ];
+ html! {
+ nav.tab-row aria-label="Frontier tabs" {
+ @for tab in TABS {
+ @let href = frontier_tab_href(frontier_slug, tab, metric);
+ a
+ href=(href)
+ class={(if tab == active_tab { "tab-chip active" } else { "tab-chip" })}
+ {
+ (tab.label())
+ }
+ }
+ }
+ }
+}
+
+fn render_closed_hypothesis_grid(
+ hypotheses: &[fidget_spinner_store_sqlite::HypothesisSummary],
+ limit: Option<u32>,
+) -> Markup {
+ html! {
+ section.card {
+ h2 { "Closed Hypotheses" }
+ @if hypotheses.is_empty() {
+ p.muted { "No dormant hypotheses yet." }
+ } @else {
+ div.card-grid {
+ @for hypothesis in limit_items(hypotheses, limit) {
+ article.mini-card {
+ div.card-header {
+ a.title-link href=(hypothesis_href(&hypothesis.slug)) {
+ (hypothesis.title)
+ }
+ @if let Some(verdict) = hypothesis.latest_verdict {
+ span class=(status_chip_classes(verdict_class(verdict))) {
+ (verdict.as_str())
+ }
+ }
+ }
+ p.prose { (hypothesis.summary) }
+ @if !hypothesis.tags.is_empty() {
+ div.chip-row {
+ @for tag in &hypothesis.tags {
+ span.tag-chip { (tag) }
+ }
+ }
+ }
+ div.meta-row.muted {
+ span { "updated " (format_timestamp(hypothesis.updated_at)) }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_metric_series_section(
+ frontier_slug: &Slug,
+ metric_keys: &[fidget_spinner_store_sqlite::MetricKeySummary],
+ selected_metric: Option<&NonEmptyText>,
+ series: Option<&FrontierMetricSeries>,
+ limit: Option<u32>,
+) -> Markup {
+ html! {
+ section.card {
+ h2 { "Metrics" }
+ p.prose {
+ "Server-rendered SVG over the frontier’s closed experiment ledger. Choose a live metric, then walk to the underlying experiments deliberately."
+ }
+ @if metric_keys.is_empty() {
+ p.muted { "No visible metrics registered for this frontier." }
+ } @else {
+ div.metric-picker {
+ @for metric in metric_keys {
+ @let href = frontier_tab_href(frontier_slug, FrontierTab::Metrics, Some(metric.key.as_str()));
+ a
+ href=(href)
+ class={(if selected_metric.is_some_and(|selected| selected == &metric.key) {
+ "metric-choice active"
+ } else {
+ "metric-choice"
+ })}
+ {
+ span.metric-choice-key { (metric.key) }
+ span.metric-choice-meta {
+ (metric.objective.as_str()) " · "
+ (metric.unit.as_str())
+ }
+ }
+ }
+ }
+ }
+ }
+ @if let Some(series) = series {
+ section.card {
+ div.card-header {
+ h2 { "Plot" }
+ span.metric-pill {
+ (series.metric.key) " · "
+ (series.metric.objective.as_str()) " · "
+ (series.metric.unit.as_str())
+ }
+ }
+ @if let Some(description) = series.metric.description.as_ref() {
+ p.muted { (description) }
+ }
+ @if series.points.is_empty() {
+ p.muted { "No closed experiments for this metric yet." }
+ } @else {
+ div.chart-frame {
+ (PreEscaped(render_metric_chart_svg(series)))
+ }
+ p.muted {
+ "x = close order, y = metric value. Point color tracks verdict."
+ }
+ table.metric-table {
+ thead {
+ tr {
+ th { "#" }
+ th { "Experiment" }
+ th { "Hypothesis" }
+ th { "Closed" }
+ th { "Verdict" }
+ th { "Value" }
+ }
+ }
+ tbody {
+ @for (index, point) in limit_items(&series.points, limit).iter().enumerate() {
+ tr {
+ td { ((index + 1).to_string()) }
+ td {
+ a href=(experiment_href(&point.experiment.slug)) {
+ (point.experiment.title)
+ }
+ }
+ td {
+ a href=(hypothesis_href(&point.hypothesis.slug)) {
+ (point.hypothesis.title)
+ }
+ }
+ td { (format_timestamp(point.closed_at)) }
+ td {
+ span class=(status_chip_classes(verdict_class(point.verdict))) {
+ (point.verdict.as_str())
+ }
+ }
+ td { (format_metric_value(point.value, series.metric.unit)) }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String {
+ let mut svg = String::new();
+ {
+ let root = SVGBackend::with_string(&mut svg, (960, 360)).into_drawing_area();
+ if root.fill(&RGBColor(255, 250, 242)).is_err() {
+ return chart_error_markup("chart fill failed");
+ }
+ let values = series
+ .points
+ .iter()
+ .map(|point| point.value)
+ .collect::<Vec<_>>();
+ let (mut min_value, mut max_value) = values
+ .iter()
+ .copied()
+ .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), value| {
+ (min.min(value), max.max(value))
+ });
+ if !min_value.is_finite() || !max_value.is_finite() {
+ return chart_error_markup("metric values are non-finite");
+ }
+ if (max_value - min_value).abs() < f64::EPSILON {
+ let pad = if max_value.abs() < 1.0 {
+ 1.0
+ } else {
+ max_value.abs() * 0.05
+ };
+ min_value -= pad;
+ max_value += pad;
+ } else {
+ let pad = (max_value - min_value) * 0.08;
+ min_value -= pad;
+ max_value += pad;
+ }
+ let x_end = i32::try_from(series.points.len().saturating_sub(1))
+ .unwrap_or(0)
+ .max(1);
+ let mut chart = match ChartBuilder::on(&root)
+ .margin(18)
+ .x_label_area_size(32)
+ .y_label_area_size(72)
+ .caption(
+ format!("{} over closed experiments", series.metric.key),
+ ("Iosevka Web", 18).into_font().color(&BLACK),
+ )
+ .build_cartesian_2d(0_i32..x_end, min_value..max_value)
+ {
+ Ok(chart) => chart,
+ Err(error) => return chart_error_markup(&format!("chart build failed: {error:?}")),
+ };
+ if chart
+ .configure_mesh()
+ .light_line_style(RGBColor(223, 209, 189).mix(0.6))
+ .bold_line_style(RGBColor(207, 190, 168).mix(0.8))
+ .axis_style(RGBColor(103, 86, 63))
+ .label_style(("Iosevka Web", 12).into_font().color(&RGBColor(79, 71, 58)))
+ .x_desc("close order")
+ .y_desc(series.metric.unit.as_str())
+ .x_label_formatter(&|value| format!("{}", value + 1))
+ .draw()
+ .is_err()
+ {
+ return chart_error_markup("mesh draw failed");
+ }
+
+ let line_points = series
+ .points
+ .iter()
+ .enumerate()
+ .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, point.value)))
+ .collect::<Vec<_>>();
+ if chart
+ .draw_series(LineSeries::new(line_points, &RGBColor(103, 86, 63)))
+ .map(|series| {
+ series.label("series").legend(|(x, y)| {
+ PathElement::new(vec![(x, y), (x + 18, y)], RGBColor(103, 86, 63))
+ })
+ })
+ .is_err()
+ {
+ return chart_error_markup("line draw failed");
+ }
+
+ let points = series
+ .points
+ .iter()
+ .enumerate()
+ .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, point)))
+ .collect::<Vec<_>>();
+ if chart
+ .draw_series(points.iter().map(|(x, point)| {
+ Circle::new(
+ (*x, point.value),
+ 4,
+ ShapeStyle::from(&verdict_color(point.verdict)).filled(),
+ )
+ }))
+ .is_err()
+ {
+ return chart_error_markup("point draw failed");
+ }
+ if chart
+ .draw_series(points.iter().map(|(x, point)| {
+ Text::new(
+ format!("{}", x + 1),
+ (*x, point.value),
+ ("Iosevka Web", 11)
+ .into_font()
+ .color(&verdict_color(point.verdict)),
+ )
+ }))
+ .is_err()
+ {
+ return chart_error_markup("label draw failed");
+ }
+ if root.present().is_err() {
+ return chart_error_markup("chart present failed");
+ }
+ }
+ svg
+}
+
+fn chart_error_markup(message: &str) -> String {
+ format!(
+ "<div class=\"chart-error\">chart render failed: {}</div>",
+ html_escape(message)
+ )
+}
+
+fn html_escape(raw: &str) -> String {
+ raw.replace('&', "&amp;")
+ .replace('<', "&lt;")
+ .replace('>', "&gt;")
+}
+
+fn verdict_color(verdict: FrontierVerdict) -> RGBColor {
+ match verdict {
+ FrontierVerdict::Accepted => RGBColor(71, 102, 63),
+ FrontierVerdict::Kept => RGBColor(90, 105, 82),
+ FrontierVerdict::Parked => RGBColor(138, 98, 48),
+ FrontierVerdict::Rejected => RGBColor(138, 58, 52),
+ }
}
fn render_frontier_grid(frontiers: &[FrontierSummary], limit: Option<u32>) -> Markup {
@@ -354,16 +839,15 @@ fn render_frontier_brief(projection: &FrontierOpenProjection) -> Markup {
p.muted { "No roadmap ordering recorded." }
} @else {
ol.roadmap-list {
- @for item in &frontier.brief.roadmap {
- @let title = hypothesis_title_for_roadmap_item(projection, item.hypothesis_id);
- li {
- a href=(hypothesis_href_from_id(item.hypothesis_id)) {
- (format!("{}.", item.rank)) " "
- (title)
- }
- @if let Some(summary) = item.summary.as_ref() {
- span.muted { " · " (summary) }
- }
+ @for item in &frontier.brief.roadmap {
+ @let title = hypothesis_title_for_roadmap_item(projection, item.hypothesis_id);
+ li {
+ a href=(hypothesis_href_from_id(item.hypothesis_id)) {
+ (title)
+ }
+ @if let Some(summary) = item.summary.as_ref() {
+ span.muted { " · " (summary) }
+ }
}
}
}
@@ -390,13 +874,13 @@ fn render_frontier_active_sets(projection: &FrontierOpenProjection) -> Markup {
html! {
section.card {
h2 { "Active Surface" }
- div.split {
- div.subcard {
+ div.stack {
+ div.subcard.compact-subcard {
h3 { "Active Tags" }
@if projection.active_tags.is_empty() {
p.muted { "No active tags." }
} @else {
- div.chip-row {
+ div.chip-row.tag-cloud {
@for tag in &projection.active_tags {
span.tag-chip { (tag) }
}
@@ -420,7 +904,15 @@ fn render_frontier_active_sets(projection: &FrontierOpenProjection) -> Markup {
tbody {
@for metric in &projection.active_metric_keys {
tr {
- td { (metric.key) }
+ td {
+ a href=(frontier_tab_href(
+ &projection.frontier.slug,
+ FrontierTab::Metrics,
+ Some(metric.key.as_str()),
+ )) {
+ (metric.key)
+ }
+ }
td { (metric.unit.as_str()) }
td { (metric.objective.as_str()) }
td { (metric.reference_count) }
@@ -914,8 +1406,11 @@ fn render_prose_block(title: &str, body: &str) -> Markup {
fn render_shell(
title: &str,
+ shell: &ShellFrame,
+ show_page_header: bool,
subtitle: Option<&str>,
breadcrumb: Option<(&str, String)>,
+ tab_bar: Option<Markup>,
content: Markup,
) -> Markup {
html! {
@@ -929,24 +1424,76 @@ fn render_shell(
}
body {
main.shell {
- header.page-header {
- div.eyebrow {
- a href="/" { "home" }
- @if let Some((label, href)) = breadcrumb {
- span.sep { "/" }
- a href=(href) { (label) }
+ aside.sidebar {
+ (render_sidebar(shell))
+ }
+ div.main-column {
+ @if show_page_header {
+ header.page-header {
+ div.eyebrow {
+ a href="/" { "home" }
+ @if let Some((label, href)) = breadcrumb {
+ span.sep { "/" }
+ a href=(href) { (label) }
+ }
+ }
+ h1.page-title { (title) }
+ @if let Some(subtitle) = subtitle {
+ p.page-subtitle { (subtitle) }
+ }
}
}
- h1.page-title { (title) }
- @if let Some(subtitle) = subtitle {
- p.page-subtitle { (subtitle) }
+ @if let Some(tab_bar) = tab_bar {
+ (tab_bar)
+ }
+ (content)
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_sidebar(shell: &ShellFrame) -> Markup {
+ html! {
+ section.sidebar-panel {
+ div.sidebar-project {
+ a.sidebar-home href="/" { (&shell.project_status.display_name) }
+ p.sidebar-copy {
+ "Frontier-scoped navigator. Open one frontier, then walk hypotheses and experiments deliberately."
+ }
+ }
+ div.sidebar-section {
+ h2 { "Frontiers" }
+ @if shell.frontiers.is_empty() {
+ p.muted { "No frontiers yet." }
+ } @else {
+ nav.frontier-nav aria-label="Frontiers" {
+ @for frontier in &shell.frontiers {
+ a
+ href=(frontier_href(&frontier.slug))
+ class={(if shell
+ .active_frontier_slug
+ .as_ref()
+ .is_some_and(|active| active == &frontier.slug)
+ {
+ "frontier-nav-link active"
+ } else {
+ "frontier-nav-link"
+ })}
+ {
+ span.frontier-nav-title { (&frontier.label) }
+ span.frontier-nav-meta {
+ (frontier.active_hypothesis_count) " active · "
+ (frontier.open_experiment_count) " open"
+ }
}
}
- (content)
}
}
}
}
+ }
}
fn render_kv(label: &str, value: &str) -> Markup {
@@ -1017,6 +1564,19 @@ fn frontier_href(slug: &Slug) -> String {
format!("/frontier/{}", encode_path_segment(slug.as_str()))
}
+fn frontier_tab_href(slug: &Slug, tab: FrontierTab, metric: Option<&str>) -> String {
+ let mut href = format!(
+ "/frontier/{}?tab={}",
+ encode_path_segment(slug.as_str()),
+ tab.as_query()
+ );
+ if let Some(metric) = metric.filter(|metric| !metric.trim().is_empty()) {
+ href.push_str("&metric=");
+ href.push_str(&encode_path_segment(metric));
+ }
+ href
+}
+
fn hypothesis_href(slug: &Slug) -> String {
format!("/hypothesis/{}", encode_path_segment(slug.as_str()))
}
@@ -1128,21 +1688,21 @@ fn styles() -> &'static str {
r#"
:root {
color-scheme: light;
- --bg: #f6f3ec;
- --panel: #fffdf8;
- --panel-2: #f3eee4;
- --border: #d8d1c4;
- --border-strong: #c8bfaf;
- --text: #22201a;
- --muted: #746e62;
- --accent: #2d5c4d;
- --accent-soft: #dbe8e2;
- --tag: #ece5d8;
- --accepted: #2f6b43;
- --kept: #3d6656;
- --parked: #8b5b24;
- --rejected: #8a2f2f;
- --shadow: rgba(74, 58, 32, 0.06);
+ --bg: #faf5ec;
+ --panel: #fffaf2;
+ --panel-2: #f6eee1;
+ --border: #dfd1bd;
+ --border-strong: #cfbea8;
+ --text: #241d16;
+ --muted: #6f6557;
+ --accent: #67563f;
+ --accent-soft: #ece2d2;
+ --tag: #efe5d7;
+ --accepted: #47663f;
+ --kept: #5a6952;
+ --parked: #8a6230;
+ --rejected: #8a3a34;
+ --shadow: rgba(83, 61, 33, 0.055);
}
* { box-sizing: border-box; }
body {
@@ -1150,6 +1710,7 @@ fn styles() -> &'static str {
background: var(--bg);
color: var(--text);
font: 15px/1.55 "Iosevka Web", "IBM Plex Mono", "SFMono-Regular", monospace;
+ overflow-x: hidden;
}
a {
color: var(--accent);
@@ -1161,7 +1722,71 @@ fn styles() -> &'static str {
margin: 0 auto;
padding: 24px 24px 40px;
display: grid;
+ gap: 20px;
+ grid-template-columns: 280px minmax(0, 1fr);
+ align-items: start;
+ min-width: 0;
+ overflow-x: clip;
+ }
+ .sidebar {
+ position: sticky;
+ top: 18px;
+ min-width: 0;
+ }
+ .sidebar-panel {
+ border: 1px solid var(--border);
+ background: var(--panel);
+ padding: 18px 16px;
+ display: grid;
+ gap: 16px;
+ box-shadow: 0 1px 0 var(--shadow);
+ }
+ .sidebar-project {
+ display: grid;
+ gap: 8px;
+ }
+ .sidebar-home {
+ color: var(--text);
+ font-size: 18px;
+ font-weight: 700;
+ }
+ .sidebar-copy {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+ }
+ .sidebar-section {
+ display: grid;
+ gap: 10px;
+ }
+ .frontier-nav {
+ display: grid;
+ gap: 8px;
+ }
+ .frontier-nav-link {
+ display: grid;
+ gap: 4px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ background: var(--panel-2);
+ }
+ .frontier-nav-link.active {
+ border-color: var(--border-strong);
+ background: var(--accent-soft);
+ }
+ .frontier-nav-title {
+ color: var(--text);
+ font-weight: 700;
+ }
+ .frontier-nav-meta {
+ color: var(--muted);
+ font-size: 12px;
+ }
+ .main-column {
+ display: grid;
gap: 18px;
+ min-width: 0;
}
.page-header {
display: grid;
@@ -1170,6 +1795,7 @@ fn styles() -> &'static str {
border: 1px solid var(--border);
background: var(--panel);
box-shadow: 0 1px 0 var(--shadow);
+ min-width: 0;
}
.eyebrow {
display: flex;
@@ -1186,6 +1812,7 @@ fn styles() -> &'static str {
font-size: clamp(22px, 3.8vw, 34px);
line-height: 1.1;
overflow-wrap: anywhere;
+ word-break: break-word;
}
.page-subtitle {
margin: 0;
@@ -1193,6 +1820,28 @@ fn styles() -> &'static str {
max-width: 90ch;
overflow-wrap: anywhere;
}
+ .tab-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+ .tab-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--muted);
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+ .tab-chip.active {
+ color: var(--text);
+ border-color: var(--border-strong);
+ background: var(--accent-soft);
+ font-weight: 700;
+ }
.card {
border: 1px solid var(--border);
background: var(--panel);
@@ -1200,6 +1849,7 @@ fn styles() -> &'static str {
display: grid;
gap: 14px;
box-shadow: 0 1px 0 var(--shadow);
+ min-width: 0;
}
.subcard {
border: 1px solid var(--border);
@@ -1210,7 +1860,14 @@ fn styles() -> &'static str {
min-width: 0;
align-content: start;
}
+ .compact-subcard {
+ justify-items: start;
+ }
.block { display: grid; gap: 10px; }
+ .stack {
+ display: grid;
+ gap: 14px;
+ }
.split {
display: grid;
gap: 16px;
@@ -1243,10 +1900,16 @@ fn styles() -> &'static str {
font-weight: 700;
color: var(--text);
overflow-wrap: anywhere;
+ word-break: break-word;
+ flex: 1 1 auto;
+ min-width: 0;
}
h1, h2, h3 {
margin: 0;
line-height: 1.15;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ min-width: 0;
}
h2 { font-size: 19px; }
h3 { font-size: 14px; color: #4f473a; }
@@ -1288,17 +1951,49 @@ fn styles() -> &'static str {
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
+ align-content: flex-start;
+ justify-content: flex-start;
}
+ .tag-cloud { max-width: 100%; }
.tag-chip, .kind-chip, .status-chip, .metric-pill {
display: inline-flex;
align-items: center;
- width: fit-content;
+ flex: 0 0 auto;
+ width: auto;
max-width: 100%;
border: 1px solid var(--border-strong);
background: var(--tag);
padding: 4px 8px;
font-size: 12px;
line-height: 1.2;
+ white-space: nowrap;
+ }
+ .metric-picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+ .metric-choice {
+ display: grid;
+ gap: 4px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ background: var(--panel-2);
+ min-width: 0;
+ }
+ .metric-choice.active {
+ border-color: var(--border-strong);
+ background: var(--accent-soft);
+ }
+ .metric-choice-key {
+ color: var(--text);
+ font-weight: 700;
+ }
+ .metric-choice-meta {
+ color: var(--muted);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
.link-chip {
display: inline-grid;
@@ -1336,17 +2031,19 @@ fn styles() -> &'static str {
letter-spacing: 0.05em;
font-weight: 700;
}
- .status-accepted { color: var(--accepted); border-color: rgba(47, 107, 67, 0.25); background: rgba(47, 107, 67, 0.08); }
- .status-kept { color: var(--kept); border-color: rgba(61, 102, 86, 0.25); background: rgba(61, 102, 86, 0.08); }
- .status-parked { color: var(--parked); border-color: rgba(139, 91, 36, 0.25); background: rgba(139, 91, 36, 0.09); }
- .status-rejected { color: var(--rejected); border-color: rgba(138, 47, 47, 0.25); background: rgba(138, 47, 47, 0.09); }
- .status-open, .status-exploring { color: var(--accent); border-color: rgba(45, 92, 77, 0.25); background: var(--accent-soft); }
+ .status-accepted { color: var(--accepted); border-color: color-mix(in srgb, var(--accepted) 24%, white); background: color-mix(in srgb, var(--accepted) 10%, white); }
+ .status-kept { color: var(--kept); border-color: color-mix(in srgb, var(--kept) 22%, white); background: color-mix(in srgb, var(--kept) 9%, white); }
+ .status-parked { color: var(--parked); border-color: color-mix(in srgb, var(--parked) 24%, white); background: color-mix(in srgb, var(--parked) 10%, white); }
+ .status-rejected { color: var(--rejected); border-color: color-mix(in srgb, var(--rejected) 24%, white); background: color-mix(in srgb, var(--rejected) 10%, white); }
+ .status-open, .status-exploring { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 22%, white); background: var(--accent-soft); }
.status-neutral, .classless { color: #5f584d; border-color: var(--border-strong); background: var(--panel); }
.status-archived { color: #7a756d; border-color: var(--border); background: var(--panel); }
.metric-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
+ display: block;
+ overflow-x: auto;
}
.metric-table th,
.metric-table td {
@@ -1354,6 +2051,7 @@ fn styles() -> &'static str {
border-top: 1px solid var(--border);
text-align: left;
vertical-align: top;
+ overflow-wrap: anywhere;
}
.metric-table th {
color: var(--muted);
@@ -1366,6 +2064,21 @@ fn styles() -> &'static str {
display: grid;
gap: 8px;
}
+ .chart-frame {
+ border: 1px solid var(--border);
+ background: var(--panel-2);
+ padding: 10px;
+ overflow-x: auto;
+ }
+ .chart-frame svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ }
+ .chart-error {
+ color: var(--rejected);
+ font-size: 13px;
+ }
.roadmap-list, .simple-list {
margin: 0;
padding-left: 18px;
@@ -1388,6 +2101,14 @@ fn styles() -> &'static str {
background: var(--panel-2);
padding: 0.05rem 0.3rem;
}
+ @media (max-width: 980px) {
+ .shell {
+ grid-template-columns: 1fr;
+ }
+ .sidebar {
+ position: static;
+ }
+ }
@media (max-width: 720px) {
.shell { padding: 12px; }
.card, .page-header { padding: 14px; }
diff --git a/crates/fidget-spinner-cli/tests/mcp_hardening.rs b/crates/fidget-spinner-cli/tests/mcp_hardening.rs
index 37e2558..c4ee002 100644
--- a/crates/fidget-spinner-cli/tests/mcp_hardening.rs
+++ b/crates/fidget-spinner-cli/tests/mcp_hardening.rs
@@ -13,6 +13,7 @@ use libmcp as _;
use libmcp_testkit::assert_no_opaque_ids;
use maud as _;
use percent_encoding as _;
+use plotters as _;
use serde as _;
use serde_json::{Value, json};
use time as _;
diff --git a/crates/fidget-spinner-store-sqlite/src/lib.rs b/crates/fidget-spinner-store-sqlite/src/lib.rs
index 3680471..283f5d3 100644
--- a/crates/fidget-spinner-store-sqlite/src/lib.rs
+++ b/crates/fidget-spinner-store-sqlite/src/lib.rs
@@ -490,6 +490,22 @@ pub struct FrontierOpenProjection {
pub open_experiments: Vec<ExperimentSummary>,
}
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub struct FrontierMetricPoint {
+ pub experiment: ExperimentSummary,
+ pub hypothesis: HypothesisSummary,
+ pub value: f64,
+ pub verdict: FrontierVerdict,
+ pub closed_at: OffsetDateTime,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub struct FrontierMetricSeries {
+ pub frontier: FrontierRecord,
+ pub metric: MetricKeySummary,
+ pub points: Vec<FrontierMetricPoint>,
+}
+
pub struct ProjectStore {
project_root: Utf8PathBuf,
state_root: Utf8PathBuf,
@@ -1381,6 +1397,57 @@ impl ProjectStore {
})
}
+ pub fn frontier_metric_series(
+ &self,
+ frontier: &str,
+ key: &NonEmptyText,
+ include_rejected: bool,
+ ) -> Result<FrontierMetricSeries, StoreError> {
+ let frontier = self.resolve_frontier(frontier)?;
+ let definition = self
+ .metric_definition(key)?
+ .ok_or_else(|| StoreError::UnknownMetricDefinition(key.clone()))?;
+ let mut points = self
+ .load_experiment_records(Some(frontier.id), None, true)?
+ .into_iter()
+ .filter(|record| record.status == ExperimentStatus::Closed)
+ .filter_map(|record| {
+ let outcome = record.outcome.clone()?;
+ if !include_rejected && outcome.verdict == FrontierVerdict::Rejected {
+ return None;
+ }
+ let metric = all_metrics(&outcome)
+ .into_iter()
+ .find(|metric| metric.key == *key)?;
+ Some((record, outcome, metric.value))
+ })
+ .map(|(record, outcome, value)| {
+ Ok(FrontierMetricPoint {
+ closed_at: outcome.closed_at,
+ experiment: self.experiment_summary_from_record(record.clone())?,
+ hypothesis: self.hypothesis_summary_from_record(
+ self.hypothesis_by_id(record.hypothesis_id)?,
+ )?,
+ value,
+ verdict: outcome.verdict,
+ })
+ })
+ .collect::<Result<Vec<_>, StoreError>>()?;
+ points.sort_by_key(|point| point.closed_at);
+ Ok(FrontierMetricSeries {
+ metric: MetricKeySummary {
+ key: definition.key.clone(),
+ unit: definition.unit,
+ objective: definition.objective,
+ visibility: definition.visibility,
+ description: definition.description,
+ reference_count: self.metric_reference_count(Some(frontier.id), key)?,
+ },
+ frontier,
+ points,
+ })
+ }
+
pub fn metric_keys(&self, query: MetricKeysQuery) -> Result<Vec<MetricKeySummary>, StoreError> {
let frontier_id = query
.frontier
diff --git a/docs/frontier-membership-design.md b/docs/frontier-membership-design.md
new file mode 100644
index 0000000..3bccf49
--- /dev/null
+++ b/docs/frontier-membership-design.md
@@ -0,0 +1,242 @@
+# Frontier Membership Without Partitioning
+
+## Status
+
+Prospective design note only. Do not implement yet.
+
+The current single-frontier posture is acceptable for now. This note captures a
+clean future cut once overlapping frontier work becomes real enough to justify
+the model change.
+
+## Thesis
+
+Frontiers should be scopes, not partitions.
+
+`hypothesis` and `experiment` are the real scientific graph vertices.
+`frontier` is a control object with a brief and a bounded grounding projection.
+It should not own vertices exclusively.
+
+The current model still treats frontier as an ownership partition:
+
+- every hypothesis has one `frontier_id`
+- every experiment has one `frontier_id`
+- experiments inherit that frontier from their owning hypothesis
+- influence edges may not cross frontier boundaries
+
+That is stricter than the real ontology. In practice:
+
+- one hypothesis may matter to multiple frontiers
+- one experiment may be relevant to multiple frontiers
+- later hypotheses may be directly informed by experiments from another frontier
+
+The current shape is tolerable while there is effectively one live frontier. It
+is not the right long-term model.
+
+## Desired Ontology
+
+### Graph vertices
+
+The true graph vertices remain:
+
+- `hypothesis`
+- `experiment`
+
+### Control objects
+
+`frontier` is not a graph vertex. It is a scope object that owns:
+
+- label
+- objective
+- status
+- brief
+
+### Sidecars
+
+`artifact` remains an attachable reference-only sidecar.
+
+## Relations
+
+Two relations remain fundamental.
+
+### 1. Ownership
+
+Every experiment has exactly one owning hypothesis.
+
+This is the canonical tree spine. It should remain mandatory and singular.
+
+### 2. Influence
+
+Hypotheses and experiments may both influence later hypotheses or experiments.
+
+This is the sparse DAG laid over the ownership spine. It should no longer be
+artificially constrained by frontier boundaries.
+
+### 3. Membership
+
+Frontier membership becomes a separate many-to-many relation.
+
+A hypothesis or experiment may belong to zero, one, or many frontiers.
+
+This relation is about scope and visibility, not causality.
+
+## Recommended Data Model
+
+### Remove vertex-owned frontier identity
+
+Delete the idea that hypotheses or experiments intrinsically belong to exactly
+one frontier.
+
+Concretely, the long-term cut should remove:
+
+- `HypothesisRecord.frontier_id`
+- `ExperimentRecord.frontier_id`
+- `CrossFrontierInfluence`
+
+### Add explicit membership tables
+
+Use explicit membership relations instead:
+
+- `frontier_hypotheses(frontier_id, hypothesis_id, added_at)`
+- `frontier_experiments(frontier_id, experiment_id, added_at)`
+
+The split tables are preferable to one polymorphic membership table because the
+types and invariants are simpler, and queries stay more direct.
+
+### Preserve one hard invariant
+
+An experiment may only be attached to a frontier if its owning hypothesis is
+also attached to that frontier.
+
+This prevents the frontier scope from containing orphan experiments whose
+canonical spine is missing from the same view.
+
+That still allows:
+
+- one hypothesis in multiple frontiers
+- one experiment in multiple frontiers
+- one experiment in a subset of its hypothesis frontiers
+
+but disallows:
+
+- experiment in frontier `B` while owning hypothesis is absent from `B`
+
+## Query Semantics
+
+### `frontier.open`
+
+`frontier.open` should derive its surface from membership, not ownership.
+
+Its bounded output should still be:
+
+- frontier brief
+- active tags
+- live metric keys
+- active hypotheses with deduped current state
+- open experiments
+
+But all of those should be computed from frontier members.
+
+### Active hypotheses
+
+Active hypotheses should be derived from a bounded combination of:
+
+- roadmap membership in the frontier brief
+- hypotheses with open experiments in the frontier
+- hypotheses with latest non-rejected closed experiments still relevant to the
+ frontier
+
+The exact rule can stay implementation-local as long as the result is bounded
+and legible.
+
+### Live metrics
+
+The right default is not “all metrics touched by frontier members.”
+
+The live metric set should be derived from:
+
+- all open experiments in the frontier
+- the immediate comparison context for those open experiments
+
+A good default comparison context is:
+
+- the union of metric keys on all open experiments
+- plus the metric keys on immediate experiment ancestors of those open
+ experiments
+
+This keeps the hot path focused on the active A/B comparison set rather than
+every historical metric ever observed in the scope.
+
+## Surface Changes
+
+When this cut happens, the public model should grow explicit membership
+operations rather than smuggling scope through create-time ownership.
+
+Likely surfaces:
+
+- `frontier.member.add`
+- `frontier.member.remove`
+- `frontier.member.list`
+
+Or, if we prefer type-specific verbs:
+
+- `frontier.hypothesis.add`
+- `frontier.hypothesis.remove`
+- `frontier.experiment.add`
+- `frontier.experiment.remove`
+
+I prefer the type-specific form because it is clearer for agents and avoids a
+generic weakly-typed membership tool.
+
+Read/list filters should then interpret `frontier=` as membership selection.
+
+## Brief and Roadmap Semantics
+
+The frontier brief remains a singleton owned by the frontier.
+
+Its roadmap should reference frontier-member hypotheses only.
+
+That is a healthy constraint:
+
+- the brief is a scoped grounding object
+- roadmap entries should not point outside the scope they summarize
+
+If a hypothesis becomes relevant to a frontier roadmap, attach it to that
+frontier first.
+
+## Migration Shape
+
+This should be a red cut when it happens.
+
+No backward-compatibility layer is needed if the project is still early enough
+that re-seeding is acceptable.
+
+The migration is straightforward:
+
+1. Create frontier membership tables.
+2. For every hypothesis, insert one membership row from its current
+ `frontier_id`.
+3. For every experiment, insert one membership row from its current
+ `frontier_id`.
+4. Drop `frontier_id` from hypotheses and experiments.
+5. Delete `CrossFrontierInfluence`.
+6. Rewrite frontier-scoped queries to use membership joins.
+
+Because the current world is effectively single-frontier, this is mostly a
+normalization cut rather than a semantic salvage operation.
+
+## Why This Is Better
+
+This model matches the clarified ontology:
+
+- the scientific truth lives in hypotheses and experiments
+- the frontier is a bounded lens over that truth
+- scope should not distort causality
+
+It also makes later comparative work cleaner:
+
+- one hypothesis can be reused across multiple frontier narratives
+- one experiment can inform more than one frontier without duplication
+- influence edges can remain honest even when they cross scope boundaries
+
+That is a better fit for a system whose real purpose is an austere experimental
+record spine rather than a partitioned project tracker.
diff --git a/scripts/install-local.sh b/scripts/install-local.sh
index b087f70..b60027e 100755
--- a/scripts/install-local.sh
+++ b/scripts/install-local.sh
@@ -3,9 +3,18 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SKILL_SOURCE_ROOT="${ROOT_DIR}/assets/codex-skills"
+SYSTEMD_TEMPLATE_ROOT="${ROOT_DIR}/assets/systemd"
LOCAL_ROOT="${1:-$HOME/.local}"
SKILL_DEST_ROOT="${2:-$HOME/.codex/skills}"
LOCAL_BIN_DIR="${LOCAL_ROOT}/bin"
+SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
+UI_SERVICE_NAME="${FIDGET_SPINNER_UI_SERVICE_NAME:-fidget-spinner-libgrid-ui.service}"
+UI_PROJECT_ROOT="${FIDGET_SPINNER_UI_PROJECT:-$HOME/programming/projects/libgrid/.worktrees/libgrid-lp-oracle-cutset}"
+UI_BIND="${FIDGET_SPINNER_UI_BIND:-127.0.0.1:8913}"
+
+escape_sed_replacement() {
+ printf '%s' "$1" | sed -e 's/[\\/&]/\\&/g'
+}
install_skill_link() {
local name="$1"
@@ -17,6 +26,78 @@ install_skill_link() {
printf 'installed skill symlink: %s -> %s\n' "${dest_dir}" "${source_dir}"
}
+listener_pid_for_bind() {
+ local bind="$1"
+ local port="${bind##*:}"
+ ss -ltnp "( sport = :${port} )" 2>/dev/null \
+ | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' \
+ | head -n 1
+}
+
+evict_conflicting_navigator() {
+ local pid
+ pid="$(listener_pid_for_bind "${UI_BIND}")"
+ if [[ -z "${pid}" ]]; then
+ return 0
+ fi
+ local cmd
+ cmd="$(ps -p "${pid}" -o args= || true)"
+ if [[ "${cmd}" == *"fidget-spinner-cli ui serve"* ]]; then
+ kill "${pid}"
+ for _ in {1..20}; do
+ if ! kill -0 "${pid}" 2>/dev/null; then
+ printf 'stopped conflicting navigator process: pid=%s\n' "${pid}"
+ return 0
+ fi
+ sleep 0.1
+ done
+ printf 'failed to stop conflicting navigator process: pid=%s\n' "${pid}" >&2
+ return 1
+ fi
+ printf 'refusing to steal %s from non-spinner process: %s\n' "${UI_BIND}" "${cmd}" >&2
+ return 1
+}
+
+install_libgrid_ui_service() {
+ if [[ ! -d "${UI_PROJECT_ROOT}" ]]; then
+ printf 'libgrid navigator root does not exist: %s\n' "${UI_PROJECT_ROOT}" >&2
+ return 1
+ fi
+ if ! command -v systemctl >/dev/null 2>&1; then
+ printf 'systemctl unavailable; skipping navigator service install\n' >&2
+ return 0
+ fi
+
+ local service_path="${SYSTEMD_USER_DIR}/${UI_SERVICE_NAME}"
+ local template_path="${SYSTEMD_TEMPLATE_ROOT}/${UI_SERVICE_NAME}.in"
+ mkdir -p "${SYSTEMD_USER_DIR}"
+ sed \
+ -e "s|@HOME@|$(escape_sed_replacement "${HOME}")|g" \
+ -e "s|@LOCAL_BIN_DIR@|$(escape_sed_replacement "${LOCAL_BIN_DIR}")|g" \
+ -e "s|@UI_PROJECT_ROOT@|$(escape_sed_replacement "${UI_PROJECT_ROOT}")|g" \
+ -e "s|@UI_BIND@|$(escape_sed_replacement "${UI_BIND}")|g" \
+ "${template_path}" > "${service_path}"
+ chmod 0644 "${service_path}"
+ printf 'installed user service: %s\n' "${service_path}"
+
+ export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
+ if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "${XDG_RUNTIME_DIR}/bus" ]]; then
+ export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
+ fi
+ if ! systemctl --user daemon-reload; then
+ printf 'systemd user manager unavailable; skipping navigator service activation\n' >&2
+ return 0
+ fi
+ evict_conflicting_navigator
+ if systemctl --user is-enabled --quiet "${UI_SERVICE_NAME}"; then
+ systemctl --user restart "${UI_SERVICE_NAME}"
+ printf 'restarted user service: %s\n' "${UI_SERVICE_NAME}"
+ else
+ systemctl --user enable --now "${UI_SERVICE_NAME}"
+ printf 'enabled user service: %s\n' "${UI_SERVICE_NAME}"
+ fi
+}
+
mkdir -p "${LOCAL_BIN_DIR}"
cargo build --release -p fidget-spinner-cli --manifest-path "${ROOT_DIR}/Cargo.toml"
@@ -28,5 +109,6 @@ printf 'installed binary: %s\n' "${LOCAL_BIN_DIR}/fidget-spinner-cli"
install_skill_link "fidget-spinner"
install_skill_link "frontier-loop"
+install_libgrid_ui_service
printf 'mcp command: %s\n' "${LOCAL_BIN_DIR}/fidget-spinner-cli mcp serve"