Add --open: render and open the page in one step

throughline render and branch already write a self-contained HTML file and print its path, but opening it was a manual second step. --open does it for you, shelling out to the platform opener — for both subcommands.

The flag is a plain boolean on each subcommand, long-form only since -o is already --output. Here it is on render, mirrored verbatim on branch:

src/cli.rs
@@ -27,0 +27,3 @@27+        /// After writing the page, open it with the platform's default handler.28+        #[arg(long)]29+        open: bool,

Once the page is written and its path printed, a new helper picks the opener by platform — open on macOS, xdg-open on Linux, cmd /C start on Windows — and runs it. It is best-effort: the render is the real deliverable, so a missing or failing opener warns to stderr and still exits 0 rather than failing the command. (Command is fully qualified because clap's Command enum is already imported here.)

src/main.rs
@@ -108,0 +118,28 @@118+/// Open a rendered page with the platform's default handler. Best-effort: the119+/// file is already written and its path printed, so a missing or failing opener120+/// warns but never fails the command. (`Command` here is fully qualified because121+/// the crate's clap `Command` enum is already in scope.)122+fn open_in_browser(path: &Path) {123+    let result = if cfg!(target_os = "macos") {124+        std::process::Command::new("open").arg(path).status()125+    } else if cfg!(target_os = "windows") {126+        // `start` is a cmd builtin; the empty "" is the (required) window title.127+        std::process::Command::new("cmd")128+            .args(["/C", "start", ""])129+            .arg(path)130+            .status()131+    } else {132+        std::process::Command::new("xdg-open").arg(path).status()133+    };134+    match result {135+        Ok(status) if status.success() => {}136+        Ok(status) => eprintln!(137+            "warning: opener exited with {status}; the page is at {}",138+            path.display()139+        ),140+        Err(e) => eprintln!(141+            "warning: couldn't launch the platform opener ({e}); the page is at {}",142+            path.display()143+        ),144+    }145+}

The binary is the one layer with side effects, and the integration suite links only the library — so main.rs has no test seam and --open is verified by running the binary, not by cargo test. A new wiki field note records that boundary, the best-effort opener, and the empty-PATH trick for hitting the warning branch without launching a browser; the README usage, the branch how-to, and the ROADMAP entry are updated alongside (and a stale v0.3 spec caption corrected to v0.4).

Remaining changes

README.md
@@ -86,11 +86,14 @@86 ## Usage87 88 ```sh-throughline render <commit> [-o out.html]89+throughline render <commit> [-o out.html] [--open]90 ```91 92 - `<commit>` is any revision: `HEAD`, a SHA, a tag, `HEAD~2`, …93 - Omit `-o` and it writes `throughline-<short-sha>.html` in the current directory.94+- `--open` opens the page in your default browser after writing it (`open` on macOS,95+  `xdg-open` on Linux, `start` on Windows). It is best-effort: if no opener is found96+  the path is still printed and the command succeeds.97 - `--debug` prints the parsed title / TL;DR / intro / anchors instead of HTML — handy98   for checking how a message parses.99 @@ -101,22 +104,23 @@104 with a contents list and previous/next links:105 106 ```sh-throughline branch <tip> [--base <ref>] [-o out.html]   # range is base..tip; default base: main-throughline branch <base>..<tip> [-o out.html]          # explicit range107+throughline branch <tip> [--base <ref>] [-o out.html] [--open]   # range is base..tip; default base: main108+throughline branch <base>..<tip> [-o out.html] [--open]          # explicit range109 ```110 111 - `throughline branch my-feature` renders every commit in `main..my-feature`.112 - `throughline branch v1.0..v1.1` renders an explicit range.113 - Omit `-o` and it writes `throughline-branch-<tip-sha>.html`.114+- `--open` works here too — render the branch page and open it in one step.115 - Completeness still holds **per commit** — each chapter diffs against its own116   parent; the page composes the commits in order rather than squashing them.117 118 The output is a single standalone HTML file (inline CSS, no JavaScript, no external-assets), so you can open it directly:119+assets), so you can open it directly — `--open` does exactly that for you:120 121 ```sh-throughline render HEAD -o /tmp/commit.html && open /tmp/commit.html   # macOS-# Linux: xdg-open /tmp/commit.html122+throughline render HEAD --open                          # render, then open it123+throughline render HEAD -o /tmp/commit.html && open /tmp/commit.html   # or do it by hand (macOS)124 ```125 126 ## Using it in any project (no Rust required)@@ -129,7 +133,7 @@133 134 ```sh135 git config --global alias.tl \-  '!f(){ throughline render "${1:-HEAD}" -o /tmp/tl.html && open /tmp/tl.html; }; f'136+  '!f(){ throughline render "${1:-HEAD}" --open; }; f'137 # then, in any repo:138 git tl            # HEAD139 git tl <sha>      # a specific commit@@ -189,7 +193,7 @@193   render.rs        resolve anchors -> ordered document model194   html.rs          emit a self-contained HTML page195 docs/-  SPEC.md             canonical format specification (v0.3)196+  SPEC.md             canonical format specification (v0.4)197   ARCHITECTURE.md     implementation design and module map198   ROADMAP.md          shipped work and the prioritized plan ahead199   llm-commit-guide.md instruction for LLM commit authors
docs/ROADMAP.md
@@ -163,9 +163,12 @@163   ([`docs/wiki/automation/docs-sync-hook.md`](wiki/automation/docs-sync-hook.md)).164 - [ ] **Golden/snapshot tests** for the HTML output (e.g. `insta`) so rendering165   changes are reviewable as a diff.-- [ ] **`--open` flag** — render and open the page in one step, for **both**-  `render` and `branch`. Shell out to the platform opener (`open` on macOS,-  `xdg-open` on Linux, `start` on Windows). *(Planned for a dedicated session.)*166+- [x] **`--open` flag** — render and open the page in one step, for **both**167+  `render` and `branch`. Shells out to the platform opener (`open` on macOS,168+  `xdg-open` on Linux, `start` on Windows). Best-effort: the page is written and its169+  path printed first, so a missing or failing opener warns to stderr but still exits170+  0 — `--open` stays safe in scripts and on headless boxes. *(`cli.rs` flag +171+  `main.rs` `open_in_browser` helper.)*172 - [x] **Pre-commit hook** running `fmt` / `clippy -D warnings` / `test` locally before173   every commit — the local half of the CI checks, with a non-Rust fast path and a174   `--no-verify` bypass
docs/wiki/README.md
@@ -41,6 +41,9 @@41   - [`field-notes/html-output.md`](field-notes/html-output.md)42     — how `html.rs` shapes the page, and the two gotchas (line-packing, CSS class43     names) that mislead you when you grep or split rendered output.44+  - [`field-notes/cli-binary-layer.md`](field-notes/cli-binary-layer.md)45+    — the binary (`main.rs`): where I/O lives, the best-effort `--open` opener, and46+    why `cargo test` can't reach the CLI (so how to verify it instead).47 - **How-to** — acting on this codebase.48   - [`how-to/render-a-branch.md`](how-to/render-a-branch.md) — render a range of49     commits as one navigable page (`throughline branch`); the input forms and the
docs/wiki/field-notes/cli-binary-layer.md
@@ -0,0 +1,54 @@1+# Field note: the binary layer (`main.rs`)2+3+Where the side effects live, and why `cargo test` doesn't reach them. Read this4+before adding or changing CLI behavior — output paths, flags, the `--open` opener —5+so you know what is testable and how to verify the rest.6+7+## The library returns data; the binary does I/O8+9+`commit_throughline` (the library: `render`, `html`, `git`, …) is effectively pure10+at its surface — it resolves a commit into a `Document` and lowers that into an HTML11+*string*. It writes nothing. Every side effect lives in the binary, `src/main.rs`:12+discover the repo, call the library, **write** the file, **print** the path, and —13+with `--open` — **spawn** the platform opener. `run_render` / `run_branch` are the14+two top-level flows; both end with the same three steps (write → print → maybe open).15+16+## `--open` is best-effort, by design17+18+`open_in_browser` (`src/main.rs`) shells out to the platform opener — `open` on19+macOS, `xdg-open` on Linux, `cmd /C start "" <path>` on Windows — selected with20+`cfg!(target_os = …)` (one function, every branch type-checks on every platform,21+only the matched branch runs). It is called *after* the page is written and its path22+printed, so the deliverable is already on disk. If the opener is missing or exits23+non-zero, it warns to stderr and the command still exits 0 — the render is the24+product; a viewer that won't launch must not fail it. This is the same25+"degrade, don't fail" stance the renderer takes with a bad anchor. Tracked under26+the `--open` flag in [`ROADMAP.md`](../../ROADMAP.md).27+28+**Gotcha:** `main.rs` already imports clap's `Command` (the subcommand enum), so29+`std::process::Command` is referred to **fully qualified** — a bare30+`use std::process::Command` would collide. Keep the qualification (or alias) if you31+move the helper.32+33+## The integration tests do not reach the binary34+35+`tests/` link the **library** and call its functions directly (`render::render`,36+`html::to_html`, …) over fixture repos built with `tempfile` + `git2`. They never37+spawn the `throughline` binary. So `run_render` / `run_branch` / `open_in_browser`38+are private to `main.rs` and **uncovered** by `cargo test`: output-path naming, the39+`--open` opener, and `--debug` are binary-layer behavior you verify by **running the40+binary**, not by adding a test. (This is the practical edge of ARCHITECTURE's "the41+I/O and rendering modules wrap the pure ones" — the outermost I/O wrapper, `main`,42+has no test seam at all.)43+44+Recipe — exercise the best-effort failure branch *without* launching a browser: run45+the built binary by path with an emptied `PATH`, so the process execs fine but its46+child opener lookup fails:47+48+```sh49+PATH="" target/debug/throughline render HEAD -o /tmp/x.html --open50+# prints "Rendered …", then "warning: couldn't launch the platform opener …", exits 051+```52+53+The happy path is a plain `target/debug/throughline render HEAD --open` (it really54+opens a browser tab, so do it sparingly).
docs/wiki/how-to/render-a-branch.md
@@ -20,7 +20,9 @@20 21 Both resolve to the same thing internally: the commits in `base..tip` — reachable22 from the tip but not the base — so the **base commit itself is excluded**. Output-goes to `throughline-branch-<tip-sha>.html` unless you pass `-o`.23+goes to `throughline-branch-<tip-sha>.html` unless you pass `-o`. Add `--open` to24+open the page in your default browser once it is written (best-effort — same flag as25+`render`).26 27 ## What you get28 
src/cli.rs
@@ -24,6 +24,10 @@24         #[arg(short, long)]25         output: Option<PathBuf>,26 27+        /// After writing the page, open it with the platform's default handler.28+        #[arg(long)]29+        open: bool,30+31         /// Print the parsed message structure instead of rendering HTML.32         #[arg(long)]33         debug: bool,@@ -46,5 +50,9 @@50         /// Write the HTML here instead of `throughline-branch-<sha>.html`.51         #[arg(short, long)]52         output: Option<PathBuf>,53+54+        /// After writing the page, open it with the platform's default handler.55+        #[arg(long)]56+        open: bool,57     },58 }
src/main.rs
@@ -13,23 +13,25 @@13         Command::Render {14             commit,15             output,16+            open,17             debug,18         } => {19             if debug {20                 debug_message(&commit)21             } else {-                run_render(&commit, output.as_deref())22+                run_render(&commit, output.as_deref(), open)23             }24         }25         Command::Branch {26             branch,27             base,28             output,-        } => run_branch(&branch, &base, output.as_deref()),29+            open,30+        } => run_branch(&branch, &base, output.as_deref(), open),31     }32 }33 -fn run_render(commit: &str, output: Option<&Path>) -> Result<()> {34+fn run_render(commit: &str, output: Option<&Path>, open: bool) -> Result<()> {35     let repo =36         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;37 @@ -51,11 +53,15 @@53         doc.sections.len(),54         path.display()55     );56+57+    if open {58+        open_in_browser(&path);59+    }60     Ok(())61 }62 63 /// Render a range of commits as one navigable page (the `branch` subcommand).-fn run_branch(branch: &str, base: &str, output: Option<&Path>) -> Result<()> {64+fn run_branch(branch: &str, base: &str, output: Option<&Path>, open: bool) -> Result<()> {65     let repo =66         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;67 @@ -102,9 +108,42 @@108         title,109         path.display()110     );111+112+    if open {113+        open_in_browser(&path);114+    }115     Ok(())116 }117 118+/// Open a rendered page with the platform's default handler. Best-effort: the119+/// file is already written and its path printed, so a missing or failing opener120+/// warns but never fails the command. (`Command` here is fully qualified because121+/// the crate's clap `Command` enum is already in scope.)122+fn open_in_browser(path: &Path) {123+    let result = if cfg!(target_os = "macos") {124+        std::process::Command::new("open").arg(path).status()125+    } else if cfg!(target_os = "windows") {126+        // `start` is a cmd builtin; the empty "" is the (required) window title.127+        std::process::Command::new("cmd")128+            .args(["/C", "start", ""])129+            .arg(path)130+            .status()131+    } else {132+        std::process::Command::new("xdg-open").arg(path).status()133+    };134+    match result {135+        Ok(status) if status.success() => {}136+        Ok(status) => eprintln!(137+            "warning: opener exited with {status}; the page is at {}",138+            path.display()139+        ),140+        Err(e) => eprintln!(141+            "warning: couldn't launch the platform opener ({e}); the page is at {}",142+            path.display()143+        ),144+    }145+}146+147 /// `--debug`: print what the message parser understands, without rendering.148 fn debug_message(commit: &str) -> Result<()> {149     let repo =