omni-dev 0.45.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, Datadog, Gmail, and Drive.
Documentation
# Claude AI Assistant Guide

This document provides guidance for AI assistants (particularly Claude) working with the omni-dev project.

## Project Overview

omni-dev is a powerful Git commit message analysis and amendment toolkit written in Rust. It provides:

- Comprehensive commit analysis with YAML output
- Branch-aware commit analysis 
- Safe commit message amendment capabilities
- GitHub integration for PR and remote information
- Conventional commit detection and suggestions

## Key Files and Structure

### Core Source Files
- `src/main.rs` - CLI entry point
- `src/lib.rs` - Library exports
- `src/cli/` - Command-line interface implementation
- `src/cli/atlassian/` - Atlassian JIRA/Confluence CLI commands
- `src/atlassian/` - Atlassian API client, ADF/JFM conversion, document format
- `src/data/` - Data structures and YAML output formatting
- `src/core/` - Core application logic
- `src/utils/` - Utility functions

### Configuration
- `Cargo.toml` - Rust package configuration and dependencies
- `.github/` - GitHub Actions CI/CD workflows
- `.claude/skills/` - Claude skill definitions

### Documentation
- `README.md` - Main project documentation
- `CHANGELOG.md` - Version history and changes
- `CONTRIBUTING.md` - Contribution guidelines
- `docs/STYLE_GUIDE.md` - Project conventions for code, documentation, and other artifacts
- `docs/RELEASE.md` - Release process documentation
- `docs/plan/` - Project planning and specifications

## Development Workflow

### Code Quality Standards
- **Build Script**: Run `./scripts/build.sh` for complete validation (recommended)
- **Tests**: Run `cargo test` before commits
- **Linting**: Use `cargo clippy -- -D warnings` for code quality
- **Formatting**: Apply `cargo fmt` for consistent style
- **Documentation**: Maintain doc comments for public APIs

### Commit Message Format
Follow conventional commit format:
```
<type>(<scope>): <description>

<body>

<footer>
```

Common types: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`

Enforced in CI by [`.github/workflows/commit-lint.yml`](.github/workflows/commit-lint.yml) (non-required,
runs `omni-dev git commit message lint --strict` on every push/PR to `main`). Check locally before
pushing with `omni-dev git commit message lint origin/main..HEAD`.

The [commit-message Stop hook](.claude/hooks/check-commit-messages.sh) also runs
`omni-dev git commit message check --strict --quiet -o json` on the branch range
at turn-end. It returns findings for correction with `commit-twiddle`, caches
successful HEAD checks per worktree, and fails open on infrastructure errors.
See [STYLE-0023](docs/STYLE_GUIDE.md#style-0023-validate-commit-messages-with-omni-dev-after-creation).

### Branch Strategy
- `main` - Production-ready code
- Feature branches - `feature/description` or `username/feature-description`
- Release branches - Tagged as `vX.Y.Z`

## AI Assistant Guidelines

### Code Changes
1. **Read Before Writing**: Always read existing files before making changes
2. **Follow the Style Guide**: Before writing or reviewing code, documentation, or other project artifacts, consult [docs/STYLE_GUIDE.md](docs/STYLE_GUIDE.md). Use the task-to-tag lookup table at the top of the guide to identify relevant tags, then search for those tags (e.g., `grep "Tags:.*code-style" docs/STYLE_GUIDE.md`). Read and follow the matched rules. Do not skip this step.
3. **Configuration Changes**: When modifying config loading or scope resolution, consult [docs/configuration-best-practices.md](docs/configuration-best-practices.md) and [docs/plan/config-internals.md](docs/plan/config-internals.md)
4. **Test Changes**: Run tests after modifications
5. **CLI Surface Changes**: After any change to `src/cli/**`, `src/main.rs`, or any `#[derive(Parser)]` / `#[derive(Subcommand)]` / `#[arg(...)]` site, invoke the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill to review and update `insta` golden snapshots — most often [tests/snapshots/integration_test__help_all_output.snap](tests/snapshots/integration_test__help_all_output.snap). Do **not** assume `cargo test` passing in isolation surfaces drift before you've inspected the new snapshot: golden tests fail loudly, but only after the full suite has run, and the fix (`cargo insta accept`) must only be applied when the diff matches the *intended* CLI change. If the diff contains anything you did not intend, investigate the regression instead of accepting. Flag placement rule: attach a flag to the narrowest node whose entire subtree reads it — a subtree `#[arg(global = true)]` (e.g. `gmail --account`) or a `#[command(flatten)]` onto the leaves that read it (e.g. `AiBackendArgs`) — and never add a root-level `global = true` flag unless every command reads it (only `--profile` qualifies, #1778).
6. **Secret Environment Variables**: Read every credential through `crate::utils::secret_env` (`secret_var`/`secret_var_any`), never `var()`/`get_env_var()` — it registers the variable in `SECRET_ENV_VARS` and gives it a `<NAME>_FILE` companion and a `<NAME>_COMMAND` (credential-helper) companion. See [STYLE-0030](docs/STYLE_GUIDE.md), [ADR-0089](docs/adrs/adr-0089.md) and [ADR-0090](docs/adrs/adr-0090.md); grep-guard tests in `secret_env.rs` enforce it.
7. **Conventional Commits**: Use proper commit message format (see `.omni-dev/commit-guidelines.md`)
8. **Incremental Changes**: Make focused, reviewable changes

### Release Process
When preparing releases, follow the comprehensive guide in [docs/RELEASE.md](docs/RELEASE.md):

1. Update version in `Cargo.toml`
2. Update `CHANGELOG.md` with release notes
3. Run quality checks (`cargo test`, `cargo clippy`)
4. Commit changes with conventional commit format
5. Create annotated git tag
6. Push commits and tag
7. Create GitHub release
8. Publish to crates.io

### Understanding YAML Output
The project generates structured YAML output with field presence tracking:

- **Field Documentation**: Each output field is documented with presence indicators
- **AI Guidance**: Look for `present: true` fields in the explanation section
- **Dynamic Tracking**: The `update_field_presence()` method tracks which fields are available

### AI Response Parsing - CRITICAL UNDERSTANDING
**IMPORTANT**: When working with AI-generated responses in this project, understand the correct data structure:

- **AI responses are VALID YAML** with `title` and `description` fields
- **The `description` field VALUE contains markdown content**, including embedded code blocks
- **Embedded ```yaml blocks are CONTENT, not structure** - they're part of the description string
- **NEVER attempt to "unwrap" or extract content between markdown code fences**
- **Use simple `content.trim()` parsing** - complex extraction logic breaks the YAML structure

**Example of correct AI response structure**:
```yaml
title: "PR title here"
description: |
  # Section
  
  ```yaml
  - some: nested content
  ```
  
  This is all part of the description field value.
```

**Common Mistake**: Treating embedded ```yaml blocks as if they need extraction. They don't - they're just content within the description field.

**Correct Approach**: Parse the entire response as YAML directly. The markdown formatting (including code blocks) is the intended content of the description field.

### AI Model Configuration
The project includes a comprehensive model registry system:

- **Model Registry**: `src/claude/model_config.rs` manages AI model specifications
- **Model Templates**: `src/templates/models.yaml` defines supported Claude models with token limits
- **Fuzzy Matching**: Supports various identifier formats (Bedrock, AWS, regional)
- **Configuration Commands**: Use `omni-dev config models show` to view available models
- **Dynamic Limits**: Token limits are automatically applied based on model specifications

### AI Backend Dispatch
`src/claude/backend.rs` is the single source of truth for backend, model, and beta-header resolution — `resolve_backend` / `resolve_model` / `resolve_beta_header` are shared by the client factory (`src/claude/client.rs::create_default_claude_client`) and preflight (`src/utils/preflight.rs`), so the two can no longer drift (#1118).

Backend selection: if `OMNI_DEV_AI_BACKEND` is set (directly or via the `--ai-backend` flag, values `default|claude-cli|openai|ollama|bedrock`) it wins outright — `default` forces the direct API even when `USE_*` flags are set, and an unknown value is a hard error. When unset, legacy order: `USE_OLLAMA=true` → `USE_OPENAI=true` → `CLAUDE_CODE_USE_BEDROCK=true` → direct API. The resolved `AiBackend` enum dispatches to `ClaudeCliAiClient` (`src/claude/ai/claude_cli.rs`), `OpenAiAiClient::new_ollama`/`new_openai` (`src/claude/ai/openai.rs`), `BedrockAiClient` (`src/claude/ai/bedrock.rs`), or `ClaudeAiClient` (`src/claude/ai/claude.rs`).

Model selection stops at the first non-empty value: explicit param (MCP/`run_*` callers) → `OMNI_DEV_MODEL` (set by `--model`) → per-family vars (Claude family: `CLAUDE_MODEL` → `CLAUDE_CODE_MODEL` → `ANTHROPIC_MODEL`; OpenAI: `OPENAI_MODEL`; Ollama: `OLLAMA_MODEL`) → registry default. `resolve_model` returns `String` (infallible); preflight validates the result via `preflight.rs::validate_model` → `ModelRegistry::is_known_model`, **gated to `Default | Bedrock`** — Ollama has no registry entries, OpenAI accepts unknown-but-well-shaped ids by design, and `claude-cli` resolves its own aliases, so widening that scope breaks them (#1333, pinned by tests in `preflight.rs`). `--ai-backend`, `--model`, `--beta-header`, and the other AI backend flags are **per-command** flags (#1778, partly reversing #1118's global placement), flattened via `AiBackendArgs` (`src/cli/ai_backend_args.rs`) onto the six AI commands (`git commit message twiddle`, `git commit message check`, `git commit message staged`, `git branch create pr`, `ai chat`, `ai jev verify-decision`) and placed *after* the subcommand; `AiBackendArgs::apply()` forwards each set flag into the corresponding env var (`OMNI_DEV_AI_BACKEND`/`OMNI_DEV_MODEL`/`OMNI_DEV_BETA_HEADER`/...) at the top of each command's `execute()`. Adding a backend = new enum variant + resolver arms + one factory arm + one preflight arm.

Structured JSON-schema output (#1119): all four HTTP backends now advertise `supports_response_schema` and take the schema path. OpenAI/Ollama use `response_format: json_schema` (`openai.rs`); the direct Anthropic (`claude.rs`) and Bedrock (`bedrock.rs`) backends use the GA Messages-API `output_config.format` — but **model-gated** via the registry's `supports_structured_output` flag (`ModelRegistry::supports_structured_output`, populated from `src/templates/models.yaml`), since `output_config` `400`s on older models. Unflagged/unknown models fall back to YAML. `claude-cli` keeps its `--json-schema` path. That gate is per-*model*, which cannot express a per-*endpoint* limitation, so #1561 adds the endpoint half: `ClaudeClient` carries a `schema_disabled` latch seeded from `OMNI_DEV_STRUCTURED_OUTPUT_DISABLE` (`backend.rs::resolve_structured_output_disabled`) and latched on the first time an endpoint rejects `output_config` — the shape a gateway named by `ANTHROPIC_BEDROCK_BASE_URL` returns when it doesn't pass the field through (the direct backend's URL is hardcoded, so Bedrock is the only proxyable one today; the latch is backend-agnostic regardless). `client.rs::send_with_optional_schema` is the single choke point for all 18 schema call sites, so it owns both the retry and the latch; `check` shares one `ClaudeClient` across its concurrent batch futures, so the rejection costs one round-trip per *run*, not per commit. The retry's correctness hinges on `yaml_system_prompt`, which strips `prompts::JSON_SCHEMA_RESPONSE_OVERRIDE` — the suffix `adjusted_system_prompt` appends tells the model to disregard the YAML instructions and satisfy "the schema provided to the runtime", so a schema-less request that kept it would ask for a schema it never shows. The shared HTTP request timeout is 300s, overridable via `OMNI_DEV_AI_TIMEOUT_SECS` (parity with the subprocess backend's separate `OMNI_DEV_CLAUDE_CLI_TIMEOUT_SECS`).

User-facing details — required env vars, model selection, Claude CLI sandbox semantics, the `--claude-cli-allow-tools` / `--claude-cli-allow-mcp` escape hatches, the `--claude-cli-max-budget-usd` spending cap, and per-backend troubleshooting — live in [docs/ai-backends.md](docs/ai-backends.md). Keep it in sync when changing any of those surfaces.

Architectural rationale for the sandboxed `claude-cli` subprocess backend — threat model, sandbox flag choices, escape-hatch design, budget-cap enforcement — lives in [ADR-0028](docs/adrs/adr-0028.md).

Error classification (#1333): non-2xx HTTP responses from all four backends funnel through `ai.rs::check_error_response`, which preserves the status as `ClaudeError::ApiHttpError { status, body }` (`claude_cli.rs::map_api_error` maps its `api_error_status` onto the same variant). `ClaudeError::is_transient` / the `is_transient_ai_error(&anyhow::Error)` helper (both `src/claude/error.rs`) classify: a non-retryable 4xx is permanent, everything else — including anything not positively identified — is transient. Callers that degrade or retry (`create_pr.rs`'s template fallback, `client.rs`'s two retry loops) **must** gate on it, so a permanent failure fails loudly instead of being silently papered over. `ApiRequestFailed(String)` remains for the statusless cases only. The one sanctioned exception to "a permanent 4xx fails loudly" is `error.rs::is_structured_output_rejection` (#1561): a `400`/`422` whose body names `output_config` makes `send_with_optional_schema` warn and re-issue a *different* request (schema dropped, prompt stripped) rather than retry the same one — the trigger is a positively-identified rejection of one field by name, so it cannot widen into blanket 4xx retrying.

Dev-only notes:
- `ClaudeCliAiClient::run` is the warn site for both escape hatches, the INFO-level `total_cost_usd` log, and the post-response WARN when reported cost exceeds the configured cap. `ClaudeCliAiClient::max_budget_from_value` is the warn site for a set-but-invalid cap value (#1135).
- `--beta-header` is Anthropic-specific and only sent on the Anthropic backends (direct API + Bedrock, validated against the registry). The non-Anthropic backends warn-and-ignore it via `client.rs::warn_beta_header_ignored`: `claude-cli` (`claude`'s `--betas` flag has different semantics), plus `openai`/`ollama` (they never transmit it; #1119 stopped them validating a header they'd never send).

### Browser Bridge
The `omni-dev browser bridge` command tree drives HTTP requests **through an authenticated browser tab** (Grafana/Loki, SSO-gated dashboards) without exfiltrating the browser's cookies/tokens — a *confused deputy by design*. It is a two-plane local server joined by an `id`-keyed correlator:

- `src/cli/browser.rs` + `src/cli/browser/` — the CLI surface: `bridge serve` (`bridge.rs`, the long-lived server), `bridge request` (`request.rs`, the thin client), and `bridge harvest <platform> <object>` (`harvest.rs`, best-effort scrapers). Both clients send a `ControlRequest` to `POST /__bridge/request` via the shared `src/browser/client.rs::BridgeClient` rather than opening their own socket.
- `src/browser/harvest/` — the harvest engines (`facebook.rs` = own-timeline pagination). These drive **reverse-engineered, undocumented** site internals: best-effort, re-harvest every volatile `doc_id`/token/provider flag per run (never hardcoded), fail with staged actionable errors on drift, and only ever use the connected tab's own session. The Facebook recipe is documented in [docs/browser-bridge.md](docs/browser-bridge.md) and issue #922.
- `src/browser/bridge.rs` — server core: the HTTP control plane (axum, default `127.0.0.1:9998`), the WebSocket plane the browser connects to (default `127.0.0.1:9999`), the `Correlator` (per-`id` channel), the transparent proxy, and `dispatch`/`start_stream`.
- `src/browser/protocol.rs` — the wire types (`ControlRequest`, `Command`, `BrowserReply`/`ResponseEnvelope`, the streaming `StreamItem`/`StreamLine`/`CancelCommand`, `StatusResponse`/`TabInfo`). New optional fields use `#[serde(default, skip_serializing_if = ...)]` to keep older clients byte-identical on the wire.
- `src/browser/auth.rs` — the **load-bearing** security primitives: token generation/resolution, `constant_time_eq`, the `X-Omni-Bridge` / `X-Omni-Bridge-Target` header constants, Host/Origin/Sec-Fetch-Site guards, and `validate_outbound_url` (server-side outbound scope; the in-page snippet is never trusted).
- `src/templates/browser-bridge.js` — the snippet pasted into the DevTools console (rendered by `src/browser/snippet.rs`); it reads `cmd.stream` / `cmd.credentials` and base64-encodes non-text bodies.

The security model is **core, not an add-on**: both planes are authenticated and default-closed. When touching the trust boundary (auth guards, outbound scope, token handling, the planes), keep [ADR-0036](docs/adrs/adr-0036.md) and the operator guide [docs/browser-bridge.md](docs/browser-bridge.md) in sync. Changes to the CLI surface require the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill (see Code Changes §5).

The bridge can also be hosted by the **omni-dev daemon** (see the [Daemon](#daemon) section below) as its first migrated service. The daemon supervises lifecycle and adds a menu-bar surface; it does **not** touch this trust boundary — `BridgeService` (`src/daemon/services/bridge.rs`) wraps the same `BridgeServer` planes verbatim, and standalone `serve` keeps working.

### Daemon
The `omni-dev daemon` command tree hosts long-lived **services** inside one supervised process behind a Unix-domain control socket, with an optional macOS menu-bar shell. The browser bridge is the first service migrated onto it (a trivial `echo` service exercises the framework in tests). Architectural rationale — the service abstraction, the control socket, single-instance supervision, the `run`/launcher split, and the main-thread/runtime inversion for the tray — lives in [ADR-0039](docs/adrs/adr-0039.md).

- `src/daemon/service.rs` — the `DaemonService` trait (`name`/`handle`/`menu`/`menu_action`/`status`/`shutdown`) plus the menu/status types (`MenuSnapshot`, `MenuItem`, `MenuAction`, `ServiceStatus`).
- `src/daemon/registry.rs` — `ServiceRegistry`: routes a request envelope's `service` field to the matching service; iterates them for `status`/`menu`/`shutdown`.
- `src/daemon/selection.rs` — `DaemonServiceKind` + `ServiceSelection`: the **build-time** service allowlist (#1318). `build_default_registry` hosts every service by default, but `daemon run --services a,b,c` (or the `OMNI_DEV_DAEMON_SERVICES` env var) narrows it to a subset, skipping the construction — and thus the startup work (bridge planes, worktrees pollers, sessions watcher) — of any unselected service. Because launchd/systemd `exec` a fixed command with a minimal env (a shell env var never reaches the demand-spawned daemon), `daemon start` bakes the selection into the generated plist/unit as a `--services` arg; `daemon restart` preserves the running daemon's current subset unless `--services` overrides. Names: `browser-bridge`, `snowflake`, `worktrees`, `sessions`. The registry is append-only, so a *runtime* enable/disable toggle remains a follow-up. Changes to the `run`/`start`/`restart` CLI surface require the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill (see Code Changes §5).
- `src/daemon/protocol.rs` — the Unix-socket wire types `DaemonEnvelope { service, op, payload }` / `DaemonReply { ok, payload, error }`, newline-delimited JSON (`tokio_util` `LinesCodec`). A `service` of `None` or the reserved `"daemon"` targets the built-in ops `ping` / `status` / `shutdown`.
- `src/daemon/accept.rs` — accept-error pacing (#2111): `AcceptBackoff` (5 ms doubling to 1 s, log-throttled, accumulates the outage) and the `ConnectionSource` seam the loop reads connections from, so tests can script `EMFILE`. `server.rs::accept_loop` retries after the backoff pause (racing shutdown), credits the failed time to every service through `DaemonService::credit_accept_outage` (the worktrees registry and the sessions registry's window reports advance their entries' `last_seen`, so an outage never reaps windows), and `handle_connection` closes a connection silent for 60 s (`IDLE_TIMEOUT`; never a request in progress or a subscription). `lifecycle.rs::raise_nofile_limit` lifts the soft `RLIMIT_NOFILE` to `min(hard, 4096)` at startup, falling back to 2048/1024/512 if the host refuses — launchd's default is 256.
- `src/daemon/server.rs` + `single_instance.rs` + `lifecycle.rs` — the accept loop and dispatch; `acquire_listener` adopts the service-manager-activated fd when socket-activated (launchd on macOS, systemd on Linux; else the exclusive socket bind, which **is** the single-instance lock: `ping`-probe + stale-socket reclaim); on a socket-activated path shutdown leaves the socket inode for the manager (the unlink is gated to the self-bound path via the `socket_activated` flag); `SIGTERM`/`SIGINT`/`SIGHUP` cancel a shared `CancellationToken` for graceful shutdown.
- `src/daemon/paths.rs` — runtime paths via `dirs::data_dir()` (`<data>/omni-dev/`, **not** `~/.omni-dev`): `daemon.sock`, `bridge.token`, and (off-macOS) `daemon.log`; directory `0700`, socket/token/log `0600`, 104-byte `sockaddr_un` guard.
- `src/daemon/launchd.rs` — macOS **socket-activated** LaunchAgent (`com.github.rust-works.omni-dev.daemon`): a `Sockets`→`Listener` dict (no `RunAtLoad`/`KeepAlive`) makes launchd own the control socket and demand-spawn the daemon on first connect; `daemon run` adopts the inherited fd via the `launch_activate_socket` FFI (`launchd_listener` — scoped `#[allow(unsafe_code)]`, like the `setsid` `pre_exec` hook in `control.rs`), falling back to self-bind off the launchd path. `install_and_load` creates the `0700` runtime dir before bootstrap (launchd makes the socket before the process runs). Retires the #1078 `kickstart` workaround (#1081).
- `src/daemon/systemd.rs` — Linux **socket-activated** systemd **user** unit (`omni-dev-daemon.socket` + `.service` under `~/.config/systemd/user/`), gated `#[cfg(target_os = "linux")]`, mirroring `launchd.rs`: `enable --now` the socket (auto-start at login via `sockets.target`, `SocketMode=0600`); `daemon run` adopts the inherited fd via a hand-rolled `sd_listen_fds` (`systemd_listener`, `LISTEN_FDS`/`LISTEN_PID`, sets `FD_CLOEXEC`; no libsystemd dep). `unload` = `stop`+`disable` (the `bootout` analogue). `is_available()` gates on `/run/systemd/system` + the user-manager socket, with an `OMNI_DEV_DAEMON_DISABLE_SYSTEMD` escape hatch; falls back to the detached spawn when unavailable. `restart` skips the down-poll on Linux (an armed socket re-activates on any connect) (#1174).
- `src/daemon/tray.rs` — the macOS menu bar, gated `#[cfg(all(target_os = "macos", feature = "menu-bar"))]` (off by default; pulls in `tray-icon`, `tao`, `arboard`). Compiled out elsewhere.
- `src/daemon/services/bridge.rs` — `BridgeService`, the first migrated service; wraps `BridgeServer`, persists the session token to the `0600` `bridge.token` file for thin-client discovery.
- `src/cli/daemon.rs` + `src/cli/daemon/{run,start,stop,restart,status,logs,bridge,service}.rs` — the CLI surface. `run` becomes the daemon (foreground, what the service manager execs); `start`/`stop`/`restart` are launchers/clients (macOS installs a launchd LaunchAgent, Linux a systemd user unit for login auto-start; without a service manager `start` detaches the spawn: `setsid` + stdio to a `0600` `daemon.log`, no login auto-start — #1174); `status` prints a per-service table (`--json` for machines) and reports the resident daemon's version, warning on a CLI/daemon mismatch (#1113). Operability additions (#1113): `logs` reads/tails the daemon's own `daemon.log` (the `0600` file the launchd LaunchAgent / detached-spawn launcher sink the daemon's stdio to — #1316; systemd uses the journal instead); `bridge` is a typed client for the browser-bridge ops (`status`/`restart`/`disconnect-tab`/`snippet`/`token`/`request-command`) previously reachable only from the tray; `service <SVC> <OP> [--payload]` is a generic passthrough to any service op. Version rides the built-in `ping`/`status` payloads (additive; `DaemonClient::version()`). The shared thin-client `call_service`/`warn_version_mismatch` helpers live in `src/cli/daemon.rs`.

Hosting the bridge in the daemon does **not** change its security model — keep [ADR-0036](docs/adrs/adr-0036.md), [ADR-0039](docs/adrs/adr-0039.md), and [docs/browser-bridge.md](docs/browser-bridge.md) in sync. Changes to the `daemon` CLI surface require the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill (see Code Changes §5).

### Snowflake service
The daemon's **second** service (after the browser bridge) authenticates Snowflake sessions via external-browser SSO and reuses them for **concurrent arbitrary SQL** against **any** account — solving "SSO popup on every query" (the crate holds the session in memory with no serialization API, so a resident process is the only reuse path). It mirrors the bridge's engine/adapter split and is **account-agnostic** (no hardcoded accounts).

- `src/snowflake/` — the engine: `snowflake.rs` (`SnowflakeEngine`: lazy auth, per-query context as a `USE`-diff, base-context capture for resets, transparent token renew-and-retry, overall sign-in deadline; `SnowflakeEngineConfig::from_env_and_settings` over `SNOWFLAKE_*` env then `settings.json`), `session.rs` (the bounded session **pool** + `PoolRegistry`), `client/` (the clean-room v1 REST client that backs the engine — SSO / PAT / key-pair-JWT login, query with **async-result polling** for long queries, **token renewal**, timeouts/retries, chunk download, row→JSON).
- **Concurrency model (the crux):** on v1, statement context (`warehouse`/`role`/`database`/`schema`) is session-global (`USE`, no statement-local form), so per-query context needs exclusive session access. To get concurrency *and* per-query context, each `(account, user)` keeps a **bounded `SessionPool`** (`SNOWFLAKE_POOL_SIZE`, default 4): a query checks out a session (LIFO reuse, else lazily authenticates one), applies only the `USE`s that differ from its current context, runs concurrently, and returns it. A `tokio::Semaphore(max)` caps concurrency *and* the live-session/auth count; sessions capture their base context (`SELECT CURRENT_*()`) so overrides can be reset on reuse. `menu()`/`status()` read pool bookkeeping behind a `std::Mutex` **never held across `.await`**; session creation (browser SSO) is serialized across pools by a shared `tokio` "auth gate" so only one browser opens at a time. (v2's per-request context would remove the per-session constraint — a v2 client was proposed in #1003 but closed as not planned.)
- `src/daemon/services/snowflake.rs` — `SnowflakeService`, the thin `DaemonService` adapter: routes `query`/`sessions`/`disconnect`, renders a "Snowflake" tray submenu (one label per pool + `disconnect:<id>` / `disconnect-all` actions — **no clipboard actions, so `tray.rs` needs no change**), and reports `status()`.
- `src/cli/snowflake.rs` — `omni-dev snowflake {query,sessions,disconnect}`; `query` reads SQL from an arg or stdin, with per-query `--warehouse/--role/--database/--schema` and `--format json|yaml`.
- **Clean-room client (`src/snowflake/client/`)** is the **sole** Snowflake implementation — written from Snowflake's documented v1 REST protocol (no third-party connector), chosen precisely to gain **token renewal** (`session/token-request` via the master token), which `snowflake-connector-rs` can't do (it discards the master token). It also does async-result polling for long queries, per-request timeouts + transient retries, and external-chunk download. (`snowflake-connector-rs` was used briefly during bring-up but is gone — it pulled `rsa`/RUSTSEC-2023-0071 and an edition-2024 MSRV bump.) A background keep-alive heartbeat (engine-owned task, `SNOWFLAKE_HEARTBEAT_INTERVAL`, default 900s) heartbeats idle pooled sessions so `CLIENT_SESSION_KEEP_ALIVE` actually extends the master token and an idle pool never re-prompts SSO (#1107). Threading a Chrome-profile browser command from settings is a follow-up. **Auth is method-pluggable** (`AuthMethod` in `client/config.rs`; `SNOWFLAKE_AUTHENTICATOR` selects it, resolved by `resolve_auth_method` in `snowflake.rs`): external-browser SSO (default), **PAT** (`SNOWFLAKE_TOKEN`), and **key-pair RS256 JWT** (`SNOWFLAKE_PRIVATE_KEY_PATH`/`SNOWFLAKE_PRIVATE_KEY`) — the two non-interactive methods added in #1108 let the daemon run headless. All three finish through the shared `auth::complete_login` and yield the same `LoginTokens`, so pooling/renewal/heartbeat are auth-agnostic. JWT signing lives in `client/jwt.rs` using **`aws-lc-rs`** (already in-tree via rustls; deliberately not the `rsa` crate — same RUSTSEC-2023-0071 reason `snowflake-connector-rs` was dropped); only **unencrypted PKCS#8** keys are supported (encrypted-key/passphrase support is a follow-up). The v2 client itself was declined in #1003.

**No new trust boundary:** requests ride the daemon's existing `0600` Unix socket; **no secret is persisted** (in-memory session only). Keep the operator guide + downstream contract [docs/snowflake-service.md](docs/snowflake-service.md) in sync, and run the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill on any CLI-surface change (see Code Changes §5).

### Worktrees service
The daemon's **third** service tracks the repos/worktrees open across **every** VS Code window — something no extension can do alone, since the extension host is sandboxed per-window (each window sees only its own `workspace.workspaceFolders`). A single resident daemon is the rendezvous point that aggregates per-window registrations into one consistent view served back to the CLI, tray, and extension UI. Fed by a first-party companion VS Code extension shipped in `editors/vscode/`; the registry is account-agnostic and **in-memory only**.

- `src/worktrees.rs` — the engine: `WorktreesRegistry` mirrors the bridge/Snowflake engine split (a standalone `crate::worktrees` module, not fused into the adapter — #1154). Holds a `HashMap` of open windows (`WindowEntry`) behind a `std::Mutex` **never held across `.await`**, plus the `RegisterRequest` DTO. Ops: `register` (idempotent upsert), `heartbeat` (returns `known` — `false` tells a window to re-register after a daemon restart, since state is in-memory), `unregister`, `list`, and `first_folder` (for the tray focus action). It also owns the two **per-window directives**, each a per-key `HashSet` behind its **own** `Mutex` (never nested under the window map's, nor under each other's), taken-and-cleared on the target's next `heartbeat` and cleared on `unregister`: **close-pending** (`mark_close_pending`/`take_close_pending`, #1277) and **reload-pending** (`mark_reload_pending`/`take_reload_pending`, #1417). Both ride the heartbeat reply as additive fields omitted when unset, are set and consumed independently (so one reply can carry both), and the companion checks `close` first because closing subsumes reloading. Keyed by a **companion-owned per-`activate()` UUID** (not `vscode.env.sessionId`, whose per-window uniqueness is unverified); `sessionId`/`pid` ride along as metadata. Owns liveness/eviction (below).
- `src/daemon/services/worktrees.rs` — `WorktreesService`, the thin `DaemonService` adapter (cheap `new()`, like Snowflake — no async setup, persists nothing): routes `register`/`heartbeat`/`unregister`/`list`/`tree`/`ahead-behind`/`open`/`close`/`merge-queue`/`rebase`/`push`/`reposition`/`reload` ops (plus the streaming `subscribe`) to the registry, validates the payload `key`, renders the menu/status, and drives the VS Code launcher. Per-worktree ahead/behind is the dominant per-worktree cost, so it is **lazy** (#1306): the streamed `tree`/`subscribe` snapshot uses the cheap `git_status_cheap` (branch + repo identity, **no** `graph_ahead_behind` walk) and divergence is fetched on demand via the `ahead-behind` op (batched by path) — the extension does it on repo-expand, `worktrees tree` folds it back in; `list`/`status` and the tray menu still enrich inline via the full `git_status`. The **`close`** op (#1277, [ADR-0049](docs/adrs/adr-0049.md)) is the one **destructive** op: a two-phase (side-effect-free safety check → confirmed execute) close of a worktree's window and, for a linked worktree, its `git2`-prune deletion (`valid`+`working_tree`, no shell-out, refuses a locked worktree). Deletability keys **solely on `is_main`** (structural, never the branch name — a linked worktree on the default branch is deletable and its branch survives); the daemon refuses to remove the main working tree defensively. A cross-window close signals the owning window via the additive `close` field on its `heartbeat` reply and waits (bounded ~20s) for it to `unregister`; a self-close removes-then-replies so the extension closes its own window. All git I/O is on a blocking thread, never under the registry lock.
- **Liveness (the crux):** entries carry `last_seen`; a 30s TTL (three missed ~10s heartbeats) ages out a window that crashed without `unregister`. Reaping is **inline on every read** (`list`/`status`/`menu`) — no background task, since the only consumers (tray ~1Hz poll, `worktrees list`, `daemon status`) trigger it naturally and staleness is only observable on read. This is the part a flat shared file could not do. The registry is also capped at 256 entries (#1140): a new-key `register` at the cap evicts the longest-silent entry rather than erroring (the evicted window re-registers off its next `known: false` heartbeat); upserts never evict.
- **Tray:** a "Worktrees" submenu lists open windows plus a best-effort `focus:<key>` action per window that shells out `code <folder>` (VS Code reuses the already-open window). The action routes through the daemon's **generic** `menu_action` dispatch (`tray.rs` needs no change). The launcher is resolved via `OMNI_DEV_VSCODE_BIN` → well-known absolute paths → bare `code` (handles launchd's minimal `PATH`); failure is logged, not fatal.
- The **`rebase`** op (#1415, [ADR-0059](docs/adrs/adr-0059.md)) batch-rebases the selected worktrees onto their repo's remote default branch, fetching **once per repository**. Two-phase like `merge-queue`: phase 1 runs `crate::git::worktree_rebase::plan` (fetch + classify — *this* is the "does it make sense?" gate), phase 2 **re-plans from scratch** and executes, serialized by a `rebase_lock` since linked worktrees share one object database. It reverses [ADR-0055](docs/adrs/adr-0055.md)'s central claim: the daemon **does** have `SSH_AUTH_SOCK` (launchd exports it into the per-user session), so the fetch authenticates through the user's own `ssh-agent`; the real gap was the minimal `PATH`, closed by `crate::git::resolve_git_binary` (the `resolve_gh_binary` twin, `OMNI_DEV_GIT_BIN`). `RebaseOptions.keep_conflicts` — which the tree view always sends and the CLI exposes as `--keep-conflicts` — **leaves a conflicting worktree mid-rebase** instead of `git rebase --abort`-ing it, so the conflict is resolved in place; the batch continues either way. The tree cue is **two orthogonal fields**, both additive: `operation` (durable, from `repo.state()` read into every snapshot, so a left-in-place conflict survives a daemon restart) and `rebasing` (transient, from a registry `Mutex<HashSet<PathBuf>>` keyed by *canonicalized worktree path*, marked/cleared by the op and bumping the change-notify **only on a real change**). They are not redundant — a clean rebase never writes an on-disk state at all. Rendered as a row icon + description, deliberately **not** the badge layer (its two chars are already taken by the PR-check and Claude-session providers).
- `src/daemon/services/worktrees/divergence.rs` — the **`ahead-behind`** op's coordination (#2111): `AheadBehindCoordinator` shares one computation per worktree between concurrent requests and caps concurrent computations at 4, with the permit moved into the `spawn_blocking` closure so a dropped caller cannot free it early. A request joins only a computation that has **not yet begun reading** the repository (`Flight::started`, set on the blocking thread before any read), so an answer is never older than its request; nothing is retained once it finishes, so it is not a cache. `graph_ahead_behind` memoizes each walk by `(commondir, local oid, upstream oid)` — exact for a complete repository, since ancestry is a pure function of the ids, so it needs no TTL and a commit/fetch/push just misses; a shallow repository bypasses the memo, since deepening changes the answer without changing an id. One `Repository::discover` per worktree answers both `ahead`/`behind` and `main_behind` (`folder_divergence`). Without this every window's re-ask fanned out unboundedly and the queued handlers held the daemon's descriptors.
- `src/daemon/services/worktrees/geometry.rs` + `geometry/ax.rs` — the **`reposition`** op (#1407, [ADR-0058](docs/adrs/adr-0058.md)): move+resize each selected worktree's already-open VS Code window onto the invoking window's frame, plus `reposition-undo`. A VS Code extension **cannot** read or set a window's OS geometry (microsoft/vscode#28150 — parked), so the daemon does it via the macOS **Accessibility** API — the service's first **OS** trust surface, and the second `unsafe_code` exception in the crate. `geometry.rs` is platform-independent and holds everything interesting (matching, planning, per-target outcome classification) behind a four-method `WindowBackend` trait, unit-tested with a fake on any platform; `geometry/ax.rs` is the `#[cfg(target_os = "macos")]` FFI (7 inline `extern "C"` decls against `ApplicationServices`, `CFRetained` ownership, `downcast`-checked attribute reads, a load-bearing `AXUIElementSetMessagingTimeout`). Key facts: setting `AXPosition`/`AXSize` **does not raise or activate**, so Z-order is preserved for free — it must **not** reuse `focus_window`, which deliberately raises. `WindowEntry.pid` is the **extension-host** process and in Electron all `NSWindow`s belong to the single **main** process, so pid cannot locate a window: resolution is `ppid(pid)` (one batched `ps`) → owning app, then **root-name → `AXTitle`** matching via a uniqueness-requiring exact→suffix→substring cascade that **refuses to guess** (ambiguous ⇒ reported, never moved). Not two-phase (nothing durable changes); reversibility instead comes from a **one-level, in-memory** undo record. `AXIsProcessTrusted()` is reported as a `trusted` reply **field**, never prompted for, so the extension can offer the settings deep-link. All AX I/O on a blocking thread, never under the registry lock.
- The **`push`** op (#1443, [ADR-0061](docs/adrs/adr-0061.md)) is the other half of `rebase`: a rebase diverges every already-published branch it touches, and this publishes them again. Two-phase and `push_lock`-serialized like `rebase`, but its plan phase **contacts no remote at all** — classification is against the local `refs/remotes/<remote>/<branch>`, which is exactly what the lease is checked against, so the plan the user confirms and the lease `git` enforces agree by construction (a fetch here would *renew* the lease). Outcomes: `UpToDate` / `WouldFastForward` (ahead only — a plain push, **no force flag**) / `WouldForce` / `WouldCreate` (`--set-upstream`) / `Skipped`; a dirty tree is deliberately **not** a skip, since a push publishes commits rather than the working tree. Two rules are load-bearing. **(1)** Every force goes out as `--force-with-lease --force-if-includes`, never `--force`: bare `--force-with-lease` leases against the local remote-tracking ref, so any background fetch (VS Code's `git.autofetch`) silently renews it, and per `git-push(1)` `--force-if-includes` is *not* implied and is a no-op unless `--force-with-lease` is **valueless**. There is no force escape hatch anywhere — not an engine option, not a CLI flag, not a wire field — so the daemon *cannot* overwrite a tip it has not seen. **(2)** The repository's **remote default branch is never force-pushed**, whichever worktree holds it; the gate is the **branch name**, not `is_main`, which inverts [ADR-0060](docs/adrs/adr-0060.md) precisely because a rebase is local and reflog-recoverable while a force-push publishes the rewrite (a *fast-forward* onto the default branch stays allowed). Both guards live in `crate::git::worktree_push`, not the UI. The tree cue is a transient `pushing` set mirroring `rebasing` but with **no durable half** — a push writes no on-disk state, so `clear_pushing` on every exit path is load-bearing; a *completed* push is observable as `upstream_sha` moving (#1344). The VS Code action is the only batch git action offered on **repo rows**, expanded client-side by `tree.ts::expandToWorktrees` (no new op).
- The **`reload`** op (#1417) signals the selected worktrees' windows to run `workbench.action.reloadWindow` — the batch form of `Developer: Reload Window`. Deliberately **not** two-phase like `close`: a reload creates, modifies and destroys nothing and VS Code's hot exit preserves dirty editors, so it follows the `reposition` precedent of firing and reporting, with nothing to undo. Addressed by **window key** like `reposition` rather than by path like `close` (a reload acts on a *window*, and one path can be open in several), which is also why it needs no `requester_key` — the invoking window reloads itself directly rather than waiting a heartbeat for its own directive, so the daemon never learns who asked. Cross-window targets are only *signalled*: the directive rides the ~10s heartbeat, and a reloaded window re-registers under the same key, so there is no completion to observe and the reply claims only what was signalled. Adds no capability (strictly weaker than `close`), so no ADR.
- `src/cli/worktrees.rs` — `omni-dev worktrees list [--json]` / `tree` read-only clients plus `focus <PATH>` (raises a worktree's VS Code window via the daemon's `open` op — the tray-only focus action made CLI-reachable, #1113) and `reposition --reference <PATH> [<PATH>…] [--dry-run|--undo]` (#1407 — paths are mapped to window keys via a `list` call, since the op addresses *windows*; `--dry-run` is the title-matching diagnostic and the AX calls happen in the **daemon's** process under the daemon's grant, not the terminal's); `--socket` override. `worktrees rebase` is the one subcommand that stays **entirely local** (its own `crate::git::worktree_rebase` engine, no socket) — since #1415 that is a *choice*, not the credential necessity ADR-0055 claimed: it keeps a batch rebase working with no daemon running and keeps `--all`/`--onto` out of the wire protocol. #1361 closed the CLI/extension parity gap, so the full subcommand set is now `list`/`tree`/`focus`/`close`/`rebase`/`push`/`merge-queue`/`reposition`/`reload`/`show-closed`/`register`/`heartbeat`/`unregister` — including the companion **feed** ops (`register`/`heartbeat`/`unregister`), exposed for scripted/headless companions and integration testing even though the real client, the VS Code extension, still speaks NDJSON to the socket directly. Note `register`'s repository-name flag is **`--repo-name`**, renamed in #1420 because `--repo` collided with `-C/--repo` (at the time a root-global flag; since #1778 it is scoped to `git`, `coverage`, `config scopes`, `ai claude skills` (#1781), and the `worktrees rebase`/`worktrees push` leaves) on clap's *arg id* (a panic, not an error); a structural test now walks the command tree asserting no subcommand re-uses a global arg id.
- `src/cli/worktrees/ui/` — **`omni-dev worktrees ui`**, a full-screen `ratatui` terminal UI and the registry's **second first-party client** (#1585, [ADR-0072](docs/adrs/adr-0072.md)). A read-only daemon client that deliberately does **not** register as a window (the daemon's `open`/`reposition` assume a VS Code window): `wire.rs` (hand-mirrored, `sanitize_for_terminal`-at-the-boundary DTOs), `supervisor.rs` (reconnect-with-backoff over `subscribe`, permanent polling fallback for an old daemon that replies `ok:false` and holds the connection open), `hub.rs` (the actor merging both feeds + local state into one `WorktreesViewModel`), `actions.rs` (the parity commands and the two-phase `close` fan-out — `close` isn't batched on the wire), `terminal/` (Phase 3: one embedded terminal tab via `alacritty_terminal` in-process, one PTY-reader OS thread per tab feeding the async loop over one channel). Two load-bearing PTY invariants, regression-tested against real children: `TERM`/`COLORTERM` set per spawn, and every `Event::PtyWrite` written back to the child. A `claude` tab launches through `omni-dev claude-wrap`, so those sessions report *authoritative* state (ADR-0057). Focus decides who gets a key — a focused terminal takes everything verbatim except `Alt`-chords, no tmux-style prefix; keys are legacy-xterm encoded and the kitty keyboard protocol is deliberately not advertised (`Config::kitty_keyboard` off). **PTY contents are never logged** — enforced by the `no_pty_content_is_ever_logged` grep-guard test, not convention; the emulator stays at `Osc52::OnlyCopy` so no clipboard→child path exists. Row colours are a local unsynced store (`~/.omni-dev/worktrees-ui-row-colors.yaml`, `0600`/`0700`) until `rowColors` moves into the daemon. Phase 4 completes the surface: `mouse.rs` (the §5 selection contract — a per-frame `RegionMap` hit-tests each event to one region and clamps a drag to where it began; **only a terminal grid has a selection model**, so chrome cannot reach the clipboard; a child that requested mouse reporting gets the encoded event unless `⌥` is held; the host's own selection escape hatch is implemented by *omission* and must stay that way — **which modifier it is varies by terminal**, measured in #1602: Ghostty keeps shift-drag, iTerm2 keeps option-drag and forwards shift-drag, the VS Code terminal keeps neither, so the mechanism is ours to leave alone and the modifier is not ours to encode), `layout.rs`/`panes.rs` (a `Vec<PaneGroup>` stack with weights and a 3-cell splitter — a tab strip out-ranks the splitter overlapping it, or no group below the first could be clicked; strip hit-spans come from the same width computation that renders them), and `glyph.rs` (**every glyph is one cell wide in both unicode and `--ascii` modes**, asserted for every variant — East-Asian *Ambiguous* characters are excluded however well they read, since `unicode-width` calls them 2 in an East-Asian locale and 1 elsewhere). `rebase`/`push`/`merge-queue` landed in Phase 4d as two-phase clients of the **daemon's** ops, never the CLI's local path (ADR-0072 §9); the UI adds no safety logic of its own because the lease lives in the daemon, and what it owes is the *absence* of a bypass — pinned by the `no_force_escape_hatch_exists_in_the_ui_surface` grep-guard test, so no `force`/`remote`/`onto` field can be added to a request without failing the build. Phase 5 closes #1585: `session_layout.rs` persists the pane *shape* only (`~/.omni-dev/worktrees-ui-layout.yaml`, same `0600`/`0700`/atomic-rename convention as the row-colour store) — a restored tab is a **new** child (ADR-0072 §2), and restoration is silent and lossy by design so a convenience file can never fail a startup; the session picker labels rows with `relocate::transcript_preview` (head-of-file only, schema-tolerant, never logged) instead of a UUID; and `alt-⇧c` retires the module's last `dead_code` allow. **#1602 adds right-click context menus** on all four surfaces (tree rows, tab strips, terminal grids, splitter/global chrome). `menu.rs` holds the per-surface contents as **pure functions of small inputs**, so "the strip menu hides *Move to Group Above* with no group above" is a direct unit test; the tree menu is the exception, built from `applicable_action_groups` so it cannot drift from the `a` menu and `:` palette. `popup.rs` owns `place` (anchor + flip + clamp, pure) and `draw_menu`, which **returns the `PopupRegion` it just drew** so the hit map and the frame cannot disagree — the invariant `GroupRegion::tab_spans` already keeps. `popup_mouse_enabled` is the single predicate naming the one popup kind that takes the mouse; the confirm modal stays keyboard-only because its `y`/`n` gates destructive actions. Contract §6 and §7 are added to `mouse.rs`: **the right button opens a menu and never inspects modifiers** (four terminals, four conventions — Terminal.app forwards both, Ghostty plain-right, iTerm2 shift-right, VS Code both plus its own menu — so one non-rule is correct everywhere *because* it does not branch; a `Shift` test in either direction breaks exactly one), and a menu is modal over what it covers, its dismissing click consumed. `alt-m` is the **primary** gesture since no right-click gesture works everywhere; `F10` is handled tree-side in `app.rs`, deliberately not as a `ChromeKey`, because `encode_key` already sends it to a child and Alt-chords are the only chrome keys. *Paste* is absent by decision, pinned by the `no_host_clipboard_read_exists_in_the_ui_surface` guard — the other half of `Osc52::OnlyCopy`, so neither a child nor the UI can read the clipboard. **#1605**: `TerminalTab::shutdown` reaps the child's whole **process group** (`SIGHUP` → 250ms → `SIGKILL`) *before* joining, because `tty::Pty`'s drop `wait()`s with no deadline and the runtime waits for blocking tasks — so a child ignoring `SIGHUP` could keep the process alive after the terminal was already restored. **#1611** found a second cause of the same class of hang: a killed `/usr/bin/login` child (a `TabKind::Shell` tab on macOS) can wedge in the kernel's own exit teardown, which nothing in-process can bound. The join-then-drop now runs on a detached `std::thread` rather than `tokio::task::spawn_blocking`, since dropping the `tokio` runtime waits for outstanding blocking-pool tasks but not detached threads — verified with a 20-run `cargo test --lib worktrees::ui` soak (0 hangs, versus roughly 2 in 3 before).
- `editors/vscode/` — the companion VS Code extension (TypeScript; the daemon's first non-omni-dev client, #1111): a thin per-window reporter plus the tree-view UI. `src/socket.ts` recomputes the socket path (`dirs::data_dir()`) and holds the NDJSON client + envelope builders (including `closeCheck`/`close`; no `vscode` import, so it is unit-tested with `node --test`); `src/extension.ts` is the activate→register / ~10s heartbeat (**close self on `close:true`**, else re-register on `known:false`) / deactivate→unregister lifecycle. The tree view adds two context-menu commands (#1277) routed by an enriched `contextValue` (`worktreeContextValue` encodes `is_main` as `.main`/`.linked`): **Close Worktree** (linked; phase-1 check → conditional modal confirm → phase-2 execute in `withProgress`) and **Close Window** (main tree; close-only, never a delete). #1407 adds **Reposition Windows to Match** (the `4_layout` group, gated `viewItem =~ /worktree\.open\./` so it never offers the invoking window, plus a `canReposition` context key set from `process.platform === "darwin"`) and a palette-visible **Undo Reposition Windows**; the handler reuses `partitionByWindow`/`partitionSelfLast` (no new tree code), fires with **no modal**, and toasts a summary carrying an **Undo** action, branching on the reply's `trusted` field to offer an "Open Accessibility Settings" deep-link. #1415 turns **Rebase on main** (`4_git`, every worktree row) from a terminal shell-out into a socket client — `rebaseCommand.ts` is now a thin `vscode` adapter over the pure, `node --test`-ed `rebaseReport.ts` (modal detail, skip/summary text), taking `send`/`windowKey` injected from `extension.ts` since those are module-private there; `omniDev.ts` (the CLI binary resolver + argv/command-line builders) is **deleted**, so a daemon-down rebase now errors like every other action and names `omni-dev worktrees rebase` as the way out. #1438/[ADR-0060](docs/adrs/adr-0060.md) drops the linked-only gate and the client-side main-tree pre-filter, so a main working tree row is offered and rebased like any other target. #1430 adds **Copy PR URL** (`3_copy@2`, beside Copy Directory and gated identically on `viewItem =~ /^(repo|worktree)/` — *not* the `/github/` gate the two Open commands use, since a non-GitHub or detached row's truthful answer is the placeholder): `prClipboard.ts` is the pure, `node --test`-ed line builder and `prCommands.ts::copyPullRequestUrls` the thin adapter. Its one structural difference from `selectPullRequests` is that it reports per selected **row**, not per discovered PR — discovery stays per *scope*, so the outcomes are joined back onto the rows by the newly-exported `prScopeKey` — which is what lets a PR-less row emit a `#`-commented placeholder instead of vanishing, and a *failed* lookup emit a distinct comment so it can never read as a settled "no PR". URLs dedupe across the block, placeholders never. #1442 adds **Open GitHub Repository** (`0_open@2`, beside Open Worktree — an *open* action, not a PR one, so deliberately outside `1_pr` — gated `viewItem =~ /^repo\.github/`, the `repoContextValue` prefix, so it is offered on GitHub **repo rows only**): `github.ts::repoWebUrl`/`repoWebUrlsForNodes` are the pure builders and `repoCommands.ts::openGithubRepository` the thin adapter. It is the cheapest action in the view — the URL is a pure function of the snapshot's `github` identity, so alone among the GitHub actions it costs **no `gh`, no subprocess, no network and no daemon op**, and needs no progress notification or confirm. A worktree row in a multi-selection still resolves to its parent repo and dedupes by URL, since a `when` clause is evaluated against the *clicked* row only. Bundled with esbuild → `dist/extension.js`, packaged to `.vsix` by `.github/workflows/vscode-extension.yml` (path-filtered on `editors/vscode/**`). The Rust build never descends into `editors/` — it is single-crate and `editors/` is in `Cargo.toml`'s `exclude`, so `cargo publish` never ships it. Marketplace/Open VSX publishing is a deferred follow-up (needs a publisher account + CI secrets).

**Three destructive ops, one new OS trust surface:** requests ride the daemon's existing `0600` Unix socket; **no secret is persisted** (in-memory only, the close directive and the rebasing set included); the companion is the daemon's first non-omni-dev client. The `rebase` op **rewrites git history** and spends the user's ambient `ssh-agent` credentials ([ADR-0059](docs/adrs/adr-0059.md)) — same-user-bounded, daemon-re-validated on execute, rewriting only branches the client names (main working tree included, since [ADR-0060](docs/adrs/adr-0060.md) — the `is_main` guard is `close`'s alone now); its one genuinely new residual is that `keep_conflicts` lets a socket request leave a worktree **mid-rebase** (intended, git-recoverable, and cued durably in the tree). The `push` op ([ADR-0061](docs/adrs/adr-0061.md)) is the first to **write** to a remote with those ambient credentials — the largest escalation so far, since it publishes rewritten history — but the mitigation that carries the weight is not ours: the lease is enforced by `git`, and with no force escape hatch in the surface the daemon cannot overwrite a remote tip it has not seen. The `close` op deletes files and closes windows — a real threat-model escalation ([ADR-0049](docs/adrs/adr-0049.md)) but same-user-bounded and guarded in the daemon (`git2`-enforced real-linked-worktree-only, main-tree removal refused). The `reposition` op adds no socket capability but needs the macOS **Accessibility** grant — coarse enough that the capability the daemon *holds* far exceeds the one it *uses*, so the narrowness (titles/geometry/flags read, position+size written, own-user processes named by pid, never input synthesis or a raise) is enforced in our code, in one `#[allow(unsafe_code)]` module behind a trait ([ADR-0058](docs/adrs/adr-0058.md)). The grant is opt-in, cannot be self-granted, needs a `daemon restart` to take effect, and is **invalidated by every upgrade** because omni-dev installs unsigned. Keep [ADR-0040](docs/adrs/adr-0040.md), [ADR-0048](docs/adrs/adr-0048.md), [ADR-0049](docs/adrs/adr-0049.md), [ADR-0058](docs/adrs/adr-0058.md), [ADR-0059](docs/adrs/adr-0059.md), [ADR-0060](docs/adrs/adr-0060.md), [ADR-0061](docs/adrs/adr-0061.md) and the operator guide + companion contract [docs/worktrees-service.md](docs/worktrees-service.md) in sync, and run the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill on any CLI-surface change (see Code Changes §5).

### Sessions service
The daemon's **fourth** service tracks the Claude Code, Codex and pi.dev sessions running across **every** terminal and VS Code window for the logged-in user, with a coarse live state (working/idle/waiting) — something no single vantage point sees (a hook knows only its own process; a VS Code window is sandboxed per host; the transcript files carry no live state alone). Mirrors the worktrees engine/adapter split and is **account-agnostic, in-memory only**. Fed by seven independent, gracefully-degrading feeds: three that **infer** state ([ADR-0052](docs/adrs/adr-0052.md)), the **authoritative** stream wrapper ([ADR-0057](docs/adrs/adr-0057.md), since #1406), the pi.dev extension (#1901), the Codex hooks (#1907), and the Codex wrapper (#1910).

- `src/sessions.rs` — the engine: `SessionsRegistry` (standalone `crate::sessions`, like `crate::worktrees`). Holds two `std::Mutex`-guarded maps **never held across `.await`** — live sessions keyed by Claude `session_id`, and companion window-embedding reports keyed by window key — plus the whole state machine ([`SessionState::for_event`]): `SessionStart→starting`; `UserPromptSubmit`/`PreToolUse`/`PostToolUse`/transcript-growth `→working`; `Stop→idle`; `Notification` classified `→waiting_for_permission`/`waiting_for_input`; `SessionEnd→ended`. `waiting_for_*` is reliable (direct `Notification`); `working`/`idle` is best-effort inference (Claude ships no state event — anthropics/claude-code#43058 *not planned*). Liveness = a TTL on a monotonic **awake clock** (#2108: `last_active`, an `Instant` that does not advance across system sleep, so a laptop sleep never reaps live sessions; the wall-clock `last_seen` is display-only), **reap-on-read** (no background reaper), maps capped (512 sessions / 256 windows, evict-longest-silent). A session's `source` (`terminal`|`vscode{window_key}`) is resolved on read by a pure `cwd`-prefix join against the window reports. Idle sessions age out on the generous 5-min TTL (no liveness event) and re-appear on next activity — the accepted hook-based limitation.
- `src/sessions/watcher.rs` — Feed 2, the engine-owned transcript watcher (adapter starts it like `start_menu_refresh`): scans `~/.claude/projects/**/*.jsonl` every 5s for new/growing files to discover sessions predating the daemon and cover the hook-silent thinking window. Parses **only presence + size/mtime**, never the version-unstable line schema; only recently-touched files are surfaced (no flooding on first scan); it cannot decode `cwd` from the lossy encoded dir name, so watcher-only sessions have no `cwd`/`repo` until a hook enriches them (`observe` never clobbers known data with `None`).
- `src/daemon/services/sessions.rs` — `SessionsService`, the thin adapter (cheap `new()`, persists nothing): routes `observe`/`end`/`window`/`window-unregister`/`list` plus the streaming `subscribe`, enriches `repo` from `cwd` via `git2` on a blocking thread (the worktrees `git_status` precedent — engine does no disk I/O), renders the "Agent Sessions" tray submenu (each row prefixed with its agent's `Agent::display_name`, #1908) (a `focus:<session_id>` action for `vscode` sessions, reusing worktrees' `pub(crate) focus_window`) + `status()`. **No menu-cache and no snapshot-cache** (unlike worktrees): `repo` is enriched at observe time, so `menu`/`list` are pure formatting — which is also why `subscribe` (#1414) needs no `TreeSnapshotCache` equivalent (#1303) and `SessionsStream::snapshot` just calls the shared `sessions_payload` that `list` returns.
- **Push, not poll (#1414):** session cues were the last tree input each window discovered on its own `setInterval`, so two windows could disagree about a row for a full ~10 s tick with the stale one arbitrary (phase is set by whenever a window activated). `SessionsRegistry` now carries the worktrees `watch` change-notify (`subscribe_changes`/`bump`) and the adapter exposes `subscribe`. The bump is deliberately **narrower than "the payload differs"**: `SessionEntry` serializes `last_seen`/`last_event`, which churn on every hook event, and the `window` op fires per window per ~10 s — bumping on those would push to every window several times a second with the server's snapshot diff unable to suppress any of it. So it bumps only on a **consumer-visible** change (session added/ended, `SessionState` transition, a best-effort field taking a new value, a window report altering the `Source` join), and the rest — plus TTL-driven ageing — rides the server's `DEFAULT_STREAM_TICK` re-sample. `list` never bumps: it is the body of every `snapshot()`, so it would feed the stream loop back into itself.
- `src/cli/sessions.rs` — `omni-dev sessions {list,hook,install-hooks,uninstall-hooks,install-wrapper,uninstall-wrapper}` (`#[cfg(unix)]`). `list` is a read-only socket client; `hook` is the **feed sink** Claude Code runs (reads hook JSON on stdin → `observe`/`end` op → fire-and-forget POST; **infallible by design** — swallows every error, always exits 0, never blocks a turn); `install-hooks`/`uninstall-hooks` idempotently merge/remove the 17-event hook block in `~/.claude/settings.json` (#1927: a moved binary's old-path sink is rewritten in place and any duplicate dropped by `sync_claude_hooks`, stale = `is_stale_claude_sink`, i.e. no args or `--agent claude` only — a `--socket`/`--agent codex` sink is customised and preserved, unlike Codex's blanket `is_sessions_sink`) (#1915 added `PermissionRequest`/`PermissionDenied`/`PostToolUseFailure`/`StopFailure`/`Elicitation`/`ElicitationResult`/`SubagentStart`/`SubagentStop`/`PreCompact`/`PostCompact`, mapped onto the existing `SessionEvent`s; `SubagentStop`/`PreCompact`/`PostCompact` are the state-preserving `TranscriptDiscovered` because each can fire while idle; `PostToolBatch` is deliberately not installed (redundant with per-tool `PostToolUse`); a `Notification` with a `notification_type` is classified by it alone; the sink must never print — stdout from a `PermissionRequest` hook is a permission decision, pinned by `tests/sessions_hook_test.rs`) (absolute-current-exe command; preserves other hooks; prunes emptied scaffolding); `install-wrapper`/`uninstall-wrapper` do the same for Feed 4 (below) — a `0700` shim in `paths::runtime_dir()` plus the `claudeCode.claudeProcessWrapper` key in VS Code's **user** settings (`dirs::config_dir()`, *not* `data_dir()`). Hook-JSON→op mapping and both settings merges are pure, unit-tested functions.
- **Feed 4 — the stream wrapper** (#1406, [ADR-0057](docs/adrs/adr-0057.md)): the only **authoritative** feed. `src/sessions/stream.rs` is the pure `StreamTracker` over Claude's `--output-format stream-json` stdio; `src/cli/claude_wrap.rs` (`omni-dev claude-wrap -- <cmd> <args…>`, top-level `#[cfg(unix)]`) is the transparent process wrapper the Claude VS Code extension launches Claude through. It reports `SessionEvent::StreamState(SessionState)` over the **existing** `observe` op — no new op, no adapter change. Load-bearing invariants: **both** stdio directions are teed (`can_use_tool` arrives on stdout, its `control_response` on stdin — one-sided parsing would wedge on `waiting_for_permission`); **fail-open is structural**, the byte pumps write-and-flush before a non-blocking `try_send` tee and never await the parser or daemon; the child's exit status is propagated by `std::process::exit` (so no request-log record); a TTY `exec`-replaces instead of observing; and **no conversation content is ever logged or persisted**. A 30s keep-alive re-reports a *busy* state (working / waiting) so a long silent turn or an unanswered prompt does not age out; an *idle* session is deliberately **not** re-reported (#1454) — the extension keeps a process per chat, not per visible tab, so process lifetime is not tab visibility, and a finished or never-prompted chat ages out on the 5-min TTL. For the same reason a session the wrapper has reported (`SessionEntry::streamed`, Claude only) is excluded from the pid watcher's TTL exemption (it still ends promptly when its pid dies).
- `editors/vscode/` — Feed 3 extends the **existing** worktrees companion (not a new extension): `src/claudeEmbeddings.ts` (pure, no `vscode` import, `node --test`-tested) counts Claude editor tabs (`viewType.includes("claudeVSCodePanel")`) and terminals (name match, honoring `$CLAUDE_CODE_TERMINAL_TITLE`); `src/socket.ts` adds `sessionWindow`/`sessionWindowUnregister` envelopes; `src/extension.ts` reports its window's embedding **counts** (never a tab's `session_id` — the Claude extension exposes no API, ADR-0052) to the sessions service on activate / the same ~10s heartbeat / tab-&-terminal-change events, and unregisters on deactivate. The daemon joins sessions→window by `cwd`, tagging `source`. Since #1406 it is also a **consumer**: `src/sessionCounts.ts` (pure, tested) tallies the `list` reply onto worktree rows by innermost-`cwd`-prefix and decides the row's glyphs (`!`/`⚙`/`◦`) and colored badge (waiting > working > idle). A single `FileDecoration` provider (`decorations.ts`) paints only the PR-check badge; sessions already show their complete glyph counts in the row description and tooltip. Sessions still contribute to the **severity-ranked** row colour via `tree.ts::rowColorId` (red = checks failing > yellow = checks pending or session waiting > green = checks passing or session working > muted = idle). Session-only rows receive colour and a tooltip without a badge; the decoration tooltip prefers checks when present, while the tree-item tooltip retains the session breakdown. The tally rides the same `resourceUri` query trick the check state uses, and `setSessionTallies` **must** no-op when unchanged: firing `onDidChangeTreeData` re-runs `getChildren`, which re-triggers the lazy ahead/behind + PR fetches. Since #1414 that tally is fed by a **second push subscription**, not a poll: `subscription.ts`'s connect/backoff machinery is now the generic `DaemonSubscription<T>` (envelope + payload guard injected) with `TreeSubscription` and the new `SessionsSubscription` as thin bindings, and the ~10 s `list` poll survives only as the **version-skew fallback**. That fallback is load-bearing and needs its own signal: an older daemon *answers* `subscribe` with `{ ok: false }` and then **holds the connection open**, so the new `onUnsupported` option fires on a non-`ok` frame, stops reconnecting (a retry earns the same refusal), and hands control back to the poll — re-attempted once when the tree stream reconnects, since a daemon upgrade lands as a restart.

- **Feed 5 — the pi.dev extension** (#1901): pi has no hook-command block, so `install-hooks` also writes a generated TypeScript extension (`src/templates/pi-sessions-extension.ts`, socket path baked in) to `~/.pi/agent/extensions/omni-dev-sessions.ts` (`$PI_CODING_AGENT_DIR` honoured), **only when pi is present** (agent dir exists or `pi` on `PATH`; `--pi-agent-dir` forces it), and `uninstall-hooks` removes it. Install and uninstall act only on a file carrying the template's first-line marker. The extension maps pi's lifecycle events (`agent_settled` → idle, `ui_prompt_*` → waiting_for_input, `session_shutdown` → `end`) to the existing authoritative `StreamState` over the socket from pi's own Node process: fail-open, serialized in order, state-change-only plus a 30s keep-alive. It never reports `waiting_for_permission`, since pi has no per-tool approval prompt. Sessions carry an additive `agent` tag (`claude` default, omitted on the wire; `pi`). Keying stays `session_id` alone because pi's UUID v7 and Claude's v4 cannot collide.

- **Feed 6 — Codex hooks** (#1907, [ADR-0087](docs/adrs/adr-0087.md)): Codex's hook payload uses Claude's field names, so it reuses the Feed 1 sink as `sessions hook --agent codex` (a `HookAgent` clap enum; the agent cannot be inferred from the payload), which tags sessions `Agent::Codex` and maps through `codex_session_event_for` onto the **existing** `SessionEvent`s: `PermissionRequest` → `Notification(PermissionPrompt)`, `PreToolUse` of `request_user_input` → `Notification(AgentNeedsInput)`, `Interrupt` → `Stop`, `SubagentStart` → `PostToolUse`, and `SubagentStop`/`PreCompact`/`PostCompact` → the state-preserving `TranscriptDiscovered` (#1928: each can fire on an idle session, and nothing afterwards would release a `working`). `install-hooks` also writes `$CODEX_HOME/hooks.json` (same shape, so `merge_hooks` is reused over a `CODEX_HOOK_EVENTS` `HookSpec` table with `timeout: 3` on `SessionEnd`/`Interrupt`) when Codex is present (`--codex-home` forces it). Codex trusts a hook by `<event>:<group>:<hook>` **index and content** and skips an untrusted one silently, which makes two rules load-bearing: install rewrites every other `omni-dev sessions hook` entry (`is_sessions_sink`: any `omni-dev` path, any args — an untagged one would race the tagged one to fix the tag as `claude`, and an old-path tagged one is what an upgrade leaves) **in place** (`replace_stale_sinks`), and uninstall uses `remove_hooks_stable`, leaving an empty `{ "hooks": [] }` placeholder wherever it empties a non-trailing group so no surviving group shifts index and loses its trust (a shift *within* a shared group is unavoidable, so it is reported as `Removal::shifted` and the user told to re-trust). Both print the one-time `/hooks` trust step. Codex ids are UUID v7 like pi's, so single-key identity rests on v7's random bits, not the version nibble. Version skew: a daemon with the two-value `Agent` rejects `"codex"`, and the fail-open sink drops it silently until the daemon restarts onto the new binary. The **Codex rollout watcher** (`src/sessions/codex_watcher.rs`, #1909) is Feed 2's Codex twin, spawned by the same `start_watcher` under one cancel token: it reads only a rollout's bounded head line (`session_meta` → id, `cwd`; an object `source` marks a subagent, skipped) for discovery, ends a session whose rollout leaves `$CODEX_HOME/sessions/` (archiving *moves* it), and uses `$CODEX_HOME/thread-writer-locks/<id>.lock` as process liveness — Codex holds an exclusive `flock` while a thread is loaded and the kernel drops it on any death, so a lock **seen held** then free/absent ends the session, a lock already free/absent on **first sight** is recorded but not announced (a restart must not list ended sessions), a missing `thread-writer-locks` dir is `Unknown` (lockless Codex: rollouts only), and a held lock heartbeats an idle session past the TTL. The registry ignores a `TranscriptDiscovered` or a second `end` on an `Ended` entry, so neither feed restarts its linger window. It never reports a state (only the state-preserving `TranscriptDiscovered`): rollout growth continues around a turn's `Stop`, so reading it as `working` would pin idle sessions. The probe is `nix` `Flock` `LockSharedNonblock` (MSRV 1.88 predates `File::try_lock_shared`), dropped at once.

- **Feed 7 — the Codex wrapper** (#1910, [ADR-0088](docs/adrs/adr-0088.md)): `omni-dev codex-wrap -- codex [args]` (`src/cli/codex_wrap.rs`, top-level `#[cfg(unix)]`, the `claude-wrap` analogue) starts a **private** `codex app-server --listen unix://<runtime-dir>/codex-wrap-<pid>.sock`, runs `codex --remote unix://… <args>` in the foreground (it cannot exec-replace: it owns the server's lifetime — the server outlives a disconnected TUI), and polls it through `src/sessions/codex_app_server.rs` (JSON-RPC over a WebSocket on the Unix socket, `tokio-tungstenite` `client_async`). Load-bearing: the observer calls **only** `initialize` / `thread/loaded/list` / `thread/read` — never start/resume/subscribe, which is what routes a thread's approval requests to a connection — and drops any server request unanswered (pinned by `the_client_polls_and_never_answers_a_server_request`); it attaches to no server it did not start. `StatusTracker` maps `active` flags onto `StreamState` (approval outranks input; `idle`/`systemError` → idle; unloaded/`notLoaded` → `end`), **re-asserted every poll** (not change-only: hooks and the rollout watcher report the same `session_id` with inferred states, and a change-only tracker would leave a hook's false "waiting" up; a repeat sighting does not bump), a thread is ended only when it leaves `thread/loaded/list` (one whose `thread/read` fails is skipped that poll), `finish()` ends all on exit. Connect has a 5s timeout and both connect and each poll race the stop token, so a stalled server cannot hang the wrapper; 3 consecutive failed polls mean the server is gone. Threads that are subagents, `ephemeral`, or `threadSource` ≠ `"user"` (Codex's chat-titling side thread) are skipped. Fail-open: non-TUI subcommands (`PASSTHROUGH_SUBCOMMANDS`), no TTY, a caller's own `--remote`, or a server not listening within 10s exec-replace plain `codex`; an observer that cannot connect leaves the TUI unobserved. `worktrees ui` gains `TabKind::Codex` (`alt-⇧x` / **New Codex Tab**; plain `alt-x` stays with the child) launched through it.

- **Hook journals** (#2108, amends [ADR-0052](docs/adrs/adr-0052.md)): the sink appends every event to `<socket dir>/sessions/<agent>/<session_id>.jsonl` (`src/sessions/journal.rs`; `0700` dirs, `0600` files, one `O_APPEND` line, session id must be a canonical UUID, **fail-open**, never any prompt/tool/transcript content) *before* its POST, and `src/sessions/journal_watcher.rs` replays them at startup and polls them every 5s. **Hooks own the events; the daemon owns the state:** a hook never computes a state, records are fed through the registry's `observe_stamped`/`end_stamped`, so the same state machine decides. A `stamp` (`ts` + `seq`) rides the POST too, and the registry drops a duplicate `seq` (last 32 per session) and — journal route only, never the socket — an event older than one already applied. A socket event is dropped as out of order only within a 10s window (the adapter awaits a git lookup, so the journal can overtake it; a wider window would let a clock step freeze a session). A journal is judged on first sight in a scratch registry: ended or older than 7 days → deleted; otherwise it needs proof of life (a held Codex writer lock, or the owning pid running and started no later than its first journaled event) or a last event within the session TTL; a *free* Codex lock is proof of death, an *absent* one is not (wrong `CODEX_HOME`). The sink bounds itself (no journaling until the runtime dir exists, a 512 KiB append cap, a 7-day sibling sweep when it starts a journal), since it cannot know a daemon is tending them. A dead/unreadable pid is *absence* of proof, never proof of death (it may be a per-hook shell). Cleanup, compaction (128 KiB → ~64 KiB tail plus the newest dropped prompt, atomic rename, abandoned if the file grew) and orphan sweeping are the daemon's; only regular `<uuid>.jsonl` files are touched and a symlink is never followed. Non-hook feeds (wrappers, pi, watchers, window reports) stay socket-only.

**No new trust boundary:** ops ride the daemon's existing `0600` Unix socket; **no secret is persisted** (in-memory only). Residual exposure: a socket reader can enumerate session cwds/repos + coarse state, a writer can inject fake sessions — both already require the owning local user. Hooks and the wrapper are both opt-in user config; the wrapper writes nothing but the socket POST, and the sink writes nothing to stdout and, besides the POST, only its session's journal (below, #2108). The wrapper is the one component that *sees* conversation content — extracting only state/`session_id`/`cwd`/model and persisting nothing is a design constraint, not a convention. Distinct from #876 (history-search MCP; shares the transcript dir). Keep [ADR-0052](docs/adrs/adr-0052.md), [ADR-0057](docs/adrs/adr-0057.md) and the operator guide + hook/wrapper/companion contract [docs/sessions-service.md](docs/sessions-service.md) in sync, and run the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill on any CLI-surface change (see Code Changes §5).

### Request log
A local, append-only NDJSON log (`log.jsonl`) of every invocation **and** the HTTP requests it issues, plus an `omni-dev log` subcommand to search and pretty-print it. Best-effort and default-on; a write failure never changes the caller's exit code.

- `src/request_log.rs` — the schema (one forward-compatible `LogRecord` for both write and read, same `#[serde(default)]` + `skip_serializing_if` contract the daemon wire types use), the `record`/`record_http`/`record_invocation` writers, path resolution (`OMNI_DEV_LOG_FILE` → `state_dir`→`data_dir`/`omni-dev/log.jsonl`, dir `0700`/file `0600`), the per-invocation `RequestLogContext` (process-global `OnceLock` + a `tokio` task-local override) that lets HTTP records inherit their parent `invocation_id`/`source`/`mcp_tool` without threading state through call sites, and the two opt-in growth bounds (#1121): env-gated unix-only rotation-on-write (`OMNI_DEV_LOG_MAX_SIZE`/`OMNI_DEV_LOG_KEEP_FILES`, serialized on a stable `log.jsonl.lock`) and the `prune` engine (age/size filter → atomic same-dir temp-file rewrite).
- `src/cli/log.rs` + `src/cli/log/{format,query,stream,prune}.rs` — the `omni-dev log` command: with no subcommand, the read-only search (the filter matrix, the `--query` mini-language — AND/OR/NOT, `field:value`, fuzzy tokens; `oneline`/`json`/`full` renderers where `json` is byte-identical to the on-disk lines; the streaming reader with a `--limit` ring buffer and `-f/--follow`); the `prune` subcommand (`--older-than`/`--max-size`/`--dry-run`) bounds on-disk growth via `request_log::prune`.
- The invocation record is emitted by the [`main.rs`](src/main.rs) shell around `cli.execute()`; HTTP records are emitted from one hook per transport method (Atlassian, Datadog, Snowflake `send_once`, the Claude/AI backends + `claude-cli`, and the browser bridge `dispatch`/`start_stream`). The MCP server's hand-written `call_tool` scopes the task-local context to `source=mcp` per tool call.

**No secret is ever written:** auth headers are redacted centrally and request/response bodies are opt-in via `OMNI_DEV_LOG_BODIES` (headers via `OMNI_DEV_LOG_HEADERS`); `OMNI_DEV_LOG_DISABLE` is an absolute opt-out for `log.jsonl` only — the fail-closed audit sink (`audit.jsonl`, `record_audit`) is a separate, forensic log deliberately exempt from it and from rotation/pruning; see [docs/log.md](docs/log.md#audit-log). **No new trust boundary** — the log is local-machine state with the same `0700`/`0600` posture as other runtime state. Keep the operator guide [docs/log.md](docs/log.md) in sync, and run the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill on any CLI-surface change (see Code Changes §5).

### Jev (TypeSafe System One)
`omni-dev ai jev {choice,score,noul,ask,route}` (#1760, #1779) is a client for TypeSafe AI's **Jev** "System One" API. Jev returns *typed probabilistic judgments*, not generated text: you send one opaque `state` plus a map of named questions to `POST /v1/systemone`, and it answers all of them in one parallel pass. It mirrors the Datadog engine/CLI split.

- `src/jev/` — the engine: `protocol.rs` (wire types), `config.rs` (`JevConfig`: `TYPESAFE_API_KEY` → `OMNI_DEV_JEV_API_KEY`, `OMNI_DEV_JEV_BASE_URL`, `TYPESAFE_MODEL`, default `jev-latest`; read through an injected `EnvSource` — `from_env_with` — per STYLE-0028, so env → `settings.json`/profile fallback comes free), `client.rs` (`JevClient::system_one`, the one call), `error.rs` (`JevError`, its own `thiserror` enum per the `DatadogError` precedent rather than `ClaudeError`).
- `src/cli/ai/jev.rs` + `src/cli/ai/jev/{choice,score,noul,ask,common}.rs` — the CLI surface. `choice`/`score`/`noul` each send a one-entry question map under the fixed key `answer` and print `{model, answer, usage}` unwrapped; `ask` sends a caller-named map read from a `--questions` YAML/JSON file (one `serde_yaml` parse path, no extension branching; file-only so the state keeps stdin) and prints `{model, answers, usage}`. `model`/`usage` are never stripped — there is no raw mode, so this is the only cost/version surface.
- **Not an `AiBackend`.** That enum dispatches chat-completion clients behind one `AiClient` trait; Jev has no messages, no streaming, no registry token limits, and no structured-output negotiation. So it is not selectable via `--ai-backend`, commit/PR generation never uses it, and it lives in `src/jev/`, not `src/claude/`.
- **Internally-tagged serde enums** (`#[serde(tag = "type", rename_all = "lowercase")]`) for `Question` and `Answer`, because the shapes genuinely differ per primitive: `choice` `criteria` is a map, `score` an ordered `Vec`, `noul` optional `{"true","false"}`; and a `noul` **answer has no `confidence`**, which was verified live and is undocumented upstream. Deserialisation stays strict on an unknown `type`: `#[serde(other)]` only works for unit variants in an internally-tagged enum. A new *field* is ignored for free.
- **`BTreeMap`, never `HashMap`**: neither `indexmap` nor serde_json's `preserve_order` is a dependency, so a `HashMap` would reorder output every run. The documented cost: `choice` options come out alphabetised, and `score` `legend`/`probabilities` keys sort lexicographically, which would put `"10"` before `"2"` at 11+ levels; `jev-1.13.0` caps a score at 10 levels (keys `"0"`–`"9"`), so it is latent until a model raises that cap.
- **`retry_if(.., |s, _| s == 429 || s == 529, ..)`, not `retry_429`**, because `retry_429` would silently not retry Jev's 529 Overloaded. It also rebuilds the POST body per attempt. Timeouts are the REST-client `connect_timeout`/`read_timeout` (`OMNI_DEV_HTTP_*_TIMEOUT_SECS`), **not** the AI backends' 300s `OMNI_DEV_AI_TIMEOUT_SECS`. Every attempt is logged via `request_log::record_http_result("jev", ..)`, with auth redacted centrally.
- **Model knob:** `--jev-model` → `TYPESAFE_MODEL` → `jev-latest`. `JevConfig` **never reads `OMNI_DEV_MODEL`**, so a user exporting it for the AI backends does not break Jev (pinned by `omni_dev_model_is_ignored`). `--model` is no longer a root-global flag (#1778) — it and the rest of the AI backend flags (`--ai-backend`, `--beta-header`, `--claude-cli-*`, `--models-yaml`), plus `--instance`, are simply not attached to the `ai jev` subtree (and `--repo` only to the `route` leaf), so passing any of them is a clap "unexpected argument" error rather than a silent no-op. The flag keeps its `--jev-model` name (it was chosen when `--model` was a reserved global arg id, #1420; renaming it is out of scope). `omni_dev_model_is_ignored` still pins the one residual footgun: an exported `OMNI_DEV_MODEL` is silently ignored by Jev, and that is called out in the group's `long_about`.
- **`JevFormat`, not `OutputFormat`**: `src/cli/ai.rs` already re-exports an `OutputFormat` (`Text | Yaml`, from `claude::skills::common`), so do not re-export `JevFormat` from `ai.rs`. The flag is **`-o/--output` only** (`json` default, `yaml`), with no hidden `--format` alias, since a new command has no legacy to deprecate.
- **State** defaults to a literal JSON string; `--state-json` opts into a YAML/JSON parse, because blind-parsing would reinterpret plain text starting with `{`.
- **STYLE-0025:** each leaf's logic (option/level validation, questions-file read, answer unwrapping) lives in a `run_*(client: &JevClient, ..)` that the wiremock tests drive; `execute()` only resolves config and builds the client.
- **`route`** (#1779, PR #1787) asks three `choice` questions per issue (design / implement / review) and reports `class` = the higher of design and implement. Engine in `src/jev/route.rs`; the question wording and the `anthropic` tiers are embedded from `src/templates/jev-route-questions.yaml` and `src/templates/jev-route-tiers-anthropic.yaml` and are the **exact text validated against `jev-1.13.0`** — `stage_questions_are_the_tested_wording` pins them, so an edit must be deliberate and re-evaluated. `--ladders` (#1820, generalized from a closed `--providers` enum to combinable named ladders in #1826) routes against several ladders at once in the **same** call: each name resolves to either a built-in `Provider` (`anthropic`/`openai`/`gemini`, one `jev-route-tiers-<provider>.yaml` each) or a custom one registered via `--ladder-definition NAME=FILE` (same tiers-file shape the removed `--tiers` used), both wrapped as a `Ladder { name, tiers }` — the questions are keyed `<name>.stage_<stage>` and output nests under `providers.<name>`. `build_ladders` (`src/cli/ai/jev/route.rs`) resolves built-ins before custom definitions, rejects an unknown name (listing the known ones), rejects a `--ladder-definition` name that isn't `[a-z0-9_-]+` or that shadows a built-in provider name (reserved), rejects a duplicate definition, and rejects a definition never referenced by `--ladders` (treated as a likely typo, not silently ignored) — `validate_options` in `src/jev/route.rs` still separately rejects an empty or duplicate ladder list generically, regardless of source. The `openai`/`gemini` descriptions are the `anthropic` text rung for rung (pinned by `openai_and_gemini_ladders_reuse_the_anthropic_descriptions_rung_for_rung`) because rewording shifts answers; only the tier names differ, and neither they nor multi-ladder keying have been evaluated. `build_route_state` reproduces the evaluated input format (the `## Comments on this issue` form, *not* the issue body's first proposal; the one difference is that bot comments are dropped, which the evaluation script did not do) and deliberately omits referenced issues. Over `--max-input-chars` it keeps the first and last halves around a `[... truncated]` marker (`crate::jev::input::truncate_middle`, shared with `verify-decision`), and the fetch takes the *latest* 100 comments, both because a late decision comment is what moves routing most. Both `route` and `verify-decision` fetch through `github_issues::fetch_issues_cached`/`fetch_items_cached` (#1858): a flat-file, per-item TTL cache (`src/github_issues/cache.rs`, `IssueCache`; `<cache_dir>/omni-dev/github-issues/<account>/<owner>/<repo>/<number>.json`, where `<account>` is a digest of `gh auth token` so a switched `gh` login or another host never reads an entry it could not have fetched, and no `gh` login at all leaves the cache off; the project is lowercased in the path, `OMNI_DEV_GITHUB_CACHE_TTL_SECS` default 300, `0` disables, `--refresh` skips reads but still writes). It was chosen over a daemon op because the usage is *sequential* re-runs, so it needs no daemon fallback path. The uncached `fetch_issues`/`fetch_items` stay unchanged beneath it. `route` alone fetches through the `*_cached_current` variants (#2041), because it acts on `state` (`--ignore-closed`, the closed refusal, open dependencies): a cached copy is served only if its `state` still matches GitHub's, taken from the `--all-open` listing (no request) or one shallow `fetch_states` call per call site for the cache hits (`IssueCache` remembers items already verified or stored this run, so a routed issue that is also cited is not checked twice), and a mismatch — or a failed check — is a miss fetched in full. The two fetchers share entries, since they build identical `IssueDoc`s for an issue, but `fetch_issues_cached` refuses a cached pull request so that error still comes from GitHub. `verify-decision` fetches its **judged** issue through `fetch_issues_refreshed` (always from GitHub, then written through), because a cached copy would silently select an older decision comment; only its cited sources are served cached. Not-found results are never cached, and every read/write failure degrades to a miss. A lookup never deletes; the per-run `prune_expired` sweep removes expired entries by file mtime (never parsing them, across every account, even at TTL `0`) and temp files a crashed write orphaned, since they hold private-repo text. `route` and `verify-decision` add a `github_cache` object to their JSON/YAML output (via `common.rs::format_output_with_cache`) when anything was reused. Items the run itself stored are served but not counted in the stderr reuse note. A failed Jev call becomes that issue's `error` (`RouteOutcome::Failed`) and the run continues, exiting non-zero at the end; only a 401/403 aborts (`crate::jev::error::is_auth_failure`, shared with `verify-decision`), since it would fail every issue. The fetch goes through a provider-neutral issue model — `src/provider.rs` (`GitProvider` with only the GitHub side, `IssueDoc`, `ItemRef`), the minimal slice of #1573 §3.1 — filled by `src/github_issues.rs`, which batches aliased `gh api graphql` queries through `pr_status::run_gh_graphql` (so every call is counted and logged) and filters bot comments by GraphQL `__typename == "Bot"` (GraphQL strips the `[bot]` login suffix).
- **`verify-decision`** (#1779) is the one `ai jev` command using **both** Jev and an AI backend: it splits a decision comment into single statements with the configured AI backend, then checks each statement against the source it cites with Jev — one `noul` call per source, covering every statement attributed to it — plus one coverage call asking whether the statements together cover the comment. Engine in `src/jev/verify.rs`. `find_citations` recognises `#N`, `PR #N`/`pull request #N`, `owner/repo#N`, and full GitHub issue/pull URLs via one combined regex, ordered so the `PR #N` alternative consumes the match before the bare-`#N` alternative can, and so a catch-all `other_url` alternative swallows any non-issue `http(s)://` link before its path or fragment can be re-read as a citation (`docs/jev.md#4-state-input` otherwise parsed as repo `docs/jev.md`, a spurious unresolved citation; since #2001 `fetch_items` tolerates a repo-level `NOT_FOUND` like a per-item one, resolving every citation into that repo to `None`, so a bogus `owner/repo#N` no longer aborts the whole run). The `regex` crate has no lookaround, so a trailing-boundary post-check (`ends_at_boundary`, shared through `first_citation`) rejects a number running into `[0-9A-Za-z_-]`: without it `#1f77b4` and `#1-overview` both resolved to issue #1 and became a Jev source. `resolve_cites_index` re-parses a split statement's raw `cites` string the same way rather than string-matching a source's display label. `group_sources` merges a cited issue with the pull requests that closed it into one source (matching #1779's validated experiment E4's `SOURCES`), or makes a standalone source for a cited PR that closed nothing cited; `github_issues.rs::fetch_items` resolves a citation as an issue or PR from GitHub's `__typename`, tolerating a per-citation or per-repository not-found rather than failing the whole call (`fetch_issues` still bails, since a missing *judged* issue is a hard error). The per-statement question wording and source-text format are `jev_eval.py`'s E4 verbatim (byte-for-byte, including a single-newline comment separator that differs from `route`'s two); the splitter prompt and the coverage question are new. **The splitter deliberately sends no JSON schema** — plain `ClaudeClient::send_message`, with `build_split_user_prompt` describing the reply shape and `parse_split_response` accepting JSON or YAML (and a stray code fence). A live run of the issue's own 25 claim comments, twice each, measured why: with the schema attached, `claude-sonnet-5` degenerated into unusable output (`placeholder`, several statements run into one `text`) in **22 of 50** runs, against **0 of 50** with the same prompt and no schema (44% → 0%). Two prompt lines are load-bearing for the same reason and are pinned by `split_prompt_keeps_the_two_additions_that_fixed_live_failures`: rule 6's second paragraph (a claim that a cited item *resolved the judged issue's question* is about the judged issue, so `cites` is null — checking it against the cited item instead rejected 3 of 10 accurate comments), and naming the judged issue as "this issue" rather than by number (giving the number made the splitter write it into statements, which the coverage check then read as added content, dropping 9 faithful splits below the threshold). Verdict: any statement below `--reject-below` rejects; anything uncertain, an unresolved citation, an *unanswered* statement (its source's call failed, so a partly checked comment is never `accepted`), or low coverage is `needs_review`. A `cites` naming the judged issue is treated as rule 6's `null` rather than as an unresolved citation, since the judged issue is deliberately never a source. **Coverage alone never rejects**, and the same live run is why: of its 25 failures across 200 runs, 8 were on *accurate* comments, and every one was caused by a bad split (a degenerate reply, or wording the comment did not use) rather than by a false claim — it is a signal about the splitter, not about the comment. It still gates `accepted`, and on hand-built splits it caught every added or strengthened claim (≤0.22) and every softened one (≤0.44), which is the only guard against a splitter softening an overstatement into something the source supports (those soften-throughs score 0.52–0.98 per statement). It misses some dropped claims (2 of 5 scored 0.68–0.77), a further reason not to let it reject; a comment citing nothing verifiable skips every Jev/AI call and is `needs_review`. As the first `ai jev` leaf needing an AI backend, it flattens `AiBackendArgs` (the `ai_client_builders_are_exactly_the_ai_backend_args_owners` grep guard in `ai_backend_args.rs` enforces this) and is a second exception, alongside `route`, to `ai jev`'s "no `-C/--repo`" rule. The MCP tools from #1779 remain a follow-up.

**No new trust boundary.** It is an outbound REST client like Datadog, and the key is held as `utils::secret::Secret`, so no ADR is needed. Keep the operator guide [docs/jev.md](docs/jev.md) in sync, and run the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill on any CLI-surface change (see Code Changes §5).

### Skill Structure
Claude skills are organized in `.claude/skills/`, one subdirectory per skill with a `SKILL.md` file.

### Working with Git
Common git operations in this project:
- `git log --format=%H` - Get commit hashes
- `git show --stat <commit>` - Get diff summaries
- `git branch -r --contains <commit>` - Check remote branch containment
- `git status --porcelain` - Get working directory status

## Testing Approach

### Test Types
- **Unit Tests**: In `src/` files using `#[cfg(test)]`
- **Integration Tests**: In `tests/` directory
- **Golden Tests**: Using `insta` crate for snapshot testing

### Test Data
- Temporary git repositories for integration tests
- YAML fixtures for parsing tests
- Golden files for output validation

## Common Patterns

### Error Handling
```rust
use anyhow::{Context, Result};

fn operation() -> Result<()> {
    // Use .context() for error chain building
    some_operation()
        .context("Failed to perform operation")?;
    Ok(())
}
```

### YAML Serialization
```rust
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Data {
    #[serde(skip_serializing_if = "Option::is_none")]
    optional_field: Option<String>,
}
```

### Git Operations
```rust
use git2::Repository;

let repo = Repository::open(".")?;
let head = repo.head()?;
let commit = head.peel_to_commit()?;
```

## Troubleshooting

### Common Issues
- **Clippy Warnings**: Use suggested fixes or add `#[allow(clippy::rule)]` with justification
- **Test Failures**: Check for timing issues with git operations
- **YAML Formatting**: Ensure proper serialization attributes

### Debug Commands
```bash
# Verbose test output
cargo test -- --nocapture

# Specific test
cargo test test_name

# Debug build
cargo build --verbose
```

## References

- [Rust Documentation](https://doc.rust-lang.org/)
- [git2 Crate Documentation](https://docs.rs/git2/)
- [Clap CLI Framework](https://docs.rs/clap/)
- [Serde Serialization](https://serde.rs/)
- [Release Process](docs/RELEASE.md) - Complete release workflow

## Best Practices

1. **Read the Full Context**: Understand the existing codebase before making changes
2. **Follow Rust Idioms**: Use idiomatic Rust patterns and conventions
3. **Maintain Safety**: Leverage Rust's safety features and error handling
4. **Document Changes**: Update documentation when adding features
5. **Test Thoroughly**: Ensure changes don't break existing functionality
6. **Follow Semver**: Use appropriate version bumps for changes