docs(plan): coordinator + 3 step-level plans for parallel Phase 9/10/11 fan-out

Ships four documents:
  * 2026-04-27-parallel-fanout-9-10-11.md — coordinator. File-conflict map,
    prep-lane scope rationale, post-prep parallel-safety guarantee, merge
    order rule, and risks.
  * 2026-04-27-prep-shell-and-pending.md — small ~1hr prep lane: extract
    AppContext, add --first-run flag scaffold (with stub firstrun::run),
    add daemon GetPendingStatus D-Bus method + GUI client wrapper, new
    PendingStatus wire type. 6 tasks, single agent.
  * 2026-04-27-phase-10-firstrun.md — first-login flow. Fullscreen modal,
    60s idle watchdog (TDD'd as pure WatchdogState), enrollment via Phase
    8 enroll_dialog, ClearPendingFlag on success, gnome-session-quit on
    idle, autostart .desktop entry, debian install. 6 tasks.
  * 2026-04-27-phase-11-totp.md — TOTP support behind default-on Cargo
    feature. Daemon-side: 160-bit secret + base32 + otpauth URI + atomic
    0600 writes in pam_google_authenticator format. PAM profile renderer
    extension. D-Bus surface. CLI subcommand. GUI tab with QR modal.
    Deviation flagged: TOTP recovery codes reuse the Phase 12 recovery
    flow rather than introducing a parallel hashed-recovery file format.
    9 tasks.

Execution model: prep lane first (single agent ~1hr), then dispatch three
subagents in parallel worktrees for the three follow-on lanes. Per the
conflict map, the lanes are file-disjoint after prep merges; merge order
is any-order.

If all four lanes land cleanly, the roadmap jumps from 13/19 to 17/19
phases code-complete in a single session, leaving Phase 13 (deb finalization),
14 (PPA + smoke), 17 (integration tests), 18 (user docs), and the v1.0
release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-04-27 10:28:23 -07:00
parent b624f005b2
commit d20643cde5
4 changed files with 2509 additions and 0 deletions

View File

@@ -0,0 +1,588 @@
# Phase 10: First-Login Flow — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
>
> **EXECUTION GATE:** Do **not** start until the prep lane ([2026-04-27-prep-shell-and-pending.md](2026-04-27-prep-shell-and-pending.md)) has merged to `main`. This lane fills the `firstrun::run()` stub the prep lane established and consumes the `GetPendingStatus` D-Bus method the prep lane added.
**Goal:** Land Flow C from the design doc end-to-end. When a user with `chage -d 0` (forced password change) logs in for the first time, the GTK GUI launches in fullscreen modal mode, queries the daemon's pending flag for the current user, prompts them through enrollment, clears the pending flag on success, and exits cleanly. A 60-second watchdog falls back to `gnome-session-quit --logout` if the user goes idle.
**Architecture:**
- `gui/src/firstrun.rs` is the modal. Replaces the prep lane's stub (`fn run(_ctx) { exit(0) }`) with the real flow.
- The modal is an `adw::ApplicationWindow` configured `fullscreened(true)`, with `decorated(false)` and a sticky-on-top hint. No `HeaderBar`, no ViewSwitcher.
- Flow: connect to daemon → `GetPendingStatus(current_user)` → if absent, exit; if present, present an `AdwStatusPage` with a "Get started" button → on click, run the same `enroll_dialog` Phase 8 wired up → on success, call `ClearPendingFlag` → close window + exit. On user inactivity (no clicks/keypresses for 60s) → `gnome-session-quit --logout --no-prompt`.
- Autostart: `gui/data/authforge-firstrun.desktop` is a `.desktop` entry installed to `/etc/xdg/autostart/` with `OnlyShowIn=GNOME` and `X-GNOME-Autostart-Phase=Initialization`. It runs `authforge --first-run` at session start; the binary returns immediately if the daemon reports no pending flag, so the cost on every session start is one D-Bus call (~ms).
**Tech Stack:**
- `gtk4-rs` + `libadwaita` (already pinned).
- `zbus` (workspace tokio config).
- `gtk::EventControllerKey` + `gtk::GestureClick` for activity tracking the watchdog.
- `glib::source::timeout_add_local` for the watchdog tick.
- `std::process::Command::new("gnome-session-quit")` for the fallback logout.
**Reference:**
- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 10 (line 1209).
- Design doc: [2026-04-26-authforge-design.md](2026-04-26-authforge-design.md) § Flow C.
- Sibling page (mirror this style): `gui/src/keys_page.rs` (post-Phase 8, post-prep AppContext refactor).
- Enrollment dialog (reuse): `gui/src/enroll_dialog.rs` (Phase 8).
- Daemon contract: `GetPendingStatus(user) -> PendingStatus` (added by prep lane), `ClearPendingFlag(user)` (Phase 1).
- Coordinator: [2026-04-27-parallel-fanout-9-10-11.md](2026-04-27-parallel-fanout-9-10-11.md).
---
## Conventions
- One logical change per commit. Conventional prefixes scoped to `gui` / `pkg` / `docs`.
- Always commit with `--no-gpg-sign`.
- TDD only for the watchdog timer logic in `firstrun::watchdog::WatchdogState` (pure logic — testable without a display server).
- After every commit: `cargo fmt --all && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo build -p authforge-gui`.
- Manual smoke (the only realistic acceptance gate for the modal UX) is in [Task 6](#task-6-acceptance-gate--vm-smoke-recipe). CI does not run it.
---
## Task 1: `WatchdogState` pure-logic helper (TDD)
**Files:**
- Create: `gui/src/firstrun/watchdog.rs`
- Modify: `gui/src/firstrun.rs` (currently a one-line stub from prep — convert to a module dir or add `mod watchdog;`)
**Background:** Watchdog state is "did anything happen in the last N seconds?" — that's testable without GTK. Pull it out so the widget code in [Task 2](#task-2-fullscreen-modal-window--enrollment-flow) is just a glue layer.
**Step 1: Convert `firstrun.rs` to a module dir**
If `gui/src/firstrun.rs` exists as a single file (from prep lane Task 2), turn it into:
```
gui/src/firstrun/mod.rs <- the existing run(ctx) stub
gui/src/firstrun/watchdog.rs <- new
```
Move the prep-lane stub from `firstrun.rs` to `firstrun/mod.rs`, then delete `firstrun.rs`.
(If the prep lane already kept it as a single file and `mod firstrun;` resolves to `firstrun.rs`, deleting the file + creating `firstrun/mod.rs` is the same module from `main.rs`'s perspective — no `main.rs` edit needed.)
**Step 2: Write the test + impl**
```rust
// gui/src/firstrun/watchdog.rs
//! Tracks the last-activity timestamp; the GTK timer queries this each tick
//! to decide whether 60s of idle has elapsed.
use std::time::{Duration, Instant};
pub(crate) struct WatchdogState {
last_activity: Instant,
timeout: Duration,
}
impl WatchdogState {
pub fn new(timeout: Duration) -> Self {
Self {
last_activity: Instant::now(),
timeout,
}
}
pub fn poke(&mut self) {
self.last_activity = Instant::now();
}
/// True iff `timeout` has passed since the last poke. Once expired,
/// stays expired until the caller drops + recreates.
pub fn expired(&self) -> bool {
self.last_activity.elapsed() >= self.timeout
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_state_is_not_expired() {
let w = WatchdogState::new(Duration::from_secs(60));
assert!(!w.expired());
}
#[test]
fn zero_timeout_is_immediately_expired() {
let w = WatchdogState::new(Duration::from_millis(0));
std::thread::sleep(Duration::from_millis(2));
assert!(w.expired());
}
#[test]
fn poke_resets_the_clock() {
let mut w = WatchdogState::new(Duration::from_millis(100));
std::thread::sleep(Duration::from_millis(150));
assert!(w.expired());
w.poke();
assert!(!w.expired());
}
}
```
**Step 3: Wire `mod watchdog;` into `firstrun/mod.rs`**
```rust
// gui/src/firstrun/mod.rs
mod watchdog;
pub(crate) use watchdog::WatchdogState; // re-export for the widget code
#![allow(dead_code)] // run() body still stub; Task 2 fills it.
use crate::app_context::AppContext;
pub(crate) fn run(_ctx: AppContext) {
eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting.");
std::process::exit(0);
}
```
**Step 4: Run tests**
Run: `cargo test -p authforge-gui firstrun::watchdog`
Expected: 3 PASS.
**Step 5: Commit**
```bash
git add gui/src/firstrun/mod.rs gui/src/firstrun/watchdog.rs
git rm gui/src/firstrun.rs # if it existed as a single file
git commit --no-gpg-sign -m "feat(gui): WatchdogState pure-logic helper for first-run modal"
```
---
## Task 2: Fullscreen modal window + enrollment flow
**Files:**
- Modify: `gui/src/firstrun/mod.rs` — replace the stub `run()` with the real flow.
**Background:** The window is fullscreen, undecorated, and shows an `AdwStatusPage` with three states: connecting / pending-flag-found / pending-flag-absent. On a present pending flag, the user clicks "Get started" → `enroll_dialog::present` runs against the daemon → on success, `ClearPendingFlag` → window closes → process exits.
**Step 1: Replace `run()`**
```rust
// gui/src/firstrun/mod.rs
mod watchdog;
pub(crate) use watchdog::WatchdogState;
use crate::app_context::AppContext;
use crate::bus::{current_user, Daemon};
use crate::error::user_message;
use adw::prelude::*;
use gtk::glib;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
const WATCHDOG_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) fn run(ctx: AppContext) {
let win = adw::ApplicationWindow::builder()
.application(&gtk::gio::Application::default()
.expect("application set on connect_activate")
.downcast::<adw::Application>()
.expect("adw::Application"))
.title("Authentication setup")
.decorated(false)
.fullscreened(true)
.build();
let _ = ctx; // window owns its own context for the modal; not strictly needed
let status = adw::StatusPage::builder()
.icon_name("dialog-password-symbolic")
.title("Setting up authentication")
.description("Connecting to the authentication daemon…")
.build();
win.set_content(Some(&status));
// Watchdog state shared across the activity-poke handlers and the timer.
let watchdog = Rc::new(RefCell::new(WatchdogState::new(WATCHDOG_TIMEOUT)));
install_activity_pokes(&win, watchdog.clone());
install_watchdog_timer(&win, watchdog.clone());
// Async startup: GetPendingStatus → render flow.
let win_for_async = win.clone();
let status_for_async = status.clone();
glib::MainContext::default().spawn_local(async move {
let daemon = match Daemon::connect().await {
Ok(d) => d,
Err(e) => {
status_for_async.set_description(Some(&user_message(&e)));
// Stay open so the user sees the error; watchdog will
// logout in 60s if they don't take action.
return;
}
};
let user = current_user();
let s = match daemon.get_pending_status(&user).await {
Ok(s) => s,
Err(e) => {
status_for_async.set_description(Some(&user_message(&e)));
return;
}
};
if !s.present {
// Nothing for us to do — exit cleanly. Most session starts
// hit this path; cost is one D-Bus call.
win_for_async.close();
std::process::exit(0);
}
render_get_started(&win_for_async, &status_for_async, daemon, user);
});
win.present();
}
fn render_get_started(
win: &adw::ApplicationWindow,
status: &adw::StatusPage,
daemon: Daemon,
user: String,
) {
status.set_title("Welcome");
status.set_description(Some("Enroll a security key to finish setting up your account."));
let go = gtk::Button::builder()
.label("Get started")
.css_classes(["pill", "suggested-action"])
.halign(gtk::Align::Center)
.build();
let win_for_click = win.clone();
let daemon_for_click = daemon.clone();
let user_for_click = user.clone();
go.connect_clicked(move |btn| {
// Disable the button so a second click doesn't double-prompt.
btn.set_sensitive(false);
let win_inner = win_for_click.clone();
let daemon_inner = daemon_for_click.clone();
let user_inner = user_for_click.clone();
crate::enroll_dialog::present(
&win_inner.clone().upcast::<gtk::Window>(),
daemon_inner.clone(),
move || {
// On success: clear the pending flag, then exit.
let win_done = win_inner.clone();
let daemon_done = daemon_inner.clone();
let user_done = user_inner.clone();
glib::MainContext::default().spawn_local(async move {
if let Err(e) = daemon_done.clear_pending_flag(&user_done).await {
// Even if clearing fails, exit; the user enrolled
// a key and the next login will succeed via pam_u2f
// even if pending is still set.
eprintln!("authforge: ClearPendingFlag failed: {}", user_message(&e));
}
win_done.close();
std::process::exit(0);
});
},
);
});
status.set_child(Some(&go));
}
fn install_activity_pokes(win: &adw::ApplicationWindow, watchdog: Rc<RefCell<WatchdogState>>) {
// Any keypress or click counts as activity.
let key = gtk::EventControllerKey::new();
let w = watchdog.clone();
key.connect_key_pressed(move |_, _, _, _| {
w.borrow_mut().poke();
glib::Propagation::Proceed
});
win.add_controller(key);
let click = gtk::GestureClick::new();
let w = watchdog.clone();
click.connect_pressed(move |_, _, _, _| w.borrow_mut().poke());
win.add_controller(click);
}
fn install_watchdog_timer(win: &adw::ApplicationWindow, watchdog: Rc<RefCell<WatchdogState>>) {
let win_for_timer = win.clone();
glib::source::timeout_add_local(Duration::from_secs(5), move || {
if watchdog.borrow().expired() {
// 60s idle — kick the user back out. gnome-session-quit
// is the documented path for "log this user out cleanly."
let _ = std::process::Command::new("gnome-session-quit")
.arg("--logout")
.arg("--no-prompt")
.spawn();
win_for_timer.close();
return glib::ControlFlow::Break;
}
glib::ControlFlow::Continue
});
}
pub(crate) fn run_or_stub(_ctx: AppContext) {
// Old prep stub kept as a fallback name during PR review; remove this fn
// once the real `run()` is reviewed.
}
```
(Drop the `run_or_stub` function before commit — it's only there to ease review of the diff against the prep stub.)
**Step 2: Build + clippy**
```bash
cargo build -p authforge-gui
cargo clippy -p authforge-gui --all-targets -- -D warnings
```
Expected: clean. The first compile may surface API-shape mismatches against the installed gtk4-rs minor (fullscreened builder, StatusPage::set_child, EventControllerKey return type); fix locally — these are stable surface in gtk4-rs 0.8 + libadwaita 0.6.
**Step 3: Smoke test (deferred to manual; CI cannot run a fullscreen window)**
Skip: covered in [Task 6](#task-6-acceptance-gate--vm-smoke-recipe).
**Step 4: Commit**
```bash
git add gui/src/firstrun/mod.rs
git commit --no-gpg-sign -m "feat(gui): first-run modal with enrollment + 60s idle watchdog"
```
---
## Task 3: Autostart `.desktop` entry
**Files:**
- Create: `gui/data/authforge-firstrun.desktop`
- Modify: `debian/authforge-gui.install` — install the autostart entry to `/etc/xdg/autostart/`
**Step 1: Create the .desktop file**
```ini
# gui/data/authforge-firstrun.desktop
[Desktop Entry]
Type=Application
Name=AuthForge First-Run Setup
Comment=Prompts new users to enroll a security key.
Exec=authforge --first-run
NoDisplay=true
OnlyShowIn=GNOME;
X-GNOME-Autostart-Phase=Initialization
X-GNOME-Autostart-enabled=true
```
`NoDisplay=true` keeps it out of the apps menu — it's an autostart-only entry. `OnlyShowIn=GNOME;` matches the rest of the project's GNOME-first stance (other DEs can ship their own entries later). `X-GNOME-Autostart-Phase=Initialization` runs it before the user's normal autostarts.
**Step 2: Install via debian/authforge-gui.install**
Read the existing `debian/authforge-gui.install` — it likely has a single line installing the binary. Append:
```
gui/data/authforge-firstrun.desktop /etc/xdg/autostart
```
**Step 3: Verify the file syntax**
`desktop-file-validate` is the canonical check, but the harness sandbox may not have it. As a fallback, verify the file is parseable as INI:
```bash
python3 -c "import configparser; c = configparser.ConfigParser(); c.read('gui/data/authforge-firstrun.desktop'); print(dict(c['Desktop Entry']))"
```
Expected: dict prints with `Type`, `Name`, `Exec`, `OnlyShowIn`, etc.
**Step 4: Commit**
```bash
git add gui/data/authforge-firstrun.desktop debian/authforge-gui.install
git commit --no-gpg-sign -m "feat(pkg): autostart .desktop entry running authforge --first-run on GNOME login"
```
---
## Task 4: Daemon-disconnect graceful exit
**Files:**
- Modify: `gui/src/firstrun/mod.rs`
**Background:** Task 2 leaves a daemon-connect failure visible-but-stuck (the watchdog eventually fires, but the user sees no feedback for 60s). Tighten the UX: a connect failure shows a "Daemon unavailable" status with a manual "Skip" button that just exits the modal.
**Step 1: Add the skip button on connect failure**
Replace the `Err(e) => { status_for_async.set_description(...); return; }` arm in `run()`:
```rust
Err(e) => {
status_for_async.set_title("Setup unavailable");
status_for_async.set_description(Some(&user_message(&e)));
let skip = gtk::Button::builder()
.label("Skip")
.css_classes(["pill"])
.halign(gtk::Align::Center)
.build();
let win_skip = win_for_async.clone();
skip.connect_clicked(move |_| {
win_skip.close();
std::process::exit(0);
});
status_for_async.set_child(Some(&skip));
return;
}
```
Also adjust the `GetPendingStatus`-error arm to render the same Skip button.
**Step 2: Build + clippy**
Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`
Expected: clean.
**Step 3: Commit**
```bash
git add gui/src/firstrun/mod.rs
git commit --no-gpg-sign -m "feat(gui): firstrun modal shows Skip button on daemon-unreachable"
```
---
## Task 5: Master plan closeout note
**Files:**
- Modify: `docs/plans/2026-04-26-authforge-implementation.md`
**Step 1: Flip Phase 10 to ✅ Code complete**
In the roadmap row at line 40 (post-prep, post-Phase-12-merge):
```
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | ✅ **Code complete** (yyyy-mm-dd) — VM smoke deferred |
```
**Step 2: Append a closeout note**
After the Phase 12 backend closeout block:
```markdown
### Phase 10 closeout notes (yyyy-mm-dd)
Landed via [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md). Built atop the prep lane's `--first-run` flag scaffold and the `GetPendingStatus` D-Bus method.
**Done:**
- `gui/src/firstrun/{mod,watchdog}.rs` — fullscreen `adw::ApplicationWindow` modal, 60s idle watchdog (3 unit tests on `WatchdogState`), enrollment via the Phase 8 `enroll_dialog`, `ClearPendingFlag` on success, `gnome-session-quit --logout` on idle.
- `gui/data/authforge-firstrun.desktop` — autostart entry installed to `/etc/xdg/autostart/`. `OnlyShowIn=GNOME`, `X-GNOME-Autostart-Phase=Initialization`. Cost on every session start: one D-Bus call to `GetPendingStatus`; no pending flag → immediate exit 0.
- `debian/authforge-gui.install` — installs the autostart entry alongside the binary.
**Plan deviations:** _(fill in)_.
**Deferred until Phase 14 VM smoke:** the full Flow C walkthrough (`useradd -m testuser; chage -d 0 testuser; sudo authforgectl pending set testuser --methods fido2; logout; login; see modal; enroll; see desktop`).
```
**Step 3: Commit**
```bash
git add docs/plans/2026-04-26-authforge-implementation.md
git commit --no-gpg-sign -m "docs: phase 10 closeout notes"
```
---
## Task 6: Acceptance gate + VM smoke recipe
**Files:**
- Create: `gui/FIRSTRUN-TESTING.md`
**Step 1: Document the manual recipe**
```markdown
# Testing the First-Run Modal (Phase 10)
Phase 10's user-visible flow can only be exercised against a real GTK
session. CI verifies that the binary builds and `--first-run` parses;
this recipe is the manual gate.
## Prerequisites
- Ubuntu 24.04 VM with GNOME on Wayland (matches the project's target).
- `authforge-daemon` + `authforge-gui` debs installed (Phase 14 PPA build).
- A USB FIDO2 device (Yubikey, Solo, etc.) attached to the VM.
## Walk-through
```bash
# As root in the VM:
sudo useradd -m testuser
sudo passwd testuser # set a temporary password
sudo chage -d 0 testuser # force password change at next login
sudo authforgectl pending set testuser --methods fido2
# Log out of the current session, log in as testuser.
# Expected sequence:
# 1. GDM forces a password change → set the real password.
# 2. Session starts.
# 3. authforge --first-run launches via the autostart entry.
# 4. Fullscreen modal appears: "Welcome — Enroll a security key…"
# 5. Click "Get started" → enrollment dialog appears.
# 6. Touch the Yubikey → modal closes → desktop usable.
# 7. Verify: authforgectl pending list (run as root in another tty) shows
# no pending flag for testuser.
# Re-test the auto-logout watchdog:
sudo authforgectl pending set testuser --methods fido2
# Log out, log in as testuser, do nothing for 60+ seconds.
# Expected: the user is logged out via gnome-session-quit and returned
# to GDM.
# Re-test the daemon-down case:
sudo systemctl stop authforge-daemon.service
# Log out, log in as testuser. The modal shows "Setup unavailable" with
# a Skip button; clicking it returns to the desktop.
sudo systemctl start authforge-daemon.service
```
## Pass criteria
- [ ] The modal appears within 2s of session start.
- [ ] Successful enrollment clears the pending flag and exits the modal.
- [ ] 60s idle triggers a clean session logout.
- [ ] Daemon-down shows a graceful Skip button rather than hanging.
- [ ] No pending flag → no modal → session start cost is < 100ms (verify
with `journalctl --since=now -f` for the autostart unit).
```
**Step 2: Commit**
```bash
git add gui/FIRSTRUN-TESTING.md
git commit --no-gpg-sign -m "docs(gui): VM smoke recipe for the first-run modal"
```
---
## Phase 10 Acceptance Gate
Verify before merging:
- [ ] `cargo build --workspace --release` clean.
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean.
- [ ] `cargo test --workspace` gui test count up by 3 (the new `WatchdogState` tests).
- [ ] `cargo fmt --all -- --check` clean.
- [ ] `target/release/authforge --first-run` exits 0 in either of: (a) "no pending flag" path (immediate exit); (b) "daemon down" path (modal opens, Skip available verified by hand only because no display server in CI).
- [ ] `python3 -c "import configparser; ..."` parses `gui/data/authforge-firstrun.desktop` clean.
- [ ] Manual VM smoke (deferred to Phase 14, recipe in `gui/FIRSTRUN-TESTING.md`): four pass criteria all green.
---
## Risks / known unknowns
| Risk | Mitigation |
|---|---|
| `gnome-session-quit` is GNOME-only; on KDE / non-GNOME the watchdog logout is a no-op. | The `.desktop` entry is `OnlyShowIn=GNOME;` so on non-GNOME the modal never autostarts. Acceptable v1. KDE port (out of scope) ships its own first-run path. |
| The user closes the modal via Alt-F4 / window manager kill before enrollment finishes. | The modal is `decorated(false)` no window controls. Alt-F4 still works (Wayland) but the watchdog catches abandonment. If a user kills the modal, the pending flag stays set and they hit it again next login. |
| `--first-run` is invoked manually (e.g. by a developer) on a system where the daemon isn't installed. | Daemon-connect failure path renders the Skip button. Process exits 0. |
| `glib::source::timeout_add_local` requires `!Send` callbacks the watchdog `Rc<RefCell<...>>` is fine, but writing tests against the GTK main loop is not. | `WatchdogState` is pure-logic and Send/Sync; the GTK glue is tested manually. |
| The modal grabs focus before GDM's password-change dialog has fully closed visual glitch / focus race. | The autostart entry uses `X-GNOME-Autostart-Phase=Initialization` which runs *before* the user's normal autostart phase but *after* GDM hands the session to GNOME that's the right slot. If a race manifests in VM smoke, fall back to phase=`Application` (slightly later). |
| Wayland `decorated(false)` + `fullscreened(true)` is the right combination older X11 needed `keep_above` too. | We target Ubuntu 24.04 which is Wayland-by-default. X11 fallback is already covered by the autostart entry running per-session-type without explicit X-only targeting. |
---
## Execution Handoff
Plan complete. Single lane, single agent. Suggested execution: open a fresh session in a worktree using `superpowers:using-git-worktrees`, then drive task-by-task with `superpowers:executing-plans`. Do **not** start until the prep lane has merged to `main`.
After this lane lands, the coordinator doc lists the parallel-safe order: any of (Phase 9 + 12 GUI), Phase 10, Phase 11 can merge in any order.