diff --git a/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md b/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md
new file mode 100644
index 0000000..8f6b58f
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md
@@ -0,0 +1,403 @@
+# maestro-web Bundle Build Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make every compiled `maestro-web` binary embed a real SPA bundle by building the frontend in `build.rs`, so no install method ever serves the "bundle not built" placeholder.
+
+**Architecture:** A new `crates/maestro-web/build.rs` runs `pnpm install --frozen-lockfile && pnpm build` in `web/` before `include_dir!` embeds `web/dist`; missing pnpm/node hard-fails the build. The installer wizard preflights the toolchain at its opt-in web step and prompts abort/skip when it's absent. CI gains the Node + pnpm toolchain.
+
+**Tech Stack:** Rust (cargo build scripts, `std::process::Command`), pnpm 9.15.4 + Node, Gitea Actions CI.
+
+**Spec:** `docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md`
+
+---
+
+## File Structure
+
+- **Create** `crates/maestro-web/build.rs` — builds the SPA bundle and declares frontend rerun triggers. Sole responsibility: guarantee `web/dist` is fresh before compile.
+- **Modify** `crates/maestro-installer/src/main.rs` — add a pure `classify_web_prereq` decision + `frontend_toolchain_present` probe, and wire the abort/skip prompt into `handle_web`.
+- **Modify** `.gitea/workflows/ci.yml` — install Node + pnpm so the workspace compiles `maestro-web`.
+
+No changes to `static_bundle.rs` (the placeholder stays as unreachable defense-in-depth).
+
+---
+
+## Task 1: build.rs builds the SPA bundle at compile time
+
+**Files:**
+- Create: `crates/maestro-web/build.rs`
+- Test: `crates/maestro-web/tests/embedded_bundle.rs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `crates/maestro-web/tests/embedded_bundle.rs`. It asserts the embedded
+index is the real SPA (which mounts `
`), not the placeholder
+(whose `
` reads "maestro-web is running"). `serve_index` is public in
+`maestro_web::static_bundle` via `lib.rs`.
+
+```rust
+//! The compiled binary must embed the real SPA bundle, never the placeholder.
+//! `build.rs` guarantees `web/dist` is populated before `include_dir!` runs.
+
+use axum::body::to_bytes;
+use maestro_web::static_bundle::serve_index;
+
+#[tokio::test]
+async fn embedded_index_is_real_spa_not_placeholder() {
+ let resp = serve_index();
+ let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
+ let html = String::from_utf8(body.to_vec()).unwrap();
+
+ assert!(
+ html.contains(r#"id="app""#),
+ "served index is missing the SPA mount point; got:\n{html}"
+ );
+ assert!(
+ !html.contains("maestro-web is running"),
+ "served index is the placeholder, not the built bundle"
+ );
+}
+```
+
+`static_bundle` is already `pub mod` in `crates/maestro-web/src/lib.rs` and `serve_index` is public, so no `lib.rs` change is needed. The `axum` and `tokio` dev-deps are already in `Cargo.toml`.
+
+- [ ] **Step 2: Run the test and verify it fails**
+
+First force an empty bundle so the test has a true red (this machine already has a built `web/dist`):
+
+```bash
+rm -rf web/dist && mkdir -p web/dist && touch web/dist/.gitkeep
+cargo test -p maestro-web --test embedded_bundle
+```
+
+Expected: FAIL — `serve_index()` returns the placeholder, so the `id="app"` assertion fails. (Without `build.rs` yet, nothing rebuilds the bundle.)
+
+- [ ] **Step 3: Write the build script**
+
+Create `crates/maestro-web/build.rs`:
+
+```rust
+//! Build the embedded SPA bundle before the crate compiles.
+//!
+//! `static_bundle.rs` embeds `web/dist` at compile time via `include_dir!`.
+//! This script guarantees that directory holds a freshly built bundle by
+//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and
+//! node are HARD requirements — a missing toolchain fails the build with an
+//! actionable message rather than silently shipping the placeholder.
+
+use std::env;
+use std::path::PathBuf;
+use std::process::Command;
+
+fn main() {
+ let manifest_dir =
+ PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
+ let web_dir = manifest_dir.join("../../web");
+
+ // Rebuild the bundle only when a frontend input changes — not on every
+ // unrelated Rust recompile.
+ for rel in ["src", "index.html", "package.json", "pnpm-lock.yaml", "vite.config.ts"] {
+ println!("cargo:rerun-if-changed={}", web_dir.join(rel).display());
+ }
+
+ require_tool("pnpm");
+ require_tool("node");
+
+ run(
+ Command::new("pnpm")
+ .args(["install", "--frozen-lockfile"])
+ .current_dir(&web_dir),
+ "pnpm install --frozen-lockfile",
+ );
+ run(
+ Command::new("pnpm")
+ .arg("build")
+ .current_dir(&web_dir),
+ "pnpm build",
+ );
+}
+
+fn require_tool(tool: &str) {
+ let ok = Command::new(tool)
+ .arg("--version")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false);
+ if !ok {
+ panic!(
+ "maestro-web requires `{tool}` to build its embedded SPA bundle. \
+ Install pnpm + node (https://pnpm.io) or use a prebuilt binary."
+ );
+ }
+}
+
+fn run(cmd: &mut Command, label: &str) {
+ let status = cmd
+ .status()
+ .unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}"));
+ if !status.success() {
+ panic!("`{label}` failed with {status}");
+ }
+}
+```
+
+- [ ] **Step 4: Run the test and verify it passes**
+
+```bash
+cargo test -p maestro-web --test embedded_bundle
+```
+
+Expected: PASS — `build.rs` ran `pnpm build`, repopulating `web/dist`, so the
+embedded index now contains `id="app"`. Confirm `web/dist/assets/` has the
+built JS/CSS again.
+
+- [ ] **Step 5: Lint**
+
+```bash
+cargo clippy -p maestro-web --all-targets -- -D warnings
+cargo fmt --all
+```
+
+Expected: clean.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add crates/maestro-web/build.rs crates/maestro-web/tests/embedded_bundle.rs
+git commit -m "feat(maestro): build maestro-web SPA bundle in build.rs
+
+include_dir! now embeds a freshly built web/dist on every compile; pnpm +
+node are hard build requirements. Fixes the placeholder served by
+cargo install --git.
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+## Task 2: Installer preflights the frontend toolchain
+
+**Files:**
+- Modify: `crates/maestro-installer/src/main.rs` (add `WebPrereq` enum + `classify_web_prereq` + `frontend_toolchain_present`; wire into `handle_web` at the opt-in point, ~line 389–396)
+- Test: `crates/maestro-installer/src/main.rs` (new `#[cfg(test)] mod tests`)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to the end of `crates/maestro-installer/src/main.rs` (no test module exists yet):
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn prereq_ok_when_toolchain_present() {
+ assert_eq!(classify_web_prereq(true, true), WebPrereq::Ok);
+ assert_eq!(classify_web_prereq(true, false), WebPrereq::Ok);
+ }
+
+ #[test]
+ fn prereq_prompts_when_missing_and_interactive() {
+ assert_eq!(classify_web_prereq(false, true), WebPrereq::PromptMissing);
+ }
+
+ #[test]
+ fn prereq_skips_when_missing_and_noninteractive() {
+ assert_eq!(classify_web_prereq(false, false), WebPrereq::SkipMissing);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test and verify it fails**
+
+```bash
+cargo test -p maestro-installer classify_web_prereq
+```
+
+Expected: FAIL to compile — `WebPrereq` and `classify_web_prereq` don't exist yet.
+
+- [ ] **Step 3: Add the enum, decision function, and toolchain probe**
+
+In `crates/maestro-installer/src/main.rs`, just above `fn handle_web(` (line 350), add:
+
+```rust
+/// What to do at the (opt-in) web step given whether pnpm + node are present.
+#[derive(Debug, PartialEq, Eq)]
+enum WebPrereq {
+ /// Toolchain present — install maestro-web normally.
+ Ok,
+ /// Missing and we can ask: prompt the operator to abort or skip.
+ PromptMissing,
+ /// Missing and we can't prompt (non-interactive refresh): warn + skip.
+ SkipMissing,
+}
+
+fn classify_web_prereq(toolchain_present: bool, interactive: bool) -> WebPrereq {
+ if toolchain_present {
+ WebPrereq::Ok
+ } else if interactive {
+ WebPrereq::PromptMissing
+ } else {
+ WebPrereq::SkipMissing
+ }
+}
+
+/// True iff both `pnpm` and `node` respond to `--version` on PATH. maestro-web's
+/// build.rs needs them to build the embedded SPA bundle.
+fn frontend_toolchain_present() -> bool {
+ ["pnpm", "node"].iter().all(|tool| {
+ Command::new(tool)
+ .arg("--version")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+ })
+}
+```
+
+- [ ] **Step 4: Run the unit test and verify it passes**
+
+```bash
+cargo test -p maestro-installer classify_web_prereq
+```
+
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Wire the preflight into `handle_web`**
+
+In `handle_web`, the existing flow is:
+
+```rust
+ if !want_web {
+ return Ok(false);
+ }
+
+ // Install the binary. Even if cargo-install fails we keep going so the
+ // operator can fix the cause and re-run; the existing daemon work
+ // doesn't need to be undone.
+ let source = build_source(args);
+```
+
+Insert the preflight between the `if !want_web` block and `let source =`:
+
+```rust
+ if !want_web {
+ return Ok(false);
+ }
+
+ // maestro-web's build.rs requires pnpm + node. Check before cargo install so
+ // a missing toolchain surfaces a clear choice instead of a raw build panic.
+ match classify_web_prereq(frontend_toolchain_present(), interactive) {
+ WebPrereq::Ok => {}
+ WebPrereq::PromptMissing => {
+ eprintln!("\nmaestro-web needs pnpm + node, which aren't installed.");
+ if confirm("Abort the whole install? (No = skip web UI)", false)? {
+ return Err(
+ "aborted: maestro-web prerequisites (pnpm + node) are missing".into(),
+ );
+ }
+ eprintln!("Skipping maestro-web install.");
+ return Ok(false);
+ }
+ WebPrereq::SkipMissing => {
+ eprintln!(
+ "\nSkipping maestro-web refresh: pnpm + node not installed. \
+ Existing unit left in place."
+ );
+ return Ok(false);
+ }
+ }
+
+ // Install the binary. Even if cargo-install fails we keep going so the
+ // operator can fix the cause and re-run; the existing daemon work
+ // doesn't need to be undone.
+ let source = build_source(args);
+```
+
+- [ ] **Step 6: Verify the crate builds, lints, and tests pass**
+
+```bash
+cargo build -p maestro-installer
+cargo clippy -p maestro-installer --all-targets -- -D warnings
+cargo test -p maestro-installer
+cargo fmt --all
+```
+
+Expected: all clean/pass. (`Command` and `confirm` are already imported/defined in the file.)
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add crates/maestro-installer/src/main.rs
+git commit -m "feat(maestro): preflight pnpm + node at installer web step
+
+The opt-in maestro-web step now checks for the frontend toolchain its
+build.rs requires; missing tools prompt abort/skip (interactive) or skip
+the refresh (non-interactive) instead of failing cargo with a raw panic.
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+## Task 3: CI installs Node + pnpm
+
+**Files:**
+- Modify: `.gitea/workflows/ci.yml`
+
+- [ ] **Step 1: Add a Node + pnpm step**
+
+`clippy` and `test` compile `maestro-web`, which now needs the toolchain. After
+the "Install rustup + stable toolchain" step (line 27) and before "Cache
+cargo", add:
+
+```yaml
+ - name: Install Node.js + pnpm
+ run: |
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+ apt-get install -y --no-install-recommends nodejs
+ corepack enable
+ corepack prepare pnpm@9.15.4 --activate
+```
+
+`corepack prepare pnpm@9.15.4` matches the `packageManager` pin in
+`web/package.json`, keeping CI deterministic.
+
+- [ ] **Step 2: Verify locally that the affected jobs compile**
+
+CI can't be run locally, but confirm the commands the runner executes still
+work on a machine with the toolchain:
+
+```bash
+cargo clippy --all-targets -- -D warnings
+cargo test --all
+```
+
+Expected: clean / all pass (these now exercise `maestro-web`'s `build.rs`).
+
+- [ ] **Step 3: Commit and push**
+
+```bash
+git add .gitea/workflows/ci.yml
+git commit -m "ci(maestro): install node + pnpm for maestro-web build.rs
+
+Compiling maestro-web now requires the frontend toolchain to build the
+embedded SPA bundle.
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+git push
+```
+
+- [ ] **Step 4: Confirm CI is green**
+
+Poll the Gitea CI run for this push (per project preference: rely on CI rather
+than long local builds). Expected: `ci / rust` job passes including the new
+Node/pnpm step.
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** §1 build.rs → Task 1; §2 static_bundle unchanged → no task (intentional); §3 installer preflight (prompt abort/skip; non-interactive skip) → Task 2; §4 CI → Task 3; §5 Justfile unchanged → no task (intentional).
+- **Type consistency:** `WebPrereq::{Ok,PromptMissing,SkipMissing}`, `classify_web_prereq(bool, bool)`, and `frontend_toolchain_present()` are used identically in the definition (Task 2 Step 3), the wiring (Step 5), and the tests (Step 1).
+- **Placeholders:** none — every code/command step is concrete.