docs(01-safety-foundation): create phase plan

This commit is contained in:
2026-02-28 21:05:46 +01:00
parent 88a93c16ed
commit c97c149366
4 changed files with 715 additions and 2 deletions
@@ -0,0 +1,327 @@
---
phase: 01-safety-foundation
plan: 03
type: execute
wave: 2
depends_on:
- "01-01"
- "01-02"
files_modified:
- src/app.rs
- src/main.rs
autonomous: true
requirements:
- SHEL-01
must_haves:
truths:
- "User can exit by pressing q (only when NOT in login shell mode)"
- "User can exit by pressing Ctrl+C twice within 2 seconds"
- "First Ctrl+C shows 'Press Ctrl+C again to disconnect' prompt in the TUI"
- "In login shell mode, q key is suppressed — only double Ctrl+C exits"
- "On exit, terminal is restored to a usable state"
- "A BBS-style goodbye message displays for ~500ms before process exits"
- "Signal-triggered shutdown (SIGHUP/SIGTERM) restores terminal and exits without goodbye message"
- "App launches straight to a placeholder screen (ready for Phase 2 content)"
artifacts:
- path: "src/app.rs"
provides: "App struct, run_event_loop(), double-Ctrl+C state machine, shutdown handling"
contains: "DOUBLE_PRESS_WINDOW"
- path: "src/main.rs"
provides: "Complete startup → event loop → shutdown pipeline"
contains: "install_panic_hook"
key_links:
- from: "src/app.rs"
to: "src/signals.rs"
via: "polls SignalFlags.should_terminate() each loop iteration"
pattern: "should_terminate"
- from: "src/app.rs"
to: "src/terminal.rs"
via: "calls restore_terminal() on exit"
pattern: "restore_terminal"
- from: "src/main.rs"
to: "src/app.rs"
via: "creates App and calls run_event_loop()"
pattern: "run_event_loop"
- from: "src/main.rs"
to: "src/config.rs"
via: "loads config before terminal init"
pattern: "load_config"
- from: "src/main.rs"
to: "src/terminal.rs"
via: "installs panic hook, inits terminal, restores on exit"
pattern: "init_terminal"
---
<objective>
Implement the App state struct with the event loop, double-press Ctrl+C state machine, 'q' key handling with login-shell suppression, goodbye message, and wire everything together in main.rs. This is the final plan that produces a complete, runnable Phase 1 application.
Purpose: The event loop is where all safety mechanisms converge — signal polling, key handling, and clean exit. This plan wires config, terminal, and signals into a working application that can be safely used as a login shell.
Output: `src/app.rs` with event loop and exit behavior, fully wired `src/main.rs`.
</objective>
<execution_context>
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-safety-foundation/01-RESEARCH.md
@.planning/phases/01-safety-foundation/01-01-SUMMARY.md
@.planning/phases/01-safety-foundation/01-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement App struct and event loop with exit behavior</name>
<files>src/app.rs</files>
<action>
Create `src/app.rs` with:
1. **`ShutdownReason` enum:**
- `UserQuit` — user pressed 'q' or completed double Ctrl+C
- `Signal` — SIGHUP/SIGTERM received
2. **`App` struct:**
```rust
pub struct App {
is_login_shell: bool,
ctrl_c_pressed_at: Option<Instant>,
show_quit_prompt: bool,
should_quit: bool,
}
```
3. **`const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2);`**
4. **`App::new(is_login_shell: bool) -> Self`** — initialize with defaults
5. **`App::run_event_loop(&mut self, terminal: &mut Term, signals: &SignalFlags) -> std::io::Result<ShutdownReason>`:**
- Main loop structure:
```
loop {
// 1. Check signal flags FIRST (fast path for SSH disconnect)
if signals.should_terminate() {
return Ok(ShutdownReason::Signal);
}
// 2. Draw the UI
terminal.draw(|frame| self.draw(frame))?;
// Note: if draw() returns BrokenPipe, the ? propagates it up.
// main.rs will catch BrokenPipe and exit cleanly (LIFE-04).
// 3. Poll for events with a 250ms timeout
if crossterm::event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = crossterm::event::read()? {
self.handle_key(key);
}
// Handle Event::Resize by clearing — ratatui redraws on next loop
}
// 4. Check if we should quit
if self.should_quit {
return Ok(ShutdownReason::UserQuit);
}
// 5. Clear quit prompt if double-press window expired
if let Some(pressed_at) = self.ctrl_c_pressed_at {
if pressed_at.elapsed() >= DOUBLE_PRESS_WINDOW {
self.ctrl_c_pressed_at = None;
self.show_quit_prompt = false;
}
}
}
```
6. **`App::handle_key(&mut self, key: KeyEvent)`:**
- Match on `key.code`:
- `KeyCode::Char('c')` with `KeyModifiers::CONTROL` → handle Ctrl+C:
- If `ctrl_c_pressed_at` is `Some(t)` and `t.elapsed() < DOUBLE_PRESS_WINDOW` → set `should_quit = true`
- Otherwise → set `ctrl_c_pressed_at = Some(Instant::now())`, `show_quit_prompt = true`
- `KeyCode::Char('q')` → if NOT `is_login_shell`, set `should_quit = true`
- Any other key → if `show_quit_prompt`, clear it (reset `ctrl_c_pressed_at = None`, `show_quit_prompt = false`)
7. **`App::draw(&self, frame: &mut Frame)`:**
- Phase 1 placeholder UI. Render a simple centered block with:
- Title: "BBS-MD" in the top border
- Body text: "Welcome to BBS-MD. Content loading will be available in Phase 2."
- If `show_quit_prompt`: render a line at the bottom saying "Press Ctrl+C again to disconnect..." (styled with a warning color like yellow)
- If `is_login_shell`: show "Login Shell Mode" indicator somewhere visible
- Use `ratatui::widgets::Block`, `Paragraph`, and basic `Style` — nothing fancy, just enough to prove the TUI works
- Keep imports minimal: `ratatui::prelude::*`, `ratatui::widgets::{Block, Borders, Paragraph}`
8. **`show_goodbye()`** (standalone function, not on App):
- Called AFTER `restore_terminal()` has been called (terminal is in normal mode)
- Print to stdout (not stderr — user should see this in their terminal):
```
println!("\r");
println!(" +------------------------------------------+");
println!(" | Thank you for calling the BBS! |");
println!(" | *** CARRIER LOST *** |");
println!(" +------------------------------------------+");
println!("\r");
```
- Call `std::thread::sleep(Duration::from_millis(500))` for the brief display per user decision
- Use `let _ = writeln!(...)` instead of `println!()` if you want to handle broken pipe gracefully here too, but `println!()` is acceptable since terminal is already restored
Use these imports at the top:
```rust
use std::io;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::terminal::Term;
use crate::signals::SignalFlags;
```
</action>
<verify>
`cargo build` succeeds with the new app module (after adding `mod app;` to main.rs).
Review: `handle_key` suppresses 'q' when `is_login_shell` is true. Double Ctrl+C window is 2 seconds. `show_goodbye()` sleeps for 500ms. Event loop polls signals before draw.
</verify>
<done>
App struct implements the event loop with signal polling, double-press Ctrl+C state machine, login-shell 'q' suppression, placeholder TUI rendering, quit prompt display, and BBS-style goodbye message with 500ms delay.
</done>
</task>
<task type="auto">
<name>Task 2: Wire complete startup-to-shutdown pipeline in main.rs</name>
<files>src/main.rs</files>
<action>
Replace the placeholder main.rs from Plan 01 with the complete pipeline. The final main.rs should:
1. Declare all modules: `mod config;`, `mod terminal;`, `mod signals;`, `mod app;`
2. `fn main()` flow:
```rust
fn main() {
// --- PRE-TERMINAL PHASE (errors use normal eprintln) ---
// 1. Detect login shell
let is_login_shell = config::detect_login_shell();
// 2. Parse CLI
let cli = config::parse_cli();
// 3. Load config
let config_path = config::resolve_config_path(cli.config.as_deref());
let app_config = match config::load_config(&config_path) {
Ok(c) => c,
Err(e) => {
config::print_config_error(&e);
std::process::exit(1);
}
};
// --- TERMINAL PHASE (safety envelope active) ---
// 4. Install panic hook BEFORE terminal init
terminal::install_panic_hook();
// 5. Register signal handlers
let signal_flags = match signals::register_signals() {
Ok(s) => s,
Err(e) => {
eprintln!("SYSTEM ERROR: Cannot register signal handlers: {}", e);
std::process::exit(1);
}
};
// 6. Initialize terminal
let mut term = match terminal::init_terminal() {
Ok(t) => t,
Err(e) => {
eprintln!("SYSTEM ERROR: Cannot initialize terminal: {}", e);
std::process::exit(1);
}
};
// --- EVENT LOOP PHASE ---
// 7. Create app and run
let mut app_state = app::App::new(is_login_shell);
let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags);
// --- SHUTDOWN PHASE ---
// 8. Restore terminal (always, regardless of how we got here)
terminal::restore_terminal();
// 9. Handle shutdown reason
match shutdown_reason {
Ok(app::ShutdownReason::UserQuit) => {
app::show_goodbye();
}
Ok(app::ShutdownReason::Signal) => {
// Signal shutdown — exit silently, no goodbye
// (SSH disconnect — nobody to show goodbye to)
}
Err(e) => {
// Handle broken pipe gracefully (LIFE-04)
if e.kind() == std::io::ErrorKind::BrokenPipe {
// Silent exit — SSH connection already gone
} else {
eprintln!("SYSTEM ERROR: {}", e);
std::process::exit(1);
}
}
}
}
```
Key points:
- Config loads BEFORE terminal init — errors print to stderr normally
- Panic hook installed BEFORE terminal init — covers panics during init
- Signal handlers registered BEFORE terminal init — covers early SIGHUP
- Terminal restored in ALL exit paths (the match arms all fall through after restore)
- Broken pipe error from event loop is caught and silently exits (LIFE-04)
- Goodbye message ONLY on user-initiated quit (not on signal shutdown)
- `app_config` is passed to App::new if needed (currently not used beyond loading, but the config is validated — Phase 2 will use vault_path)
Note: `app_config` may appear unused in Phase 1 since we only display a placeholder. That's fine — the config is validated and ready. Suppress the warning with `let _app_config = ...` or pass it to App::new for future use. Prefer passing it: `App::new(is_login_shell, app_config)` and store it in App even if unused yet.
</action>
<verify>
Run `cargo build` — must compile with all four modules wired together.
Run `cargo run` — TUI should display the placeholder screen with "BBS-MD" title. Press 'q' — should show goodbye message and exit. Press Ctrl+C once — should show "Press Ctrl+C again to disconnect". Press Ctrl+C twice quickly — should show goodbye and exit. Wait 2+ seconds after first Ctrl+C, then press any key — quit prompt should disappear.
Run `cargo run` and send SIGTERM from another terminal (`kill $(pgrep bbs-md)`) — should exit cleanly without goodbye message, terminal should be restored.
Verify no `unwrap()` exists in terminal.rs panic hook. Verify no `LeaveAlternateScreen` anywhere in the codebase.
</verify>
<done>
Complete Phase 1 application runs: config loads before terminal init, panic hook protects against crashes, signals trigger clean shutdown, double Ctrl+C exits with goodbye message, 'q' exits (suppressed in login shell mode), broken pipe exits silently, terminal is restored in all exit paths.
</done>
</task>
</tasks>
<verification>
1. `cargo build` compiles the complete application with all modules
2. TUI launches and displays placeholder content
3. Pressing 'q' exits with goodbye message (normal mode)
4. Pressing 'q' does nothing in login shell mode
5. Single Ctrl+C shows quit prompt, second within 2s exits
6. SIGTERM/SIGHUP causes clean exit without goodbye message
7. Terminal is restored after any exit path
8. No `unwrap()` in panic hook, no alternate screen usage
9. Goodbye message displays for ~500ms
</verification>
<success_criteria>
- Complete runnable application from `cargo run`
- All 7 Phase 1 requirements (LIFE-01 through LIFE-04, CONF-01, SHEL-01, SHEL-02) are implemented and wired
- Every exit path restores the terminal
- Double Ctrl+C state machine works with 2-second window
- Login shell mode suppresses 'q' key
</success_criteria>
<output>
After completion, create `.planning/phases/01-safety-foundation/01-03-SUMMARY.md`
</output>